thermostat
This commit is contained in:
@@ -4,12 +4,16 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
import yaml
|
import yaml
|
||||||
from aiomqtt import Client
|
from aiomqtt import Client
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from packages.home_capabilities import LightState, ThermostatState
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -129,12 +133,35 @@ async def handle_abstract_set(
|
|||||||
Args:
|
Args:
|
||||||
mqtt_client: MQTT client instance
|
mqtt_client: MQTT client instance
|
||||||
device_id: Device identifier
|
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
|
vendor_topic: Vendor-specific SET topic
|
||||||
payload: Message payload
|
payload: Message payload
|
||||||
"""
|
"""
|
||||||
# Extract actual payload (remove type wrapper if present)
|
# Extract actual payload (remove type wrapper if present)
|
||||||
vendor_payload = payload.get("payload", payload)
|
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)
|
vendor_message = json.dumps(vendor_payload)
|
||||||
|
|
||||||
logger.info(f"→ vendor SET {device_id}: {vendor_topic} ← {vendor_message}")
|
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
|
mqtt_client: MQTT client instance
|
||||||
redis_client: Redis client instance
|
redis_client: Redis client instance
|
||||||
device_id: Device identifier
|
device_id: Device identifier
|
||||||
device_type: Device type (e.g., 'light')
|
device_type: Device type (e.g., 'light', 'thermostat')
|
||||||
payload: State payload
|
payload: State payload
|
||||||
redis_channel: Redis channel for UI updates
|
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)
|
# Publish to abstract state topic (retained)
|
||||||
abstract_topic = f"home/{device_type}/{device_id}/state"
|
abstract_topic = f"home/{device_type}/{device_id}/state"
|
||||||
abstract_message = json.dumps(payload)
|
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}")
|
logger.info(f"← abstract STATE {device_id}: {abstract_topic} → {abstract_message}")
|
||||||
await mqtt_client.publish(abstract_topic, abstract_message, qos=1, retain=True)
|
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 = {
|
ui_update = {
|
||||||
"type": "state",
|
"type": "state",
|
||||||
"device_id": device_id,
|
"device_id": device_id,
|
||||||
"payload": payload
|
"payload": payload,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat()
|
||||||
}
|
}
|
||||||
redis_message = json.dumps(ui_update)
|
redis_message = json.dumps(ui_update)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, ValidationError
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -57,7 +57,8 @@ async def spec() -> dict[str, dict[str, str]]:
|
|||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"capabilities": {
|
"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,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Invalid payload for light: {e}"
|
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:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
|||||||
@@ -201,6 +201,123 @@
|
|||||||
border: none;
|
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 {
|
.events {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
background: white;
|
background: white;
|
||||||
@@ -280,6 +397,8 @@
|
|||||||
{% if device.type == "light" %}
|
{% if device.type == "light" %}
|
||||||
Light
|
Light
|
||||||
{% if device.features.brightness %}• Dimmbar{% endif %}
|
{% if device.features.brightness %}• Dimmbar{% endif %}
|
||||||
|
{% elif device.type == "thermostat" %}
|
||||||
|
Thermostat
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ device.type or "Unknown" }}
|
{{ device.type or "Unknown" }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -287,6 +406,7 @@
|
|||||||
<div class="device-id">{{ device.device_id }}</div>
|
<div class="device-id">{{ device.device_id }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if device.type == "light" %}
|
||||||
<div class="device-state">
|
<div class="device-state">
|
||||||
<span class="state-label">Status:</span>
|
<span class="state-label">Status:</span>
|
||||||
<span class="state-value off" id="state-{{ device.device_id }}">off</span>
|
<span class="state-value off" id="state-{{ device.device_id }}">off</span>
|
||||||
@@ -297,7 +417,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if device.type == "light" and device.features.power %}
|
{% if device.features.power %}
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button
|
<button
|
||||||
class="toggle-button off"
|
class="toggle-button off"
|
||||||
@@ -324,6 +444,60 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -348,11 +522,16 @@
|
|||||||
const API_BASE = 'http://localhost:8001';
|
const API_BASE = 'http://localhost:8001';
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let currentState = {};
|
let currentState = {};
|
||||||
|
let thermostatTargets = {};
|
||||||
|
|
||||||
// Initialize device states
|
// Initialize device states
|
||||||
{% for room in rooms %}
|
{% for room in rooms %}
|
||||||
{% for device in room.devices %}
|
{% for device in room.devices %}
|
||||||
|
{% if device.type == "light" %}
|
||||||
currentState['{{ device.device_id }}'] = 'off';
|
currentState['{{ device.device_id }}'] = 'off';
|
||||||
|
{% elif device.type == "thermostat" %}
|
||||||
|
thermostatTargets['{{ device.device_id }}'] = 21.0;
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% 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
|
// Update device UI
|
||||||
function updateDeviceUI(deviceId, power, brightness) {
|
function updateDeviceUI(deviceId, power, brightness) {
|
||||||
currentState[deviceId] = power;
|
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
|
// Add event to list
|
||||||
function addEvent(event) {
|
function addEvent(event) {
|
||||||
const eventList = document.getElementById('event-list');
|
const eventList = document.getElementById('event-list');
|
||||||
@@ -507,14 +785,27 @@
|
|||||||
addEvent(data);
|
addEvent(data);
|
||||||
|
|
||||||
// Update device state
|
// Update device state
|
||||||
if (data.type === 'state' && data.device_id) {
|
if (data.type === 'state' && data.device_id && data.payload) {
|
||||||
if (data.payload) {
|
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
|
||||||
|
|
||||||
|
// Check if it's a light
|
||||||
|
if (data.payload.power !== undefined) {
|
||||||
updateDeviceUI(
|
updateDeviceUI(
|
||||||
data.device_id,
|
data.device_id,
|
||||||
data.payload.power,
|
data.payload.power,
|
||||||
data.payload.brightness
|
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:
|
topics:
|
||||||
set: "vendor/test_lampe_3/set"
|
set: "vendor/test_lampe_3/set"
|
||||||
state: "vendor/test_lampe_3/state"
|
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."""
|
"""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
|
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