thermostat

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

View File

@@ -4,12 +4,16 @@ import asyncio
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import redis.asyncio as aioredis
import yaml
from aiomqtt import Client
from pydantic import ValidationError
from packages.home_capabilities import LightState, ThermostatState
# Configure logging
logging.basicConfig(
@@ -129,12 +133,35 @@ async def handle_abstract_set(
Args:
mqtt_client: MQTT client instance
device_id: Device identifier
device_type: Device type (e.g., 'light')
device_type: Device type (e.g., 'light', 'thermostat')
vendor_topic: Vendor-specific SET topic
payload: Message payload
"""
# Extract actual payload (remove type wrapper if present)
vendor_payload = payload.get("payload", payload)
# Validate payload based on device type
try:
if device_type == "light":
# Validate light SET payload (power and/or brightness)
LightState.model_validate(vendor_payload)
elif device_type == "thermostat":
# For thermostat SET: only allow mode and target fields
allowed_set_fields = {"mode", "target"}
invalid_fields = set(vendor_payload.keys()) - allowed_set_fields
if invalid_fields:
logger.warning(
f"Thermostat SET {device_id} contains invalid fields {invalid_fields}, "
f"only {allowed_set_fields} allowed"
)
return
# Validate against ThermostatState (current/battery/window_open are optional)
ThermostatState.model_validate(vendor_payload)
except ValidationError as e:
logger.error(f"Validation failed for {device_type} SET {device_id}: {e}")
return
vendor_message = json.dumps(vendor_payload)
logger.info(f"→ vendor SET {device_id}: {vendor_topic}{vendor_message}")
@@ -155,10 +182,21 @@ async def handle_vendor_state(
mqtt_client: MQTT client instance
redis_client: Redis client instance
device_id: Device identifier
device_type: Device type (e.g., 'light')
device_type: Device type (e.g., 'light', 'thermostat')
payload: State payload
redis_channel: Redis channel for UI updates
"""
# Validate state payload based on device type
try:
if device_type == "light":
LightState.model_validate(payload)
elif device_type == "thermostat":
# Validate thermostat state: mode, target, current (required), battery, window_open
ThermostatState.model_validate(payload)
except ValidationError as e:
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
return
# Publish to abstract state topic (retained)
abstract_topic = f"home/{device_type}/{device_id}/state"
abstract_message = json.dumps(payload)
@@ -166,11 +204,12 @@ async def handle_vendor_state(
logger.info(f"← abstract STATE {device_id}: {abstract_topic}{abstract_message}")
await mqtt_client.publish(abstract_topic, abstract_message, qos=1, retain=True)
# Publish to Redis for UI updates
# Publish to Redis for UI updates with timestamp
ui_update = {
"type": "state",
"device_id": device_id,
"payload": payload
"payload": payload,
"ts": datetime.now(timezone.utc).isoformat()
}
redis_message = json.dumps(ui_update)

View File

@@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ValidationError
from packages.home_capabilities import CAP_VERSION, LightState
from packages.home_capabilities import LIGHT_VERSION, THERMOSTAT_VERSION, LightState, ThermostatState
logger = logging.getLogger(__name__)
@@ -57,7 +57,8 @@ async def spec() -> dict[str, dict[str, str]]:
"""
return {
"capabilities": {
"light": CAP_VERSION
"light": LIGHT_VERSION,
"thermostat": THERMOSTAT_VERSION
}
}
@@ -233,6 +234,22 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid payload for light: {e}"
)
elif request.type == "thermostat":
try:
# For thermostat SET: only allow mode and target
allowed_set_fields = {"mode", "target"}
invalid_fields = set(request.payload.keys()) - allowed_set_fields
if invalid_fields:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Thermostat SET only allows {allowed_set_fields}, got invalid fields: {invalid_fields}"
)
ThermostatState(**request.payload)
except ValidationError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid payload for thermostat: {e}"
)
else:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,

View File

@@ -201,6 +201,123 @@
border: none;
}
/* Thermostat styles */
.thermostat-display {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 1rem 0;
}
.temp-reading {
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.temp-label {
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.temp-value {
font-size: 2rem;
font-weight: 700;
color: #333;
}
.temp-unit {
font-size: 1rem;
color: #999;
}
.mode-display {
background: #f8f9fa;
border-radius: 8px;
padding: 0.75rem;
text-align: center;
margin-bottom: 1rem;
}
.mode-label {
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
}
.mode-value {
font-size: 1rem;
font-weight: 600;
color: #667eea;
text-transform: uppercase;
}
.temp-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.temp-button {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 8px;
font-size: 1.125rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
background: #667eea;
color: white;
min-height: 44px;
}
.temp-button:hover {
background: #5568d3;
}
.temp-button:active {
transform: scale(0.95);
}
.mode-controls {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.mode-button {
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
background: white;
color: #666;
min-height: 44px;
text-transform: uppercase;
}
.mode-button:hover {
border-color: #667eea;
color: #667eea;
}
.mode-button.active {
background: #667eea;
border-color: #667eea;
color: white;
}
.mode-button:active {
transform: scale(0.95);
}
.events {
margin-top: 2rem;
background: white;
@@ -280,6 +397,8 @@
{% if device.type == "light" %}
Light
{% if device.features.brightness %}• Dimmbar{% endif %}
{% elif device.type == "thermostat" %}
Thermostat
{% else %}
{{ device.type or "Unknown" }}
{% endif %}
@@ -287,6 +406,7 @@
<div class="device-id">{{ device.device_id }}</div>
</div>
{% if device.type == "light" %}
<div class="device-state">
<span class="state-label">Status:</span>
<span class="state-value off" id="state-{{ device.device_id }}">off</span>
@@ -297,7 +417,7 @@
{% endif %}
</div>
{% if device.type == "light" and device.features.power %}
{% if device.features.power %}
<div class="controls">
<button
class="toggle-button off"
@@ -324,6 +444,60 @@
{% endif %}
</div>
{% endif %}
{% elif device.type == "thermostat" %}
<div class="thermostat-display">
<div class="temp-reading">
<div class="temp-label">Ist</div>
<div class="temp-value">
<span id="state-{{ device.device_id }}-current">--</span>
<span class="temp-unit">°C</span>
</div>
</div>
<div class="temp-reading">
<div class="temp-label">Soll</div>
<div class="temp-value">
<span id="state-{{ device.device_id }}-target">21.0</span>
<span class="temp-unit">°C</span>
</div>
</div>
</div>
<div class="mode-display">
<div class="mode-label">Modus</div>
<div class="mode-value" id="state-{{ device.device_id }}-mode">OFF</div>
</div>
<div class="temp-controls">
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', -0.5)">
-0.5
</button>
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', 0.5)">
+0.5
</button>
</div>
<div class="mode-controls">
<button
class="mode-button"
id="mode-{{ device.device_id }}-off"
onclick="setMode('{{ device.device_id }}', 'off')">
Off
</button>
<button
class="mode-button"
id="mode-{{ device.device_id }}-heat"
onclick="setMode('{{ device.device_id }}', 'heat')">
Heat
</button>
<button
class="mode-button"
id="mode-{{ device.device_id }}-auto"
onclick="setMode('{{ device.device_id }}', 'auto')">
Auto
</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
@@ -348,11 +522,16 @@
const API_BASE = 'http://localhost:8001';
let eventSource = null;
let currentState = {};
let thermostatTargets = {};
// Initialize device states
{% for room in rooms %}
{% for device in room.devices %}
{% if device.type == "light" %}
currentState['{{ device.device_id }}'] = 'off';
{% elif device.type == "thermostat" %}
thermostatTargets['{{ device.device_id }}'] = 21.0;
{% endif %}
{% endfor %}
{% endfor %}
@@ -424,6 +603,71 @@
}
}
// Adjust thermostat target temperature
async function adjustTarget(deviceId, delta) {
const currentTarget = thermostatTargets[deviceId] || 21.0;
const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta));
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'thermostat',
payload: {
target: newTarget
}
})
});
if (response.ok) {
thermostatTargets[deviceId] = newTarget;
console.log(`Sent target ${newTarget} to ${deviceId}`);
addEvent({
action: 'target_adjusted',
device_id: deviceId,
target: newTarget
});
}
} catch (error) {
console.error('Failed to adjust target:', error);
}
}
// Set thermostat mode
async function setMode(deviceId, mode) {
const currentTarget = thermostatTargets[deviceId] || 21.0;
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'thermostat',
payload: {
mode: mode,
target: currentTarget
}
})
});
if (response.ok) {
console.log(`Sent mode ${mode} to ${deviceId}`);
addEvent({
action: 'mode_set',
device_id: deviceId,
mode: mode
});
}
} catch (error) {
console.error('Failed to set mode:', error);
}
}
// Update device UI
function updateDeviceUI(deviceId, power, brightness) {
currentState[deviceId] = power;
@@ -464,6 +708,40 @@
}
}
// Update thermostat UI
function updateThermostatUI(deviceId, current, target, mode) {
const currentSpan = document.getElementById(`state-${deviceId}-current`);
const targetSpan = document.getElementById(`state-${deviceId}-target`);
const modeSpan = document.getElementById(`state-${deviceId}-mode`);
if (current !== undefined && currentSpan) {
currentSpan.textContent = current.toFixed(1);
}
if (target !== undefined) {
if (targetSpan) {
targetSpan.textContent = target.toFixed(1);
}
thermostatTargets[deviceId] = target;
}
if (mode !== undefined && modeSpan) {
modeSpan.textContent = mode.toUpperCase();
// Update mode button states
['off', 'heat', 'auto'].forEach(m => {
const btn = document.getElementById(`mode-${deviceId}-${m}`);
if (btn) {
if (m === mode.toLowerCase()) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
}
});
}
}
// Add event to list
function addEvent(event) {
const eventList = document.getElementById('event-list');
@@ -507,14 +785,27 @@
addEvent(data);
// Update device state
if (data.type === 'state' && data.device_id) {
if (data.payload) {
if (data.type === 'state' && data.device_id && data.payload) {
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
// Check if it's a light
if (data.payload.power !== undefined) {
updateDeviceUI(
data.device_id,
data.payload.power,
data.payload.brightness
);
}
// Check if it's a thermostat
if (data.payload.mode !== undefined || data.payload.target !== undefined || data.payload.current !== undefined) {
updateThermostatUI(
data.device_id,
data.payload.current,
data.payload.target,
data.payload.mode
);
}
}
});