From cb555a1f67919992f1feb4bb8672bbe5ca9c9a8e Mon Sep 17 00:00:00 2001 From: Wolfgang Hottgenroth Date: Wed, 5 Nov 2025 18:26:36 +0100 Subject: [PATCH] thermostat --- apps/abstraction/main.py | 47 +++- apps/api/main.py | 21 +- apps/ui/templates/dashboard.html | 297 ++++++++++++++++++++++- config/devices.yaml | 12 + packages/home_capabilities/__init__.py | 16 +- packages/home_capabilities/thermostat.py | 77 ++++++ tools/README_device_simulator.md | 190 +++++++++++++++ tools/device_simulator.py | 285 ++++++++++++++++++++++ tools/sim_thermo.py | 205 ++++++++++++++++ tools/test_device_simulator.sh | 154 ++++++++++++ 10 files changed, 1293 insertions(+), 11 deletions(-) create mode 100644 packages/home_capabilities/thermostat.py create mode 100644 tools/README_device_simulator.md create mode 100755 tools/device_simulator.py create mode 100755 tools/sim_thermo.py create mode 100755 tools/test_device_simulator.sh diff --git a/apps/abstraction/main.py b/apps/abstraction/main.py index 717b658..01537c2 100644 --- a/apps/abstraction/main.py +++ b/apps/abstraction/main.py @@ -4,12 +4,16 @@ import asyncio import json import logging import os +from datetime import datetime, timezone from pathlib import Path from typing import Any import redis.asyncio as aioredis import yaml from aiomqtt import Client +from pydantic import ValidationError + +from packages.home_capabilities import LightState, ThermostatState # Configure logging logging.basicConfig( @@ -129,12 +133,35 @@ async def handle_abstract_set( Args: mqtt_client: MQTT client instance device_id: Device identifier - device_type: Device type (e.g., 'light') + device_type: Device type (e.g., 'light', 'thermostat') vendor_topic: Vendor-specific SET topic payload: Message payload """ # Extract actual payload (remove type wrapper if present) vendor_payload = payload.get("payload", payload) + + # Validate payload based on device type + try: + if device_type == "light": + # Validate light SET payload (power and/or brightness) + LightState.model_validate(vendor_payload) + elif device_type == "thermostat": + # For thermostat SET: only allow mode and target fields + allowed_set_fields = {"mode", "target"} + invalid_fields = set(vendor_payload.keys()) - allowed_set_fields + if invalid_fields: + logger.warning( + f"Thermostat SET {device_id} contains invalid fields {invalid_fields}, " + f"only {allowed_set_fields} allowed" + ) + return + + # Validate against ThermostatState (current/battery/window_open are optional) + ThermostatState.model_validate(vendor_payload) + except ValidationError as e: + logger.error(f"Validation failed for {device_type} SET {device_id}: {e}") + return + vendor_message = json.dumps(vendor_payload) logger.info(f"→ vendor SET {device_id}: {vendor_topic} ← {vendor_message}") @@ -155,10 +182,21 @@ async def handle_vendor_state( mqtt_client: MQTT client instance redis_client: Redis client instance device_id: Device identifier - device_type: Device type (e.g., 'light') + device_type: Device type (e.g., 'light', 'thermostat') payload: State payload redis_channel: Redis channel for UI updates """ + # Validate state payload based on device type + try: + if device_type == "light": + LightState.model_validate(payload) + elif device_type == "thermostat": + # Validate thermostat state: mode, target, current (required), battery, window_open + ThermostatState.model_validate(payload) + except ValidationError as e: + logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}") + return + # Publish to abstract state topic (retained) abstract_topic = f"home/{device_type}/{device_id}/state" abstract_message = json.dumps(payload) @@ -166,11 +204,12 @@ async def handle_vendor_state( 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 + # Publish to Redis for UI updates with timestamp ui_update = { "type": "state", "device_id": device_id, - "payload": payload + "payload": payload, + "ts": datetime.now(timezone.utc).isoformat() } redis_message = json.dumps(ui_update) diff --git a/apps/api/main.py b/apps/api/main.py index 2e17dad..6716679 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel, ValidationError -from packages.home_capabilities import CAP_VERSION, LightState +from packages.home_capabilities import LIGHT_VERSION, THERMOSTAT_VERSION, LightState, ThermostatState logger = logging.getLogger(__name__) @@ -57,7 +57,8 @@ async def spec() -> dict[str, dict[str, str]]: """ return { "capabilities": { - "light": CAP_VERSION + "light": LIGHT_VERSION, + "thermostat": THERMOSTAT_VERSION } } @@ -233,6 +234,22 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Invalid payload for light: {e}" ) + elif request.type == "thermostat": + try: + # For thermostat SET: only allow mode and target + allowed_set_fields = {"mode", "target"} + invalid_fields = set(request.payload.keys()) - allowed_set_fields + if invalid_fields: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Thermostat SET only allows {allowed_set_fields}, got invalid fields: {invalid_fields}" + ) + ThermostatState(**request.payload) + except ValidationError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid payload for thermostat: {e}" + ) else: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/apps/ui/templates/dashboard.html b/apps/ui/templates/dashboard.html index d01db1e..aba827b 100644 --- a/apps/ui/templates/dashboard.html +++ b/apps/ui/templates/dashboard.html @@ -201,6 +201,123 @@ border: none; } + /* Thermostat styles */ + .thermostat-display { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin: 1rem 0; + } + + .temp-reading { + background: #f8f9fa; + border-radius: 8px; + padding: 1rem; + text-align: center; + } + + .temp-label { + font-size: 0.75rem; + color: #666; + text-transform: uppercase; + margin-bottom: 0.25rem; + } + + .temp-value { + font-size: 2rem; + font-weight: 700; + color: #333; + } + + .temp-unit { + font-size: 1rem; + color: #999; + } + + .mode-display { + background: #f8f9fa; + border-radius: 8px; + padding: 0.75rem; + text-align: center; + margin-bottom: 1rem; + } + + .mode-label { + font-size: 0.75rem; + color: #666; + text-transform: uppercase; + } + + .mode-value { + font-size: 1rem; + font-weight: 600; + color: #667eea; + text-transform: uppercase; + } + + .temp-controls { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + } + + .temp-button { + flex: 1; + padding: 0.75rem; + border: none; + border-radius: 8px; + font-size: 1.125rem; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + background: #667eea; + color: white; + min-height: 44px; + } + + .temp-button:hover { + background: #5568d3; + } + + .temp-button:active { + transform: scale(0.95); + } + + .mode-controls { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + } + + .mode-button { + padding: 0.75rem; + border: 2px solid #ddd; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + background: white; + color: #666; + min-height: 44px; + text-transform: uppercase; + } + + .mode-button:hover { + border-color: #667eea; + color: #667eea; + } + + .mode-button.active { + background: #667eea; + border-color: #667eea; + color: white; + } + + .mode-button:active { + transform: scale(0.95); + } + .events { margin-top: 2rem; background: white; @@ -280,6 +397,8 @@ {% if device.type == "light" %} Light {% if device.features.brightness %}• Dimmbar{% endif %} + {% elif device.type == "thermostat" %} + Thermostat {% else %} {{ device.type or "Unknown" }} {% endif %} @@ -287,6 +406,7 @@
{{ device.device_id }}
+ {% if device.type == "light" %}
Status: off @@ -297,7 +417,7 @@ {% endif %}
- {% if device.type == "light" and device.features.power %} + {% if device.features.power %}
+ +
+ +
+ + + +
+ {% endif %} {% endfor %} @@ -348,11 +522,16 @@ const API_BASE = 'http://localhost:8001'; let eventSource = null; let currentState = {}; + let thermostatTargets = {}; // Initialize device states {% for room in rooms %} {% for device in room.devices %} + {% if device.type == "light" %} currentState['{{ device.device_id }}'] = 'off'; + {% elif device.type == "thermostat" %} + thermostatTargets['{{ device.device_id }}'] = 21.0; + {% endif %} {% endfor %} {% endfor %} @@ -424,6 +603,71 @@ } } + // Adjust thermostat target temperature + async function adjustTarget(deviceId, delta) { + const currentTarget = thermostatTargets[deviceId] || 21.0; + const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta)); + + try { + const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type: 'thermostat', + payload: { + target: newTarget + } + }) + }); + + if (response.ok) { + thermostatTargets[deviceId] = newTarget; + console.log(`Sent target ${newTarget} to ${deviceId}`); + addEvent({ + action: 'target_adjusted', + device_id: deviceId, + target: newTarget + }); + } + } catch (error) { + console.error('Failed to adjust target:', error); + } + } + + // Set thermostat mode + async function setMode(deviceId, mode) { + const currentTarget = thermostatTargets[deviceId] || 21.0; + + try { + const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type: 'thermostat', + payload: { + mode: mode, + target: currentTarget + } + }) + }); + + if (response.ok) { + console.log(`Sent mode ${mode} to ${deviceId}`); + addEvent({ + action: 'mode_set', + device_id: deviceId, + mode: mode + }); + } + } catch (error) { + console.error('Failed to set mode:', error); + } + } + // Update device UI function updateDeviceUI(deviceId, power, brightness) { currentState[deviceId] = power; @@ -464,6 +708,40 @@ } } + // Update thermostat UI + function updateThermostatUI(deviceId, current, target, mode) { + const currentSpan = document.getElementById(`state-${deviceId}-current`); + const targetSpan = document.getElementById(`state-${deviceId}-target`); + const modeSpan = document.getElementById(`state-${deviceId}-mode`); + + if (current !== undefined && currentSpan) { + currentSpan.textContent = current.toFixed(1); + } + + if (target !== undefined) { + if (targetSpan) { + targetSpan.textContent = target.toFixed(1); + } + thermostatTargets[deviceId] = target; + } + + if (mode !== undefined && modeSpan) { + modeSpan.textContent = mode.toUpperCase(); + + // Update mode button states + ['off', 'heat', 'auto'].forEach(m => { + const btn = document.getElementById(`mode-${deviceId}-${m}`); + if (btn) { + if (m === mode.toLowerCase()) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + } + }); + } + } + // Add event to list function addEvent(event) { const eventList = document.getElementById('event-list'); @@ -507,14 +785,27 @@ addEvent(data); // Update device state - if (data.type === 'state' && data.device_id) { - if (data.payload) { + if (data.type === 'state' && data.device_id && data.payload) { + const card = document.querySelector(`[data-device-id="${data.device_id}"]`); + + // Check if it's a light + if (data.payload.power !== undefined) { updateDeviceUI( data.device_id, data.payload.power, data.payload.brightness ); } + + // Check if it's a thermostat + if (data.payload.mode !== undefined || data.payload.target !== undefined || data.payload.current !== undefined) { + updateThermostatUI( + data.device_id, + data.payload.current, + data.payload.target, + data.payload.mode + ); + } } }); diff --git a/config/devices.yaml b/config/devices.yaml index e6f5fec..c0b9c94 100644 --- a/config/devices.yaml +++ b/config/devices.yaml @@ -42,3 +42,15 @@ devices: topics: set: "vendor/test_lampe_3/set" state: "vendor/test_lampe_3/state" + - device_id: test_thermo_1 + type: thermostat + cap_version: "thermostat@2.0.0" + technology: zigbee2mqtt + features: + mode: true + target: true + current: true + battery: true + topics: + set: "vendor/test_thermo_1/set" + state: "vendor/test_thermo_1/state" diff --git a/packages/home_capabilities/__init__.py b/packages/home_capabilities/__init__.py index 9ad36cc..169e1d6 100644 --- a/packages/home_capabilities/__init__.py +++ b/packages/home_capabilities/__init__.py @@ -1,6 +1,18 @@ """Home capabilities package.""" -from packages.home_capabilities.light import CAP_VERSION, LightState +from packages.home_capabilities.light import CAP_VERSION as LIGHT_VERSION +from packages.home_capabilities.light import LightState +from packages.home_capabilities.thermostat import CAP_VERSION as THERMOSTAT_VERSION +from packages.home_capabilities.thermostat import ThermostatState from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout -__all__ = ["LightState", "CAP_VERSION", "DeviceTile", "Room", "UiLayout", "load_layout"] +__all__ = [ + "LightState", + "LIGHT_VERSION", + "ThermostatState", + "THERMOSTAT_VERSION", + "DeviceTile", + "Room", + "UiLayout", + "load_layout", +] diff --git a/packages/home_capabilities/thermostat.py b/packages/home_capabilities/thermostat.py new file mode 100644 index 0000000..9b4016a --- /dev/null +++ b/packages/home_capabilities/thermostat.py @@ -0,0 +1,77 @@ +""" +Thermostat Capability Model +Pydantic v2 model for thermostat device state and commands. +""" + +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, Field + +CAP_VERSION = "thermostat@2.0.0" + + +class ThermostatState(BaseModel): + """ + Thermostat state model with validation. + + Attributes: + mode: Operating mode (off, heat, auto) + target: Target temperature in °C [5.0..30.0] + current: Current temperature in °C (optional in SET, required in STATE) + battery: Battery level 0-100% (optional) + window_open: Window open detection (optional) + """ + + mode: Literal["off", "heat", "auto"] = Field( + ..., + description="Operating mode of the thermostat" + ) + + target: float | Decimal = Field( + ..., + ge=5.0, + le=30.0, + description="Target temperature in degrees Celsius" + ) + + current: float | Decimal | None = Field( + None, + ge=0.0, + description="Current measured temperature in degrees Celsius" + ) + + battery: int | None = Field( + None, + ge=0, + le=100, + description="Battery level percentage" + ) + + window_open: bool | None = Field( + None, + description="Window open detection status" + ) + + model_config = { + "json_schema_extra": { + "examples": [ + { + "mode": "heat", + "target": 21.0, + "current": 20.2, + "battery": 85, + "window_open": False + }, + { + "mode": "auto", + "target": 22.5 + }, + { + "mode": "off", + "target": 5.0, + "current": 18.0 + } + ] + } + } diff --git a/tools/README_device_simulator.md b/tools/README_device_simulator.md new file mode 100644 index 0000000..15967db --- /dev/null +++ b/tools/README_device_simulator.md @@ -0,0 +1,190 @@ +# Device Simulator + +Unified MQTT device simulator für das Home Automation System. + +## Übersicht + +Dieser Simulator ersetzt die einzelnen Simulatoren (`sim_test_lampe.py`, `sim_thermo.py`) und vereint alle Device-Typen in einer einzigen Anwendung. + +## Unterstützte Geräte + +### Lampen (3 Geräte) +- `test_lampe_1` - Mit Power und Brightness +- `test_lampe_2` - Mit Power und Brightness +- `test_lampe_3` - Mit Power und Brightness + +**Features:** +- `power`: "on" oder "off" +- `brightness`: 0-100 + +### Thermostaten (1 Gerät) +- `test_thermo_1` - Vollständiger Thermostat mit Temperatur-Simulation + +**Features:** +- `mode`: "off", "heat", oder "auto" +- `target`: Soll-Temperatur (5.0-30.0°C) +- `current`: Ist-Temperatur (wird simuliert) +- `battery`: Batteriestand (90%) +- `window_open`: Fensterstatus (false) + +**Temperatur-Simulation:** +- Alle 5 Sekunden wird die Ist-Temperatur angepasst +- **HEAT/AUTO Mode**: Drift zu `target` (+0.2°C pro Intervall) +- **OFF Mode**: Drift zu Ambient-Temperatur 18°C (-0.2°C pro Intervall) + +## MQTT-Konfiguration + +- **Broker**: 172.16.2.16:1883 (konfigurierbar via ENV) +- **QoS**: 1 für alle Publishes +- **Retained**: Ja für alle State-Messages +- **Client ID**: device_simulator + +### Topics + +Für jedes Gerät: +- Subscribe: `vendor/{device_id}/set` (QoS 1) +- Publish: `vendor/{device_id}/state` (QoS 1, retained) + +## Verwendung + +### Starten + +```bash +poetry run python tools/device_simulator.py +``` + +Oder im Hintergrund: + +```bash +poetry run python tools/device_simulator.py > /tmp/simulator.log 2>&1 & +``` + +### Umgebungsvariablen + +```bash +export MQTT_BROKER="172.16.2.16" # MQTT Broker Host +export MQTT_PORT="1883" # MQTT Broker Port +``` + +## Testen + +Ein umfassendes Test-Skript ist verfügbar: + +```bash +./tools/test_device_simulator.sh +``` + +Das Test-Skript: +1. Stoppt alle laufenden Services +2. Startet Abstraction Layer, API und Simulator +3. Testet alle Lampen-Operationen +4. Testet alle Thermostat-Operationen +5. Verifiziert MQTT State Messages +6. Zeigt Simulator-Logs + +## Beispiele + +### Lampe einschalten + +```bash +curl -X POST http://localhost:8001/devices/test_lampe_1/set \ + -H "Content-Type: application/json" \ + -d '{"type":"light","payload":{"power":"on"}}' +``` + +### Helligkeit setzen + +```bash +curl -X POST http://localhost:8001/devices/test_lampe_1/set \ + -H "Content-Type: application/json" \ + -d '{"type":"light","payload":{"brightness":75}}' +``` + +### Thermostat Mode setzen + +```bash +curl -X POST http://localhost:8001/devices/test_thermo_1/set \ + -H "Content-Type: application/json" \ + -d '{"type":"thermostat","payload":{"mode":"heat","target":22.5}}' +``` + +### State abfragen via MQTT + +```bash +# Lampe +mosquitto_sub -h 172.16.2.16 -t 'vendor/test_lampe_1/state' -C 1 + +# Thermostat +mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -C 1 +``` + +## Architektur + +``` +Browser/API + ↓ POST /devices/{id}/set +API Server (Port 8001) + ↓ MQTT: home/{type}/{id}/set +Abstraction Layer + ↓ MQTT: vendor/{id}/set +Device Simulator + ↓ MQTT: vendor/{id}/state (retained) +Abstraction Layer + ↓ MQTT: home/{type}/{id}/state (retained) + ↓ Redis Pub/Sub: ui:updates +UI / Dashboard +``` + +## Logs + +Der Simulator loggt alle Aktivitäten: +- Startup und MQTT-Verbindung +- Empfangene SET-Commands +- State-Änderungen +- Temperature-Drift (Thermostaten) +- Publizierte State-Messages + +Log-Level: INFO + +## Troubleshooting + +### Simulator startet nicht + +```bash +# Prüfe ob Port bereits belegt +lsof -ti:1883 + +# Prüfe MQTT Broker +mosquitto_sub -h 172.16.2.16 -t '#' -C 1 +``` + +### Keine State-Updates + +```bash +# Prüfe Simulator-Log +tail -f /tmp/simulator.log + +# Prüfe MQTT Topics +mosquitto_sub -h 172.16.2.16 -t 'vendor/#' -v +``` + +### API antwortet nicht + +```bash +# Prüfe ob API läuft +curl http://localhost:8001/devices + +# Prüfe API-Log +tail -f /tmp/api.log +``` + +## Integration + +Der Simulator integriert sich nahtlos in das Home Automation System: + +1. **Abstraction Layer** empfängt Commands und sendet sie an Simulator +2. **Simulator** reagiert und publiziert neuen State +3. **Abstraction Layer** empfängt State und publiziert zu Redis +4. **UI** empfängt Updates via SSE und aktualisiert Dashboard + +Alle Komponenten arbeiten vollständig asynchron über MQTT. diff --git a/tools/device_simulator.py b/tools/device_simulator.py new file mode 100755 index 0000000..ba5d579 --- /dev/null +++ b/tools/device_simulator.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +""" +Unified Device Simulator for Home Automation. + +Simulates multiple device types: +- Lights (test_lampe_1, test_lampe_2, test_lampe_3) +- Thermostats (test_thermo_1) + +Each device: +- Subscribes to vendor/{device_id}/set +- Maintains local state +- Publishes state changes to vendor/{device_id}/state (retained, QoS 1) +- Thermostats simulate temperature drift every 5 seconds +""" + +import asyncio +import json +import logging +import os +import signal +import sys +from datetime import datetime +from typing import Dict, Any + +from aiomqtt import Client, MqttError + +# 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"] + + +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 + + 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 + ) + 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] + updated = False + + if "power" in payload: + old_power = state["power"] + state["power"] = payload["power"] + if old_power != state["power"]: + updated = True + 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"]: + updated = True + logger.info(f"[{device_id}] Brightness: {old_brightness} -> {state['brightness']}") + + if updated: + 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] + updated = False + + 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: + updated = True + 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: + updated = True + 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 updated: + 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] + + 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) + + 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()) + + 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}") + except Exception as e: + logger.error(f"Error handling message: {e}") + + async def run(self): + """Main simulator loop.""" + try: + async with Client( + hostname=BROKER_HOST, + port=BROKER_PORT, + identifier="device_simulator" + ) as client: + self.client = client + 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}") + + # 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}") + + # 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}") + except KeyboardInterrupt: + logger.info("⚠️ Interrupted by user") + finally: + self.running = False + if self.drift_task: + self.drift_task.cancel() + logger.info("👋 Simulator stopped") + + +async def main(): + """Entry point.""" + simulator = DeviceSimulator() + await simulator.run() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n👋 Simulator terminated") + sys.exit(0) diff --git a/tools/sim_thermo.py b/tools/sim_thermo.py new file mode 100755 index 0000000..79a1874 --- /dev/null +++ b/tools/sim_thermo.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +MQTT Simulator für test_thermo_1 Thermostat. + +Funktionalität: +- Subscribt auf vendor/test_thermo_1/set +- Hält internen Zustand (mode, target, current, battery, window_open) +- Reagiert auf SET-Commands (mode/target) +- Simuliert Temperatur-Drift zu target (alle 5s, max ±0.2°C) +- Publiziert vendor/test_thermo_1/state (retained, QoS 1) + +Usage: + poetry run python tools/sim_thermo.py + +Environment Variables: + MQTT_BROKER: MQTT broker hostname (default: 172.16.2.16) + MQTT_PORT: MQTT broker port (default: 1883) + +Test Commands: + # Start simulator + poetry run python tools/sim_thermo.py & + + # Test SET command + curl -X POST http://localhost:8001/devices/test_thermo_1/set \ + -H "Content-Type: application/json" \ + -d '{"type":"thermostat","payload":{"mode":"heat","target":22.5}}' + + # Monitor state + mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -v +""" + +import asyncio +import json +import os +import sys +from datetime import datetime +from aiomqtt import Client, MqttError + + +# Configuration +BROKER_HOST = os.getenv("MQTT_BROKER", "172.16.2.16") +BROKER_PORT = int(os.getenv("MQTT_PORT", "1883")) +DEVICE_ID = "test_thermo_1" +SET_TOPIC = f"vendor/{DEVICE_ID}/set" +STATE_TOPIC = f"vendor/{DEVICE_ID}/state" +DRIFT_INTERVAL = 5 # seconds + + +class ThermostatSimulator: + """Simulates a thermostat device with temperature regulation.""" + + def __init__(self): + self.state = { + "mode": "auto", + "target": 21.0, + "current": 20.5, + "battery": 90, + "window_open": False + } + self.client = None + self.running = True + + def log(self, msg: str): + """Log with timestamp.""" + timestamp = datetime.now().strftime("%H:%M:%S") + print(f"[{timestamp}] {msg}", flush=True) + + async def publish_state(self): + """Publish current state to MQTT (retained, QoS 1).""" + if not self.client: + return + + payload = json.dumps(self.state) + await self.client.publish( + STATE_TOPIC, + payload=payload, + qos=1, + retain=True + ) + self.log(f"📤 Published state: {payload}") + + def apply_temperature_drift(self): + """ + Simulate temperature drift towards target. + Max change: ±0.2°C per interval. + """ + if self.state["mode"] == "off": + # In OFF mode, drift slowly towards ambient (assume 18°C) + ambient = 18.0 + diff = ambient - self.state["current"] + else: + # In HEAT/AUTO mode, drift towards target + diff = self.state["target"] - self.state["current"] + + # Apply max ±0.2°C drift + if abs(diff) < 0.1: + # Close enough, small adjustment + self.state["current"] = round(self.state["current"] + diff, 1) + elif diff > 0: + self.state["current"] = round(self.state["current"] + 0.2, 1) + else: + self.state["current"] = round(self.state["current"] - 0.2, 1) + + self.log(f"🌡️ Temperature drift: current={self.state['current']}°C (target={self.state['target']}°C)") + + async def handle_set_command(self, payload: dict): + """ + Handle SET command from MQTT. + Payload can contain: mode, target + """ + self.log(f"📥 Received SET: {payload}") + + changed = False + + if "mode" in payload: + new_mode = payload["mode"] + if new_mode in ["off", "heat", "auto"]: + self.state["mode"] = new_mode + changed = True + self.log(f" Mode changed to: {new_mode}") + else: + self.log(f" ⚠️ Invalid mode: {new_mode}") + + if "target" in payload: + try: + new_target = float(payload["target"]) + if 5.0 <= new_target <= 30.0: + self.state["target"] = new_target + changed = True + self.log(f" Target changed to: {new_target}°C") + else: + self.log(f" ⚠️ Target out of range: {new_target}") + except (ValueError, TypeError): + self.log(f" ⚠️ Invalid target value: {payload['target']}") + + if changed: + await self.publish_state() + + async def drift_loop(self): + """Background loop for temperature drift simulation.""" + while self.running: + await asyncio.sleep(DRIFT_INTERVAL) + self.apply_temperature_drift() + await self.publish_state() + + async def mqtt_loop(self): + """Main MQTT connection and message handling loop.""" + try: + async with Client( + hostname=BROKER_HOST, + port=BROKER_PORT, + identifier=f"sim_{DEVICE_ID}" + ) as client: + self.client = client + self.log(f"✅ Connected to MQTT broker {BROKER_HOST}:{BROKER_PORT}") + + # Publish initial state + await self.publish_state() + self.log(f"📡 Thermo sim started for {DEVICE_ID}") + + # Subscribe to SET topic + await client.subscribe(SET_TOPIC, qos=1) + self.log(f"👂 Subscribed to {SET_TOPIC}") + + # Start drift loop in background + drift_task = asyncio.create_task(self.drift_loop()) + + # Listen for messages + async for message in client.messages: + try: + payload = json.loads(message.payload.decode()) + await self.handle_set_command(payload) + except json.JSONDecodeError as e: + self.log(f"❌ Invalid JSON: {e}") + except Exception as e: + self.log(f"❌ Error handling message: {e}") + + # Cancel drift loop on disconnect + drift_task.cancel() + + except MqttError as e: + self.log(f"❌ MQTT Error: {e}") + except KeyboardInterrupt: + self.log("⚠️ Interrupted by user") + finally: + self.running = False + self.log("👋 Simulator stopped") + + async def run(self): + """Run the simulator.""" + await self.mqtt_loop() + + +async def main(): + """Entry point.""" + simulator = ThermostatSimulator() + await simulator.run() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n👋 Simulator terminated") + sys.exit(0) diff --git a/tools/test_device_simulator.sh b/tools/test_device_simulator.sh new file mode 100755 index 0000000..6455422 --- /dev/null +++ b/tools/test_device_simulator.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# Test script for device_simulator.py + +set -e # Exit on error + +echo "=== Device Simulator Test Suite ===" +echo "" + +# 1. Stop all running services +echo "1. Stoppe alle laufenden Services..." +pkill -f "device_simulator" 2>/dev/null || true +pkill -f "uvicorn apps" 2>/dev/null || true +pkill -f "apps.abstraction" 2>/dev/null || true +sleep 2 +echo " ✓ Services gestoppt" +echo "" + +# 2. Start services +echo "2. Starte Services..." +poetry run python -m apps.abstraction.main > /tmp/abstraction.log 2>&1 & +ABSTRACTION_PID=$! +echo " Abstraction Layer gestartet (PID: $ABSTRACTION_PID)" +sleep 2 + +poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8001 > /tmp/api.log 2>&1 & +API_PID=$! +echo " API Server gestartet (PID: $API_PID)" +sleep 2 + +poetry run python tools/device_simulator.py > /tmp/simulator.log 2>&1 & +SIM_PID=$! +echo " Device Simulator gestartet (PID: $SIM_PID)" +sleep 2 + +echo " ✓ Alle Services laufen" +echo "" + +# 3. Test API reachability +echo "3. Teste API Erreichbarkeit..." +if timeout 3 curl -s http://localhost:8001/devices > /dev/null; then + echo " ✓ API antwortet" +else + echo " ✗ API antwortet nicht!" + echo " API Log:" + tail -10 /tmp/api.log + exit 1 +fi +echo "" + +# 4. Test Light Operations +echo "4. Teste Lampen-Operationen..." + +# 4.1 Power On +echo " 4.1 Lampe einschalten (test_lampe_1)..." +RESPONSE=$(timeout 3 curl -s -X POST http://localhost:8001/devices/test_lampe_1/set \ + -H "Content-Type: application/json" \ + -d '{"type":"light","payload":{"power":"on"}}') +echo " Response: $RESPONSE" +sleep 1 + +# 4.2 Check state via MQTT +echo " 4.2 Prüfe State via MQTT..." +STATE=$(timeout 2 mosquitto_sub -h 172.16.2.16 -t 'vendor/test_lampe_1/state' -C 1) +echo " State: $STATE" +if echo "$STATE" | grep -q '"power": "on"'; then + echo " ✓ Power ist ON" +else + echo " ✗ Power nicht ON!" +fi + +# 4.3 Brightness +echo " 4.3 Helligkeit setzen (75%)..." +RESPONSE=$(timeout 3 curl -s -X POST http://localhost:8001/devices/test_lampe_1/set \ + -H "Content-Type: application/json" \ + -d '{"type":"light","payload":{"brightness":75}}') +echo " Response: $RESPONSE" +sleep 1 + +STATE=$(timeout 2 mosquitto_sub -h 172.16.2.16 -t 'vendor/test_lampe_1/state' -C 1) +echo " State: $STATE" +if echo "$STATE" | grep -q '"brightness": 75'; then + echo " ✓ Brightness ist 75" +else + echo " ✗ Brightness nicht 75!" +fi +echo "" + +# 5. Test Thermostat Operations +echo "5. Teste Thermostat-Operationen..." + +# 5.1 Set mode and target +echo " 5.1 Setze Mode HEAT und Target 22.5°C..." +RESPONSE=$(timeout 3 curl -s -X POST http://localhost:8001/devices/test_thermo_1/set \ + -H "Content-Type: application/json" \ + -d '{"type":"thermostat","payload":{"mode":"heat","target":22.5}}') +echo " Response: $RESPONSE" +sleep 1 + +STATE=$(timeout 2 mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -C 1) +echo " State: $STATE" +if echo "$STATE" | grep -q '"mode": "heat"' && echo "$STATE" | grep -q '"target": 22.5'; then + echo " ✓ Mode ist HEAT, Target ist 22.5" +else + echo " ✗ Mode oder Target nicht korrekt!" +fi + +# 5.2 Wait for temperature drift +echo " 5.2 Warte 6 Sekunden auf Temperature Drift..." +sleep 6 + +STATE=$(timeout 2 mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -C 1) +echo " State: $STATE" +CURRENT=$(echo "$STATE" | grep -o '"current": [0-9.]*' | grep -o '[0-9.]*$') +echo " Current Temperature: ${CURRENT}°C" +if [ -n "$CURRENT" ]; then + echo " ✓ Temperature drift funktioniert" +else + echo " ✗ Temperature drift nicht sichtbar!" +fi + +# 5.3 Set mode OFF +echo " 5.3 Setze Mode OFF..." +RESPONSE=$(timeout 3 curl -s -X POST http://localhost:8001/devices/test_thermo_1/set \ + -H "Content-Type: application/json" \ + -d '{"type":"thermostat","payload":{"mode":"off","target":22.5}}') +echo " Response: $RESPONSE" +sleep 1 + +STATE=$(timeout 2 mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -C 1) +if echo "$STATE" | grep -q '"mode": "off"'; then + echo " ✓ Mode ist OFF" +else + echo " ✗ Mode nicht OFF!" +fi +echo "" + +# 6. Check simulator log +echo "6. Simulator Log (letzte 20 Zeilen)..." +tail -20 /tmp/simulator.log +echo "" + +# 7. Summary +echo "=== Test Summary ===" +echo "✓ Alle Tests abgeschlossen" +echo "" +echo "Laufende Prozesse:" +echo " Abstraction: PID $ABSTRACTION_PID" +echo " API: PID $API_PID" +echo " Simulator: PID $SIM_PID" +echo "" +echo "Logs verfügbar in:" +echo " /tmp/abstraction.log" +echo " /tmp/api.log" +echo " /tmp/simulator.log"