initial, step 2 already

This commit is contained in:
2025-10-31 14:25:12 +01:00
commit ea17d048ad
24 changed files with 1431 additions and 0 deletions

63
.gitignore vendored Normal file
View File

@@ -0,0 +1,63 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
*.manifest
*.spec
pip-log.txt
pip-delete-this-directory.txt
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
.hypothesis/
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
cython_debug/
# Virtual environments
venv/
ENV/
env/
.venv
# VSCode
.vscode/
*.code-workspace
.history/
# IDEs
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Poetry
poetry.lock

53
PORTS.md Normal file
View File

@@ -0,0 +1,53 @@
# Port Configuration
This document describes the port allocation for the home automation services.
## Port Scan Results (31. Oktober 2025)
### Ports in Use
- **8000**: In use (likely API server)
- **8021**: In use (system service)
- **8080**: In use (system service)
- **8100**: In use (system service)
- **8200**: In use (system service)
- **8770**: In use (system service)
### Free Ports Found
- **8001**: FREE ✓
- **8002**: FREE ✓
- **8003**: FREE ✓
- **8004**: FREE ✓
- **8005**: FREE ✓
## Service Port Allocation
| Service | Port | Purpose |
|---------|------|---------|
| API | 8001 | FastAPI REST API for capabilities and health checks |
| UI | 8002 | FastAPI web interface with Jinja2 templates |
| (Reserved) | 8003 | Available for future services |
| (Reserved) | 8004 | Available for future services |
| (Reserved) | 8005 | Available for future services |
## Access URLs
- **API**: http://localhost:8001
- Health: http://localhost:8001/health
- Spec: http://localhost:8001/spec
- Docs: http://localhost:8001/docs
- **UI**: http://localhost:8002
- Main page: http://localhost:8002/
## Starting Services
```bash
# Start API
poetry run uvicorn apps.api.main:app --reload --port 8001
# Start UI
poetry run uvicorn apps.ui.main:app --reload --port 8002
# Start Abstraction Worker (no port - MQTT client)
poetry run python -m apps.abstraction.main
```

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# Home Automation Monorepo
A Python-based home automation system built with Poetry in a monorepo structure.
## Project Structure
```
home-automation/
├── apps/ # Applications
│ ├── api/ # API service
│ ├── abstraction/ # Abstraction layer
│ ├── rules/ # Rules engine
│ └── ui/ # User interface
├── packages/ # Shared packages
│ └── home_capabilities/ # Home capabilities library
├── infra/ # Infrastructure
│ ├── docker-compose.yml
│ └── README.md
├── pyproject.toml # Poetry configuration
└── README.md
```
## Requirements
- Python 3.11+
- Poetry
## Setup
1. Install Poetry if you haven't already:
```bash
curl -sSL https://install.python-poetry.org | python3 -
```
2. Install dependencies:
```bash
poetry install
```
3. Activate the virtual environment:
```bash
poetry shell
```
## Development
### Code Quality Tools
This project uses the following tools configured in `pyproject.toml`:
- **Ruff**: Fast Python linter
- **Black**: Code formatter
- **Mypy**: Static type checker
Run code quality checks:
```bash
# Format code
poetry run black .
# Lint code
poetry run ruff check .
# Type check
poetry run mypy .
```
### Running Applications
#### Port Configuration
See `PORTS.md` for detailed port allocation.
- **API Server**: http://localhost:8001
- **UI Server**: http://localhost:8002
#### API Server
Start the FastAPI server with auto-reload:
```bash
# Using uvicorn directly (port 8001)
poetry run uvicorn apps.api.main:app --reload --port 8001
# Or using the main function
poetry run python -m apps.api.main
```
The API will be available at:
- API Base: http://localhost:8001
- Interactive Docs: http://localhost:8001/docs
- OpenAPI Schema: http://localhost:8001/openapi.json
Available endpoints:
- `GET /health` - Health check endpoint
- `GET /spec` - Capabilities specification
#### UI Server
Start the web interface:
```bash
# Using uvicorn directly (port 8002)
poetry run uvicorn apps.ui.main:app --reload --port 8002
# Or using the main function
poetry run python -m apps.ui.main
```
The UI will be available at:
- Main page: http://localhost:8002
- `GET /spec` - Capabilities specification
#### Other Applications
```bash
# Abstraction
poetry run python -m apps.abstraction.main
# Rules
poetry run python -m apps.rules.main
# UI
poetry run python -m apps.ui.main
```
## License
TBD

