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 %}
{% 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"