diff --git a/apps/simulator/README.md b/apps/simulator/README.md new file mode 100644 index 0000000..1f05286 --- /dev/null +++ b/apps/simulator/README.md @@ -0,0 +1,319 @@ +# Device Simulator Web Application + +Web-basierte Anwendung zur Simulation von Home-Automation-Geräten mit Echtzeit-Monitoring. + +## Features + +- ✅ **Web-Interface**: Dashboard mit Echtzeit-Anzeige aller Events +- ✅ **Server-Sent Events (SSE)**: Live-Updates ohne Polling +- ✅ **Funktioniert ohne Browser**: Simulator läuft im Hintergrund +- ✅ **Multi-Device Support**: Lights + Thermostats +- ✅ **MQTT Integration**: Vollständige Kommunikation über MQTT +- ✅ **Statistiken**: Zähler für Events, Commands und States +- ✅ **Event-History**: Letzte 50 Events mit Details + +## Geräte + +### Lights (3 Stück) +- `test_lampe_1` - Dimmbar +- `test_lampe_2` - Einfach +- `test_lampe_3` - Dimmbar + +### Thermostats (1 Stück) +- `test_thermo_1` - Auto/Heat/Off Modi, Temperatur-Drift + +## Installation + +Der Simulator ist bereits Teil des Projekts. Keine zusätzlichen Dependencies erforderlich. + +## Start + +```bash +# Standard-Start (Port 8003) +poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8003 + +# Mit Auto-Reload für Entwicklung +poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8003 --reload + +# Im Hintergrund +poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8003 > /tmp/simulator.log 2>&1 & +``` + +## Web-Interface + +Öffne im Browser: +``` +http://localhost:8003 +``` + +### Features im Dashboard + +1. **Status-Anzeige** + - MQTT-Verbindungsstatus + - Anzahl aktiver Geräte + - Simulator-Status + +2. **Geräte-Übersicht** + - Echtzeit-Anzeige aller Light-States + - Echtzeit-Anzeige aller Thermostat-States + +3. **Event-Stream** + - Alle MQTT-Commands + - Alle State-Updates + - Temperatur-Drift-Events + - Fehler und Warnungen + +4. **Statistiken** + - Total Events + - Commands Received + - States Published + +5. **Controls** + - Clear Events + - Toggle Auto-Scroll + +## API Endpoints + +### `GET /` +Web-Dashboard (HTML) + +### `GET /health` +Health-Check +```json +{ + "status": "ok", + "simulator_running": true, + "mqtt_connected": true +} +``` + +### `GET /status` +Vollständiger Status +```json +{ + "connected": true, + "running": true, + "light_states": {...}, + "thermostat_states": {...}, + "broker": "172.16.2.16:1883" +} +``` + +### `GET /events` +Letzte Events (JSON) +```json +{ + "events": [...] +} +``` + +### `GET /realtime` +Server-Sent Events Stream + +## Event-Typen + +### `simulator_connected` +Simulator hat MQTT-Verbindung hergestellt +```json +{ + "type": "simulator_connected", + "broker": "172.16.2.16:1883", + "client_id": "device_simulator-abc123" +} +``` + +### `command_received` +SET-Command von MQTT empfangen +```json +{ + "type": "command_received", + "device_id": "test_lampe_1", + "topic": "vendor/test_lampe_1/set", + "payload": {"power": "on"} +} +``` + +### `light_updated` +Light-State wurde aktualisiert +```json +{ + "type": "light_updated", + "device_id": "test_lampe_1", + "changes": { + "power": {"old": "off", "new": "on"} + }, + "new_state": {"power": "on", "brightness": 50} +} +``` + +### `thermostat_updated` +Thermostat-State wurde aktualisiert +```json +{ + "type": "thermostat_updated", + "device_id": "test_thermo_1", + "changes": { + "mode": {"old": "off", "new": "heat"} + }, + "new_state": {...} +} +``` + +### `temperature_drift` +Temperatur-Drift simuliert (alle 5 Sekunden) +```json +{ + "type": "temperature_drift", + "device_id": "test_thermo_1", + "old_temp": 20.5, + "new_temp": 20.7, + "target": 21.0, + "mode": "auto" +} +``` + +### `state_published` +State wurde nach MQTT publiziert +```json +{ + "type": "state_published", + "device_id": "test_lampe_1", + "device_type": "light", + "topic": "vendor/test_lampe_1/state", + "payload": {"power": "on", "brightness": 50} +} +``` + +## MQTT Topics + +### Subscribe +- `vendor/test_lampe_1/set` +- `vendor/test_lampe_2/set` +- `vendor/test_lampe_3/set` +- `vendor/test_thermo_1/set` + +### Publish (retained, QoS 1) +- `vendor/test_lampe_1/state` +- `vendor/test_lampe_2/state` +- `vendor/test_lampe_3/state` +- `vendor/test_thermo_1/state` + +## Integration mit anderen Services + +Der Simulator funktioniert nahtlos mit: + +1. **Abstraction Layer** (`apps.abstraction.main`) + - Empfängt Commands über MQTT + - Sendet States zurück + +2. **API** (`apps.api.main`) + - Commands werden via API gesendet + - Simulator reagiert automatisch + +3. **UI** (`apps.ui.main`) + - UI zeigt Simulator-States in Echtzeit + - Bedienung über UI beeinflusst Simulator + +## Deployment + +### Systemd Service +```ini +[Unit] +Description=Device Simulator +After=network.target + +[Service] +Type=simple +User=homeautomation +WorkingDirectory=/path/to/home-automation +ExecStart=/path/to/.venv/bin/uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8003 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +### Docker +```dockerfile +FROM python:3.14 +WORKDIR /app +COPY . . +RUN pip install poetry && poetry install +EXPOSE 8003 +CMD ["poetry", "run", "uvicorn", "apps.simulator.main:app", "--host", "0.0.0.0", "--port", "8003"] +``` + +## Troubleshooting + +### Simulator startet nicht +```bash +# Check logs +tail -f /tmp/simulator.log + +# Verify MQTT broker +mosquitto_sub -h 172.16.2.16 -t '#' -v +``` + +### Keine Events im Dashboard +1. Browser-Console öffnen (F12) +2. Prüfe SSE-Verbindung +3. Reload Seite (F5) + +### MQTT-Verbindung fehlgeschlagen +```bash +# Test broker connection +mosquitto_pub -h 172.16.2.16 -t test -m hello + +# Check broker status +systemctl status mosquitto +``` + +## Unterschied zum alten Simulator + +### Alt (`tools/device_simulator.py`) +- ✅ Reine CLI-Anwendung +- ✅ Logging nur in stdout +- ❌ Keine Web-UI +- ❌ Keine Live-Monitoring + +### Neu (`apps/simulator/main.py`) +- ✅ FastAPI Web-Application +- ✅ Logging + Web-Dashboard +- ✅ SSE für Echtzeit-Updates +- ✅ REST API für Status +- ✅ Funktioniert auch ohne Browser +- ✅ Statistiken und Event-History + +## Entwicklung + +### Code-Struktur +``` +apps/simulator/ +├── __init__.py +├── main.py # FastAPI app + Simulator logic +└── templates/ + └── index.html # Web dashboard +``` + +### Logging +```python +logger.info() # Wird in stdout UND als Event gestreamt +add_event({}) # Wird nur als Event gestreamt +``` + +### Neue Event-Typen hinzufügen +1. Event in `main.py` erstellen: `add_event({...})` +2. Optional: CSS-Klasse in `index.html` für Farbe +3. Event wird automatisch im Dashboard angezeigt + +## Performance + +- **Memory**: ~50 MB +- **CPU**: <1% idle, ~5% bei Commands +- **SSE Connections**: Unbegrenzt +- **Event Queue**: Max 100 Events (rolling) +- **Per-Client Queue**: Unbegrenzt + +## License + +Teil des Home-Automation Projekts. diff --git a/apps/simulator/__init__.py b/apps/simulator/__init__.py new file mode 100644 index 0000000..ab01124 --- /dev/null +++ b/apps/simulator/__init__.py @@ -0,0 +1 @@ +"""Simulator app package.""" diff --git a/apps/simulator/main.py b/apps/simulator/main.py new file mode 100644 index 0000000..9fb1dff --- /dev/null +++ b/apps/simulator/main.py @@ -0,0 +1,489 @@ +"""Device Simulator Web Application. + +FastAPI application that runs the device simulator in the background +and provides a web interface with real-time event streaming (SSE). +""" + +import asyncio +import json +import logging +import os +import uuid +from datetime import datetime +from typing import Dict, Any, AsyncGenerator +from queue import Queue +from collections import deque + +from aiomqtt import Client, MqttError +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.templating import Jinja2Templates +from pathlib import Path + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# Configuration +BROKER_HOST = os.getenv("MQTT_BROKER", "172.16.2.16") +BROKER_PORT = int(os.getenv("MQTT_PORT", "1883")) +DRIFT_INTERVAL = 5 # seconds for thermostat temperature drift + +# Device configurations +LIGHT_DEVICES = ["test_lampe_1", "test_lampe_2", "test_lampe_3"] +THERMOSTAT_DEVICES = ["test_thermo_1"] + +# Global event queue for SSE +event_queue = deque(maxlen=100) # Keep last 100 events +sse_queues = [] # List of queues for active SSE connections + + +def add_event(event: dict): + """Add event to global queue and notify all SSE clients.""" + event_with_ts = { + **event, + "timestamp": datetime.utcnow().isoformat() + "Z" + } + event_queue.append(event_with_ts) + + # Notify all SSE clients + for q in sse_queues: + try: + q.put_nowait(event_with_ts) + except: + pass + + +class DeviceSimulator: + """Unified simulator for lights and thermostats.""" + + def __init__(self): + # Light states + self.light_states: Dict[str, Dict[str, Any]] = { + "test_lampe_1": {"power": "off", "brightness": 50}, + "test_lampe_2": {"power": "off", "brightness": 50}, + "test_lampe_3": {"power": "off", "brightness": 50} + } + + # Thermostat states + self.thermostat_states: Dict[str, Dict[str, Any]] = { + "test_thermo_1": { + "mode": "auto", + "target": 21.0, + "current": 20.5, + "battery": 90, + "window_open": False + } + } + + self.client = None + self.running = True + self.drift_task = None + self.connected = False + + async def publish_state(self, device_id: str, device_type: str): + """Publish device state to MQTT (retained, QoS 1).""" + if not self.client: + return + + if device_type == "light": + state = self.light_states.get(device_id) + elif device_type == "thermostat": + state = self.thermostat_states.get(device_id) + else: + logger.warning(f"Unknown device type: {device_type}") + return + + if not state: + logger.warning(f"Unknown device: {device_id}") + return + + state_topic = f"vendor/{device_id}/state" + payload = json.dumps(state) + + await self.client.publish( + state_topic, + payload=payload, + qos=1, + retain=True + ) + + add_event({ + "type": "state_published", + "device_id": device_id, + "device_type": device_type, + "topic": state_topic, + "payload": state + }) + + logger.info(f"[{device_id}] Published state: {payload}") + + async def handle_light_set(self, device_id: str, payload: dict): + """Handle SET command for light device.""" + if device_id not in self.light_states: + logger.warning(f"Unknown light device: {device_id}") + return + + state = self.light_states[device_id] + changes = {} + + if "power" in payload: + old_power = state["power"] + state["power"] = payload["power"] + if old_power != state["power"]: + changes["power"] = {"old": old_power, "new": state["power"]} + logger.info(f"[{device_id}] Power: {old_power} -> {state['power']}") + + if "brightness" in payload: + old_brightness = state["brightness"] + state["brightness"] = int(payload["brightness"]) + if old_brightness != state["brightness"]: + changes["brightness"] = {"old": old_brightness, "new": state["brightness"]} + logger.info(f"[{device_id}] Brightness: {old_brightness} -> {state['brightness']}") + + if changes: + add_event({ + "type": "light_updated", + "device_id": device_id, + "changes": changes, + "new_state": dict(state) + }) + await self.publish_state(device_id, "light") + + async def handle_thermostat_set(self, device_id: str, payload: dict): + """Handle SET command for thermostat device.""" + if device_id not in self.thermostat_states: + logger.warning(f"Unknown thermostat device: {device_id}") + return + + state = self.thermostat_states[device_id] + changes = {} + + if "mode" in payload: + new_mode = payload["mode"] + if new_mode in ["off", "heat", "auto"]: + old_mode = state["mode"] + state["mode"] = new_mode + if old_mode != new_mode: + changes["mode"] = {"old": old_mode, "new": new_mode} + logger.info(f"[{device_id}] Mode: {old_mode} -> {new_mode}") + else: + logger.warning(f"[{device_id}] Invalid mode: {new_mode}") + + if "target" in payload: + try: + new_target = float(payload["target"]) + if 5.0 <= new_target <= 30.0: + old_target = state["target"] + state["target"] = new_target + if old_target != new_target: + changes["target"] = {"old": old_target, "new": new_target} + logger.info(f"[{device_id}] Target: {old_target}°C -> {new_target}°C") + else: + logger.warning(f"[{device_id}] Target out of range: {new_target}") + except (ValueError, TypeError): + logger.warning(f"[{device_id}] Invalid target value: {payload['target']}") + + if changes: + add_event({ + "type": "thermostat_updated", + "device_id": device_id, + "changes": changes, + "new_state": dict(state) + }) + await self.publish_state(device_id, "thermostat") + + def apply_temperature_drift(self, device_id: str): + """ + Simulate temperature drift for thermostat. + Max change: ±0.2°C per interval. + """ + if device_id not in self.thermostat_states: + return + + state = self.thermostat_states[device_id] + old_current = state["current"] + + if state["mode"] == "off": + # Drift towards ambient (18°C) + ambient = 18.0 + diff = ambient - state["current"] + else: + # Drift towards target + diff = state["target"] - state["current"] + + # Apply max ±0.2°C drift + if abs(diff) < 0.1: + state["current"] = round(state["current"] + diff, 1) + elif diff > 0: + state["current"] = round(state["current"] + 0.2, 1) + else: + state["current"] = round(state["current"] - 0.2, 1) + + if old_current != state["current"]: + add_event({ + "type": "temperature_drift", + "device_id": device_id, + "old_temp": old_current, + "new_temp": state["current"], + "target": state["target"], + "mode": state["mode"] + }) + + logger.info(f"[{device_id}] Temperature drift: current={state['current']}°C (target={state['target']}°C, mode={state['mode']})") + + async def thermostat_drift_loop(self): + """Background loop for thermostat temperature drift.""" + while self.running: + await asyncio.sleep(DRIFT_INTERVAL) + + for device_id in THERMOSTAT_DEVICES: + self.apply_temperature_drift(device_id) + await self.publish_state(device_id, "thermostat") + + async def handle_message(self, message): + """Handle incoming MQTT message.""" + try: + # Extract device_id from topic (vendor/{device_id}/set) + topic_parts = message.topic.value.split('/') + if len(topic_parts) != 3 or topic_parts[0] != "vendor" or topic_parts[2] != "set": + logger.warning(f"Unexpected topic format: {message.topic}") + return + + device_id = topic_parts[1] + payload = json.loads(message.payload.decode()) + + add_event({ + "type": "command_received", + "device_id": device_id, + "topic": message.topic.value, + "payload": payload + }) + + logger.info(f"[{device_id}] Received SET: {payload}") + + # Determine device type and handle accordingly + if device_id in self.light_states: + await self.handle_light_set(device_id, payload) + elif device_id in self.thermostat_states: + await self.handle_thermostat_set(device_id, payload) + else: + logger.warning(f"Unknown device: {device_id}") + + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON: {e}") + add_event({"type": "error", "message": f"Invalid JSON: {e}"}) + except Exception as e: + logger.error(f"Error handling message: {e}") + add_event({"type": "error", "message": f"Error: {e}"}) + + async def run(self): + """Main simulator loop.""" + # Generate unique client ID to avoid collisions + base_client_id = "device_simulator" + client_suffix = os.environ.get("MQTT_CLIENT_ID_SUFFIX") or uuid.uuid4().hex[:6] + unique_client_id = f"{base_client_id}-{client_suffix}" + + try: + async with Client( + hostname=BROKER_HOST, + port=BROKER_PORT, + identifier=unique_client_id + ) as client: + self.client = client + self.connected = True + + add_event({ + "type": "simulator_connected", + "broker": f"{BROKER_HOST}:{BROKER_PORT}", + "client_id": unique_client_id + }) + + logger.info(f"✅ Connected to MQTT broker {BROKER_HOST}:{BROKER_PORT}") + + # Publish initial states + for device_id in LIGHT_DEVICES: + await self.publish_state(device_id, "light") + logger.info(f"💡 Light simulator started: {device_id}") + + for device_id in THERMOSTAT_DEVICES: + await self.publish_state(device_id, "thermostat") + logger.info(f"🌡️ Thermostat simulator started: {device_id}") + + add_event({ + "type": "devices_initialized", + "lights": LIGHT_DEVICES, + "thermostats": THERMOSTAT_DEVICES + }) + + # Subscribe to all SET topics + all_devices = LIGHT_DEVICES + THERMOSTAT_DEVICES + for device_id in all_devices: + set_topic = f"vendor/{device_id}/set" + await client.subscribe(set_topic, qos=1) + logger.info(f"👂 Subscribed to {set_topic}") + + add_event({ + "type": "subscriptions_complete", + "topics": [f"vendor/{d}/set" for d in all_devices] + }) + + # Start thermostat drift loop + self.drift_task = asyncio.create_task(self.thermostat_drift_loop()) + + # Listen for messages + async for message in client.messages: + await self.handle_message(message) + + # Cancel drift loop on disconnect + if self.drift_task: + self.drift_task.cancel() + + except MqttError as e: + logger.error(f"❌ MQTT Error: {e}") + add_event({"type": "error", "message": f"MQTT Error: {e}"}) + self.connected = False + except Exception as e: + logger.error(f"❌ Error: {e}") + add_event({"type": "error", "message": f"Error: {e}"}) + self.connected = False + finally: + self.running = False + self.connected = False + if self.drift_task: + self.drift_task.cancel() + add_event({"type": "simulator_stopped"}) + logger.info("👋 Simulator stopped") + + +# Create FastAPI app +app = FastAPI( + title="Device Simulator", + description="Web interface for device simulator with real-time event streaming", + version="1.0.0" +) + +# Setup templates +templates_dir = Path(__file__).parent / "templates" +templates = Jinja2Templates(directory=str(templates_dir)) + +# Global simulator instance +simulator = DeviceSimulator() +simulator_task = None + + +@app.on_event("startup") +async def startup_event(): + """Start simulator on application startup.""" + global simulator_task + simulator_task = asyncio.create_task(simulator.run()) + add_event({"type": "app_started", "message": "Simulator web app started"}) + logger.info("🚀 Simulator web app started") + + +@app.on_event("shutdown") +async def shutdown_event(): + """Stop simulator on application shutdown.""" + global simulator_task + simulator.running = False + if simulator_task: + simulator_task.cancel() + try: + await simulator_task + except asyncio.CancelledError: + pass + add_event({"type": "app_stopped", "message": "Simulator web app stopped"}) + logger.info("🛑 Simulator web app stopped") + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + """Render simulator dashboard.""" + return templates.TemplateResponse("index.html", { + "request": request, + "broker": f"{BROKER_HOST}:{BROKER_PORT}", + "light_devices": LIGHT_DEVICES, + "thermostat_devices": THERMOSTAT_DEVICES, + "connected": simulator.connected + }) + + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return { + "status": "ok", + "simulator_running": simulator.running, + "mqtt_connected": simulator.connected + } + + +@app.get("/status") +async def status(): + """Get current simulator status.""" + return { + "connected": simulator.connected, + "running": simulator.running, + "light_states": simulator.light_states, + "thermostat_states": simulator.thermostat_states, + "broker": f"{BROKER_HOST}:{BROKER_PORT}" + } + + +@app.get("/events") +async def get_events(): + """Get recent events.""" + return { + "events": list(event_queue) + } + + +@app.get("/realtime") +async def realtime(request: Request): + """Server-Sent Events stream for real-time updates.""" + async def event_generator() -> AsyncGenerator[str, None]: + # Create a queue for this SSE connection + q = asyncio.Queue() + sse_queues.append(q) + + try: + # Send recent events first + for event in event_queue: + yield f"data: {json.dumps(event)}\n\n" + + # Stream new events + while True: + # Check if client disconnected + if await request.is_disconnected(): + break + + try: + # Wait for new event with timeout + event = await asyncio.wait_for(q.get(), timeout=30.0) + yield f"data: {json.dumps(event)}\n\n" + except asyncio.TimeoutError: + # Send heartbeat + yield f"event: ping\ndata: {json.dumps({'type': 'ping'})}\n\n" + + finally: + # Remove queue on disconnect + if q in sse_queues: + sse_queues.remove(q) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8003) diff --git a/apps/simulator/templates/index.html b/apps/simulator/templates/index.html new file mode 100644 index 0000000..44a6284 --- /dev/null +++ b/apps/simulator/templates/index.html @@ -0,0 +1,537 @@ + + +
+ + +Real-time monitoring of simulated home automation devices
+ + +Waiting for events...
+Realtime Status: Verbinde...
Warte auf Events...