1
apps/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Apps package."""

View File

@@ -0,0 +1,63 @@
# Abstraction Layer
The abstraction layer is an asyncio-based worker that manages device communication through MQTT.
## Features
- **Configuration Loading**: Reads device configuration from `config/devices.yaml`
- **MQTT Integration**: Connects to MQTT broker for real-time device communication
- **Async Design**: Built with asyncio for efficient concurrent operations
- **Message Subscription**: Listens to `home/#` topics for device events
- **Logging**: Structured logging at INFO level
## Running
```bash
# Start the abstraction worker
poetry run python -m apps.abstraction.main
```
The worker will:
1. Load configuration from `config/devices.yaml`
2. Connect to MQTT broker (172.16.2.16:1883)
3. Subscribe to topic `home/#`
4. Log "Abstraction worker started" at INFO level
5. Keep running and processing MQTT messages until interrupted
## Configuration
The worker reads configuration from `config/devices.yaml`. Example structure:
```yaml
mqtt:
broker: "172.16.2.16"
port: 1883
client_id: "home-automation-abstraction"
keepalive: 60
devices:
- id: "light_living_room"
type: "light"
name: "Living Room Light"
mqtt_topic: "home/living_room/light"
```
## MQTT Broker
The worker connects to a real MQTT broker at:
- **Host**: 172.16.2.16
- **Port**: 1883
- **Authentication**: None required
Topics subscribed:
- `home/#` - All home automation topics
## Dependencies
- **PyYAML**: Configuration file parsing
- **aiomqtt**: Modern async MQTT client
- **asyncio**: Asynchronous I/O
## Stopping
Press `Ctrl+C` to gracefully stop the worker.

View File

@@ -0,0 +1 @@
"""Abstraction application."""

287
apps/abstraction/main.py Normal file
View File

@@ -0,0 +1,287 @@
"""Abstraction main entry point."""
import asyncio
import json
import logging
import os
from pathlib import Path
from typing import Any
import redis.asyncio as aioredis
import yaml
from aiomqtt import Client
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
def load_config(config_path: Path) -> dict[str, Any]:
"""Load configuration from YAML file.
Args:
config_path: Path to the configuration file
Returns:
dict: Configuration dictionary
"""
if not config_path.exists():
logger.warning(f"Config file not found: {config_path}, using defaults")
return {
"mqtt": {
"broker": "172.16.2.16",
"port": 1883,
"client_id": "home-automation-abstraction",
"keepalive": 60
},
"devices": []
}
with open(config_path, "r") as f:
config = yaml.safe_load(f)
logger.info(f"Loaded configuration from {config_path}")
return config
def validate_devices(devices: list[dict[str, Any]]) -> None:
"""Validate device configuration.
Args:
devices: List of device configurations
Raises:
ValueError: If device configuration is invalid
"""
for device in devices:
if "id" not in device:
raise ValueError(f"Device missing 'id': {device}")
if "type" not in device:
raise ValueError(f"Device {device['id']} missing 'type'")
if "topics" not in device:
raise ValueError(f"Device {device['id']} missing 'topics'")
if "set" not in device["topics"] or "state" not in device["topics"]:
raise ValueError(f"Device {device['id']} missing 'topics.set' or 'topics.state'")
logger.info(f"Validated {len(devices)} device(s)")
async def get_redis_client(redis_url: str, max_retries: int = 5) -> aioredis.Redis:
"""Connect to Redis with exponential backoff.
Args:
redis_url: Redis connection URL
max_retries: Maximum number of connection attempts
Returns:
Redis client instance
"""
retry_delay = 1
for attempt in range(max_retries):
try:
redis_client = await aioredis.from_url(redis_url, decode_responses=True)
await redis_client.ping()
logger.info(f"Connected to Redis: {redis_url}")
return redis_client
except Exception as e:
if attempt < max_retries - 1:
logger.warning(f"Redis connection failed (attempt {attempt + 1}/{max_retries}): {e}")
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 30) # Exponential backoff, max 30s
else:
logger.error(f"Failed to connect to Redis after {max_retries} attempts")
raise
async def handle_abstract_set(
mqtt_client: Client,
device_id: str,
device_type: str,
vendor_topic: str,
payload: dict[str, Any]
) -> None:
"""Handle abstract SET message and publish to vendor topic.
Args:
mqtt_client: MQTT client instance
device_id: Device identifier
device_type: Device type (e.g., 'light')
vendor_topic: Vendor-specific SET topic
payload: Message payload
"""
# Extract actual payload (remove type wrapper if present)
vendor_payload = payload.get("payload", payload)
vendor_message = json.dumps(vendor_payload)
logger.info(f"→ vendor SET {device_id}: {vendor_topic}{vendor_message}")
await mqtt_client.publish(vendor_topic, vendor_message, qos=1)
async def handle_vendor_state(
mqtt_client: Client,
redis_client: aioredis.Redis,
device_id: str,
device_type: str,
payload: dict[str, Any],
redis_channel: str = "ui:updates"
) -> None:
"""Handle vendor STATE message and publish to abstract topic + Redis.
Args:
mqtt_client: MQTT client instance
redis_client: Redis client instance
device_id: Device identifier
device_type: Device type (e.g., 'light')
payload: State payload
redis_channel: Redis channel for UI updates
"""
# Publish to abstract state topic (retained)
abstract_topic = f"home/{device_type}/{device_id}/state"
abstract_message = json.dumps(payload)
logger.info(f"← abstract STATE {device_id}: {abstract_topic}{abstract_message}")
await mqtt_client.publish(abstract_topic, abstract_message, qos=1, retain=True)
# Publish to Redis for UI updates
ui_update = {
"type": "state",
"device_id": device_id,
"payload": payload
}
redis_message = json.dumps(ui_update)
logger.info(f"← Redis PUBLISH {redis_channel}{redis_message}")
await redis_client.publish(redis_channel, redis_message)
async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> None:
"""MQTT worker that handles device communication.
Args:
config: Configuration dictionary containing MQTT settings
redis_client: Redis client for UI updates
"""
mqtt_config = config.get("mqtt", {})
broker = mqtt_config.get("broker", "172.16.2.16")
port = mqtt_config.get("port", 1883)
client_id = mqtt_config.get("client_id", "home-automation-abstraction")
keepalive = mqtt_config.get("keepalive", 60)
redis_config = config.get("redis", {})
redis_channel = redis_config.get("channel", "ui:updates")
devices = {d["id"]: d for d in config.get("devices", [])}
retry_delay = 1
max_retry_delay = 60
while True:
try:
logger.info(f"Connecting to MQTT broker: {broker}:{port}")
async with Client(
hostname=broker,
port=port,
identifier=client_id,
keepalive=keepalive
) as client:
logger.info(f"Connected to MQTT broker as {client_id}")
# Subscribe to abstract SET topics for all devices
for device in devices.values():
abstract_set_topic = f"home/{device['type']}/{device['id']}/set"
await client.subscribe(abstract_set_topic)
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
# Subscribe to vendor STATE topics
vendor_state_topic = device["topics"]["state"]
await client.subscribe(vendor_state_topic)
logger.info(f"Subscribed to vendor STATE: {vendor_state_topic}")
# Reset retry delay on successful connection
retry_delay = 1
# Process messages
async for message in client.messages:
topic = str(message.topic)
payload_str = message.payload.decode()
try:
payload = json.loads(payload_str)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON on {topic}: {payload_str}")
continue
# Check if this is an abstract SET message
if topic.startswith("home/") and topic.endswith("/set"):
# Extract device_type and device_id from topic
parts = topic.split("/")
if len(parts) == 4: # home/<type>/<id>/set
device_type = parts[1]
device_id = parts[2]
if device_id in devices:
device = devices[device_id]
vendor_topic = device["topics"]["set"]
await handle_abstract_set(
client, device_id, device_type, vendor_topic, payload
)
# Check if this is a vendor STATE message
else:
# Find device by vendor state topic
for device_id, device in devices.items():
if topic == device["topics"]["state"]:
await handle_vendor_state(
client, redis_client, device_id, device["type"], payload, redis_channel
)
break
except Exception as e:
logger.error(f"MQTT error: {e}")
logger.info(f"Reconnecting in {retry_delay}s...")
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, max_retry_delay)
async def async_main() -> None:
"""Async main function for the abstraction worker."""
# Determine config path
config_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
# Load configuration
config = load_config(config_path)
# Validate devices
devices = config.get("devices") or []
validate_devices(devices)
logger.info(f"Loaded {len(devices)} device(s) from configuration")
# Get Redis URL from config or environment variable or use default
redis_config = config.get("redis", {})
redis_url = redis_config.get("url") or os.environ.get("REDIS_URL", "redis://localhost:6379/0")
# Connect to Redis with retry
redis_client = await get_redis_client(redis_url)
logger.info("Abstraction worker started")
# Start MQTT worker
await mqtt_worker(config, redis_client)
def main() -> None:
"""Run the abstraction application."""
try:
asyncio.run(async_main())
except KeyboardInterrupt:
logger.info("Abstraction worker stopped by user")
except Exception as e:
logger.error(f"Fatal error: {e}")
raise
if __name__ == "__main__":
main()

79
apps/api/README.md Normal file
View File

@@ -0,0 +1,79 @@
# Home Automation API
FastAPI-based REST API for the home automation system.
## Features
- **Health Check**: Monitor API availability
- **Capabilities Specification**: Discover supported capabilities and versions
- **CORS Support**: Configured for local frontend development
- **Auto-generated Documentation**: Interactive API docs via Swagger UI
## Running the API
### Development Mode (with auto-reload)
```bash
poetry run uvicorn apps.api.main:app --reload
```
### Production Mode
```bash
poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8000
```
### Using Python directly
```bash
poetry run python -m apps.api.main
```
## API Endpoints
### `GET /health`
Health check endpoint.
**Response:**
```json
{
"status": "ok"
}
```
### `GET /spec`
Returns supported capabilities and their versions.
**Response:**
```json
{
"capabilities": {
"light": "light@1.2.0"
}
}
```
## Interactive Documentation
Once the server is running, visit:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
- **OpenAPI Schema**: http://localhost:8000/openapi.json
## CORS Configuration
The API is configured to accept requests from the following origins:
- http://localhost:3000
- http://localhost:5173
- http://localhost:8080
- http://127.0.0.1:3000
- http://127.0.0.1:5173
- http://127.0.0.1:8080
## Dependencies
- **FastAPI**: Modern, fast web framework
- **Uvicorn**: ASGI server
- **Pydantic**: Data validation using Python type hints

1
apps/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API application."""

