commit ea17d048ad45b7201ee73f0ac25fad98f19294ce Author: Wolfgang Hottgenroth Date: Fri Oct 31 14:25:12 2025 +0100 initial, step 2 already diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8e8443 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/PORTS.md b/PORTS.md new file mode 100644 index 0000000..b592827 --- /dev/null +++ b/PORTS.md @@ -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 +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..685dae0 --- /dev/null +++ b/README.md @@ -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 diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..32cc164 --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1 @@ +"""Apps package.""" diff --git a/apps/abstraction/README.md b/apps/abstraction/README.md new file mode 100644 index 0000000..22ed96f --- /dev/null +++ b/apps/abstraction/README.md @@ -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. diff --git a/apps/abstraction/__init__.py b/apps/abstraction/__init__.py new file mode 100644 index 0000000..03dff13 --- /dev/null +++ b/apps/abstraction/__init__.py @@ -0,0 +1 @@ +"""Abstraction application.""" diff --git a/apps/abstraction/main.py b/apps/abstraction/main.py new file mode 100644 index 0000000..ba443d2 --- /dev/null +++ b/apps/abstraction/main.py @@ -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///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() diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..22a7ba5 --- /dev/null +++ b/apps/api/README.md @@ -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 diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..596f2bd --- /dev/null +++ b/apps/api/__init__.py @@ -0,0 +1 @@ +"""API application.""" diff --git a/apps/api/main.py b/apps/api/main.py new file mode 100644 index 0000000..be70d9d --- /dev/null +++ b/apps/api/main.py @@ -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() diff --git a/apps/rules/README.md b/apps/rules/README.md new file mode 100644 index 0000000..f43b5c8 --- /dev/null +++ b/apps/rules/README.md @@ -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) diff --git a/apps/rules/__init__.py b/apps/rules/__init__.py new file mode 100644 index 0000000..155550d --- /dev/null +++ b/apps/rules/__init__.py @@ -0,0 +1 @@ +"""Rules application.""" diff --git a/apps/rules/main.py b/apps/rules/main.py new file mode 100644 index 0000000..73c4fac --- /dev/null +++ b/apps/rules/main.py @@ -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() diff --git a/apps/ui/README.md b/apps/ui/README.md new file mode 100644 index 0000000..a03dd3c --- /dev/null +++ b/apps/ui/README.md @@ -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 diff --git a/apps/ui/__init__.py b/apps/ui/__init__.py new file mode 100644 index 0000000..0ba8cb7 --- /dev/null +++ b/apps/ui/__init__.py @@ -0,0 +1 @@ +"""UI application.""" diff --git a/apps/ui/main.py b/apps/ui/main.py new file mode 100644 index 0000000..c6127df --- /dev/null +++ b/apps/ui/main.py @@ -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() diff --git a/apps/ui/templates/index.html b/apps/ui/templates/index.html new file mode 100644 index 0000000..f0cf3ad --- /dev/null +++ b/apps/ui/templates/index.html @@ -0,0 +1,50 @@ + + + + + + Home Automation + + + +
+

🏠 Home Automation

+

UI wird geladen...

+

API erreichbar? API Health Check

+
+ + diff --git a/config/devices.yaml b/config/devices.yaml new file mode 100644 index 0000000..22c0c4c --- /dev/null +++ b/config/devices.yaml @@ -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 diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..5917295 --- /dev/null +++ b/infra/README.md @@ -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 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..cb99156 --- /dev/null +++ b/infra/docker-compose.yml @@ -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" diff --git a/packages/__init__.py b/packages/__init__.py new file mode 100644 index 0000000..ffced73 --- /dev/null +++ b/packages/__init__.py @@ -0,0 +1 @@ +"""Packages.""" diff --git a/packages/home_capabilities/__init__.py b/packages/home_capabilities/__init__.py new file mode 100644 index 0000000..d186d13 --- /dev/null +++ b/packages/home_capabilities/__init__.py @@ -0,0 +1,5 @@ +"""Home capabilities package.""" + +from packages.home_capabilities.light import CAP_VERSION, LightState + +__all__ = ["LightState", "CAP_VERSION"] diff --git a/packages/home_capabilities/light.py b/packages/home_capabilities/light.py new file mode 100644 index 0000000..bf25c8e --- /dev/null +++ b/packages/home_capabilities/light.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b42a19f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +[tool.poetry] +name = "home-automation" +version = "0.1.0" +description = "Home automation monorepo" +authors = ["Your Name "] +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