initial, step 2 already
This commit is contained in:
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal 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
53
PORTS.md
Normal 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
129
README.md
Normal 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
1
apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Apps package."""
|
||||||
63
apps/abstraction/README.md
Normal file
63
apps/abstraction/README.md
Normal 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.
|
||||||
1
apps/abstraction/__init__.py
Normal file
1
apps/abstraction/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Abstraction application."""
|
||||||
287
apps/abstraction/main.py
Normal file
287
apps/abstraction/main.py
Normal 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
79
apps/api/README.md
Normal 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
1
apps/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API application."""
|
||||||
198
apps/api/main.py
Normal file
198
apps/api/main.py
Normal 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
103
apps/rules/README.md
Normal 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
1
apps/rules/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Rules application."""
|
||||||
84
apps/rules/main.py
Normal file
84
apps/rules/main.py
Normal 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
73
apps/ui/README.md
Normal 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
1
apps/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""UI application."""
|
||||||
47
apps/ui/main.py
Normal file
47
apps/ui/main.py
Normal 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()
|
||||||
50
apps/ui/templates/index.html
Normal file
50
apps/ui/templates/index.html
Normal 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
31
config/devices.yaml
Normal 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
23
infra/README.md
Normal 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
15
infra/docker-compose.yml
Normal 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
1
packages/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Packages."""
|
||||||
5
packages/home_capabilities/__init__.py
Normal file
5
packages/home_capabilities/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Home capabilities package."""
|
||||||
|
|
||||||
|
from packages.home_capabilities.light import CAP_VERSION, LightState
|
||||||
|
|
||||||
|
__all__ = ["LightState", "CAP_VERSION"]
|
||||||
52
packages/home_capabilities/light.py
Normal file
52
packages/home_capabilities/light.py
Normal 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
70
pyproject.toml
Normal 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
|
||||||
Reference in New Issue
Block a user