198
apps/api/main.py Normal file
View File

@@ -0,0 +1,198 @@
"""API main entry point."""
import json
import os
from pathlib import Path
from typing import Any
import yaml
from aiomqtt import Client
from fastapi import FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, ValidationError
from packages.home_capabilities import CAP_VERSION, LightState
app = FastAPI(
title="Home Automation API",
description="API for home automation system",
version="0.1.0"
)
# Configure CORS for localhost (Frontend)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:8002",
"http://127.0.0.1:8002",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health() -> dict[str, str]:
"""Health check endpoint.
Returns:
dict: Status indicating the service is healthy
"""
return {"status": "ok"}
@app.get("/spec")
async def spec() -> dict[str, dict[str, str]]:
"""Capability specification endpoint.
Returns:
dict: Dictionary containing supported capabilities and their versions
"""
return {
"capabilities": {
"light": CAP_VERSION
}
}
# Pydantic Models
class SetDeviceRequest(BaseModel):
"""Request model for setting device state."""
type: str
payload: dict[str, Any]
class DeviceInfo(BaseModel):
"""Device information model."""
device_id: str
type: str
name: str
# Configuration helpers
def load_devices() -> list[dict[str, Any]]:
"""Load devices from configuration file.
Returns:
list: List of device configurations
"""
config_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
if not config_path.exists():
return []
with open(config_path, "r") as f:
config = yaml.safe_load(f)
return config.get("devices", [])
def get_mqtt_settings() -> tuple[str, int]:
"""Get MQTT broker settings from environment.
Returns:
tuple: (host, port)
"""
host = os.environ.get("MQTT_HOST", "172.16.2.16")
port = int(os.environ.get("MQTT_PORT", "1883"))
return host, port
async def publish_mqtt(topic: str, payload: dict[str, Any]) -> None:
"""Publish message to MQTT broker.
Args:
topic: MQTT topic to publish to
payload: Message payload
"""
host, port = get_mqtt_settings()
message = json.dumps(payload)
async with Client(hostname=host, port=port, identifier="home-automation-api") as client:
await client.publish(topic, message, qos=1)
@app.get("/devices")
async def get_devices() -> list[DeviceInfo]:
"""Get list of available devices.
Returns:
list: List of device information
"""
devices = load_devices()
return [
DeviceInfo(
device_id=device["id"],
type=device["type"],
name=device.get("name", device["id"])
)
for device in devices
]
@app.post("/devices/{device_id}/set", status_code=status.HTTP_202_ACCEPTED)
async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str]:
"""Set device state.
Args:
device_id: Device identifier
request: Device state request
Returns:
dict: Confirmation message
Raises:
HTTPException: If device not found or payload invalid
"""
# Load devices and check if device exists
devices = load_devices()
device = next((d for d in devices if d["id"] == device_id), None)
if not device:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device {device_id} not found"
)
# Validate payload based on device type
if request.type == "light":
try:
LightState(**request.payload)
except ValidationError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid payload for light: {e}"
)
else:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unsupported device type: {request.type}"
)
# Publish to MQTT
topic = f"home/{request.type}/{device_id}/set"
mqtt_payload = {
"type": request.type,
"payload": request.payload
}
await publish_mqtt(topic, mqtt_payload)
return {"message": f"Command sent to {device_id}"}
def main() -> None:
"""Run the API application with uvicorn."""
import uvicorn
uvicorn.run(
"apps.api.main:app",
host="0.0.0.0",
port=8001,
reload=True
)
if __name__ == "__main__":
main()

