thermostat

This commit is contained in:
2025-11-05 18:26:36 +01:00
parent 478450794f
commit cb555a1f67
10 changed files with 1293 additions and 11 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 @@
<div class="device-id">{{ device.device_id }}</div>
</div>
{% if device.type == "light" %}
<div class="device-state">
<span class="state-label">Status:</span>
<span class="state-value off" id="state-{{ device.device_id }}">off</span>
@@ -297,7 +417,7 @@
{% endif %}
</div>
{% if device.type == "light" and device.features.power %}
{% if device.features.power %}
<div class="controls">
<button
class="toggle-button off"
@@ -324,6 +444,60 @@
{% endif %}
</div>
{% endif %}
{% elif device.type == "thermostat" %}
<div class="thermostat-display">
<div class="temp-reading">
<div class="temp-label">Ist</div>
<div class="temp-value">
<span id="state-{{ device.device_id }}-current">--</span>
<span class="temp-unit">°C</span>
</div>
</div>
<div class="temp-reading">
<div class="temp-label">Soll</div>
<div class="temp-value">
<span id="state-{{ device.device_id }}-target">21.0</span>
<span class="temp-unit">°C</span>
</div>
</div>
</div>
<div class="mode-display">
<div class="mode-label">Modus</div>
<div class="mode-value" id="state-{{ device.device_id }}-mode">OFF</div>
</div>
<div class="temp-controls">
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', -0.5)">
-0.5
</button>
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', 0.5)">
+0.5
</button>
</div>
<div class="mode-controls">
<button
class="mode-button"
id="mode-{{ device.device_id }}-off"
onclick="setMode('{{ device.device_id }}', 'off')">
Off
</button>
<button
class="mode-button"
id="mode-{{ device.device_id }}-heat"
onclick="setMode('{{ device.device_id }}', 'heat')">
Heat
</button>
<button
class="mode-button"
id="mode-{{ device.device_id }}-auto"
onclick="setMode('{{ device.device_id }}', 'auto')">
Auto
</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
@@ -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
);
}
}
});

View File

@@ -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"

View File

@@ -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",
]

View File

@@ -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
}
]
}
}

View File

@@ -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.

285
tools/device_simulator.py Executable file
View File

@@ -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)

205
tools/sim_thermo.py Executable file
View File

@@ -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)

154
tools/test_device_simulator.sh Executable file
View File

@@ -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"