thermostat
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
77
packages/home_capabilities/thermostat.py
Normal file
77
packages/home_capabilities/thermostat.py
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
190
tools/README_device_simulator.md
Normal file
190
tools/README_device_simulator.md
Normal 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
285
tools/device_simulator.py
Executable 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
205
tools/sim_thermo.py
Executable 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
154
tools/test_device_simulator.sh
Executable 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"
|
||||
Reference in New Issue
Block a user