103
apps/rules/README.md Normal file
View File

@@ -0,0 +1,103 @@
# Rules Engine
APScheduler-based automation rules engine for the home automation system.
## Features
- **APScheduler**: Background job scheduler for rule execution
- **Interval Jobs**: Periodic rule evaluation
- **Graceful Shutdown**: Proper signal handling (SIGINT, SIGTERM)
- **Logging**: Structured logging at INFO level
## Running
```bash
poetry run python -m apps.rules.main
```
## Architecture
The rules engine uses APScheduler's `BackgroundScheduler` to run automation rules on a schedule.
### Current Jobs
- **rule_tick**: Example job that runs every minute
- Logs "Rule tick" message
- Can be extended with actual rule logic
## Example Output
```
2025-10-31 13:05:46,865 - __main__ - INFO - Rules engine starting...
2025-10-31 13:05:46,868 - __main__ - INFO - Scheduler started with rule_tick job (every 1 minute)
2025-10-31 13:05:46,868 - __main__ - INFO - Rule tick
2025-10-31 13:06:46,874 - __main__ - INFO - Rule tick
2025-10-31 13:07:46,874 - __main__ - INFO - Rule tick
```
## Signal Handling
The application handles shutdown signals gracefully:
- **SIGINT** (Ctrl+C): Initiates graceful shutdown
- **SIGTERM**: Initiates graceful shutdown
On shutdown:
1. Stops accepting new jobs
2. Waits for running jobs to complete
3. Shuts down the scheduler
4. Exits cleanly
## Adding New Rules
To add a new rule, define a function and schedule it:
```python
def my_custom_rule() -> None:
"""Custom automation rule."""
# Your rule logic here
logger.info("Custom rule executed")
# In main():
scheduler.add_job(
my_custom_rule,
'interval',
minutes=5, # Run every 5 minutes
id='custom_rule',
name='My Custom Rule'
)
```
## Scheduler Triggers
APScheduler supports various trigger types:
- **interval**: Run at fixed intervals (e.g., every N minutes)
- **cron**: Run at specific times (e.g., daily at 8:00 AM)
- **date**: Run once at a specific datetime
Example with cron trigger:
```python
scheduler.add_job(
morning_routine,
'cron',
hour=8,
minute=0,
id='morning',
name='Morning Routine'
)
```
## Dependencies
- **APScheduler**: Advanced job scheduling
## Future Enhancements
- [ ] Load rules from configuration file
- [ ] MQTT integration for device state monitoring
- [ ] Rule conditions (if/then logic)
- [ ] Rule chaining and dependencies
- [ ] Web API for dynamic rule management
- [ ] Persistent job store (database)

