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
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user