1
apps/rules/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Rules application."""

84
apps/rules/main.py Normal file
View File

@@ -0,0 +1,84 @@
"""Rules main entry point."""
import logging
import signal
import sys
import time
from typing import NoReturn
from apscheduler.schedulers.background import BackgroundScheduler
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Global scheduler instance
scheduler: BackgroundScheduler | None = None
def rule_tick() -> None:
"""Example job that runs every minute.
This is a placeholder for actual rule evaluation logic.
"""
logger.info("Rule tick")
def shutdown_handler(signum: int, frame: object) -> NoReturn:
"""Handle shutdown signals gracefully.
Args:
signum: Signal number
frame: Current stack frame
"""
logger.info(f"Received signal {signum}, shutting down...")
if scheduler:
scheduler.shutdown(wait=True)
logger.info("Scheduler stopped")
sys.exit(0)
def main() -> None:
"""Run the rules application."""
global scheduler
logger.info("Rules engine starting...")
# Register signal handlers
signal.signal(signal.SIGINT, shutdown_handler)
signal.signal(signal.SIGTERM, shutdown_handler)
# Initialize scheduler
scheduler = BackgroundScheduler()
# Add example job - runs every minute
scheduler.add_job(
rule_tick,
'interval',
minutes=1,
id='rule_tick',
name='Rule Tick Job'
)
# Start scheduler
scheduler.start()
logger.info("Scheduler started with rule_tick job (every 1 minute)")
# Run initial tick immediately
rule_tick()
# Keep the application running
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("KeyboardInterrupt received, shutting down...")
scheduler.shutdown(wait=True)
logger.info("Scheduler stopped")
if __name__ == "__main__":
main()

73
apps/ui/README.md Normal file
View File

@@ -0,0 +1,73 @@
# Home Automation UI
FastAPI-based web interface with Jinja2 templates for the home automation system.
## Features
- **Jinja2 Templates**: Dynamic HTML rendering
- **Responsive Design**: Modern, clean UI
- **FastAPI Backend**: Fast and reliable serving
## Port Configuration
- **Development Port**: 8002
- **Access URL**: http://localhost:8002
## Running the UI
### Using uvicorn directly
```bash
poetry run uvicorn apps.ui.main:app --reload --port 8002
```
### Using Python module
```bash
poetry run python -m apps.ui.main
```
## Project Structure
```
apps/ui/
├── __init__.py
├── main.py # FastAPI application
├── templates/ # Jinja2 templates
│ └── index.html # Main page
└── README.md # This file
```
## Templates
Templates are located in `apps/ui/templates/` and use Jinja2 syntax.
### Available Routes
- `GET /` - Main UI page (renders `index.html`)
## Development
The server runs with auto-reload enabled during development. Any changes to:
- Python files (`.py`)
- Template files (`.html`)
will trigger an automatic restart.
## Dependencies
- **FastAPI**: Web framework
- **Jinja2**: Template engine
- **Uvicorn**: ASGI server
## Template Variables
Templates receive the following context variables:
- `request`: FastAPI Request object (required by Jinja2Templates)
## Future Enhancements
- [ ] Add static file serving (CSS, JS, images)
- [ ] Implement WebSocket support for real-time updates
- [ ] Add device control interface
- [ ] Integrate with API for capability discovery

1
apps/ui/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""UI application."""

47
apps/ui/main.py Normal file
View File

@@ -0,0 +1,47 @@
"""UI main entry point."""
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
# Initialize FastAPI app
app = FastAPI(
title="Home Automation UI",
description="User interface for home automation system",
version="0.1.0"
)
# Setup Jinja2 templates
templates_dir = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
"""Render the main UI page.
Args:
request: The FastAPI request object
Returns:
HTMLResponse: Rendered HTML template
"""
return templates.TemplateResponse("index.html", {"request": request})
def main() -> None:
"""Run the UI application with uvicorn."""
import uvicorn
uvicorn.run(
"apps.ui.main:app",
host="0.0.0.0",
port=8002,
reload=True
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home Automation</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
background: white;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 600px;
width: 90%;
}
h1 {
color: #333;
margin-bottom: 0.5rem;
}
p {
color: #666;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<h1>🏠 Home Automation</h1>
<p>UI wird geladen...</p>
<p>API erreichbar? <a href="http://localhost:8001/health" target="_blank">API Health Check</a></p>
</div>
</body>
</html>

31
config/devices.yaml Normal file
View File

@@ -0,0 +1,31 @@
# Device Configuration
# Configuration for home automation devices
mqtt:
broker: "172.16.2.16"
port: 1883
client_id: "home-automation-abstraction"
username: null
password: null
keepalive: 60
redis:
url: "redis://172.23.1.116:6379/8"
channel: "ui:updates"
devices:
- id: "test_lampe"
type: "light"
name: "Test Lampe"
topics:
set: "vendor/test_lampe/set"
state: "vendor/test_lampe/state"
# - color
# - id: "light_bedroom"
# type: "light"
# name: "Bedroom Light"
# mqtt_topic: "home/bedroom/light"
# capabilities:
# - power
# - brightness

23
infra/README.md Normal file
View File

@@ -0,0 +1,23 @@
# Infrastructure
This directory contains infrastructure-related files for the home automation project.
## Files
- `docker-compose.yml`: Docker Compose configuration for running services
## Usage
```bash
# Start services
docker-compose up -d
# Stop services
docker-compose down
```
## TODO
- Add service definitions to docker-compose.yml
- Add deployment configurations
- Add monitoring and logging setup

15
infra/docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
version: '3.8'
services:
# Placeholder for future services
# Example:
# api:
# build:
# context: ..
# dockerfile: apps/api/Dockerfile
# ports:
# - "8000:8000"
placeholder:
image: alpine:latest
command: echo "Docker Compose placeholder - add your services here"

1
packages/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Packages."""

View File

@@ -0,0 +1,5 @@
"""Home capabilities package."""
from packages.home_capabilities.light import CAP_VERSION, LightState
__all__ = ["LightState", "CAP_VERSION"]

View File

@@ -0,0 +1,52 @@
"""Light capability models and constants."""
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator
CAP_VERSION = "light@1.2.0"
class LightState(BaseModel):
"""Represents the state of a light device.
Attributes:
power: Whether the light is "on" or "off"
brightness: Optional brightness level (0-100)
color_temp: Optional color temperature in Kelvin (150-500)
color: Optional hex color string in format "#RRGGBB"
"""
power: Literal["on", "off"] = Field(
...,
description="Power state of the light"
)
brightness: Optional[int] = Field(
None,
ge=0,
le=100,
description="Brightness level from 0 to 100"
)
color_temp: Optional[int] = Field(
None,
ge=150,
le=500,
description="Color temperature in Kelvin (150-500)"
)
color: Optional[str] = Field(
None,
pattern=r"^#[0-9A-Fa-f]{6}$",
description="Hex color string in format #RRGGBB"
)
@field_validator("color")
@classmethod
def validate_color_format(cls, v: Optional[str]) -> Optional[str]:
"""Ensure color is uppercase if provided."""
if v is not None:
return v.upper()
return v

70
pyproject.toml Normal file
View File

@@ -0,0 +1,70 @@
[tool.poetry]
name = "home-automation"
version = "0.1.0"
description = "Home automation monorepo"
authors = ["Your Name <you@example.com>"]
readme = "README.md"
packages = [
{ include = "apps" },
{ include = "packages" }
]
[tool.poetry.dependencies]
python = "^3.11"
pydantic = "^2.12.3"
fastapi = "^0.120.3"
uvicorn = {extras = ["standard"], version = "^0.38.0"}
asyncio-mqtt = "^0.16.2"
pyyaml = "^6.0.3"
aiomqtt = "^2.4.0"
jinja2 = "^3.1.6"
apscheduler = "^3.11.0"
redis = "^7.0.1"
[tool.poetry.group.dev.dependencies]
ruff = "^0.6.0"
black = "^24.0.0"
mypy = "^1.11.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
]
ignore = []
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
[tool.black]
line-length = 100
target-version = ["py311"]
include = '\.pyi?$'
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
strict_equality = true