Compare commits

...

37 Commits

Author SHA1 Message Date
f389115841 kontakte 2025-11-10 21:20:43 +01:00
19a6a603d5 window contact first try 2025-11-10 19:41:08 +01:00
e728dd58e4 all collapsed at load/refresh 2025-11-10 17:11:50 +01:00
6310fedeea zigbee2mqtt thermostat transformation 2025-11-10 17:07:41 +01:00
e113616abf fix 2025-11-10 16:58:46 +01:00
e8cd34f88f thermostat mode optional 2025-11-10 16:54:09 +01:00
1bd175c912 fix 2025-11-10 16:42:15 +01:00
cc566c9e73 drop mode from thermostat ui 2025-11-10 16:36:20 +01:00
2eb4f3c376 max thermostats added 2025-11-10 16:19:55 +01:00
b57ddb1589 MAX transformation added 2025-11-10 16:11:28 +01:00
a49d56df60 temperaturschrittweite 1.0 2025-11-10 16:04:44 +01:00
5a7b16f7aa mode buttons removed from thermostat 2025-11-10 16:02:23 +01:00
e69822719a fix 2025-11-10 12:35:06 +01:00
25a6b98d41 alle lampen 2025-11-10 12:28:23 +01:00
5f7af7574c sse iphone fix 4 2025-11-09 21:19:06 +01:00
0c73e36e82 sse iphone fix 2 2025-11-09 20:12:08 +01:00
01b60671db sse iphone fix 1 2025-11-09 20:05:35 +01:00
b60fdfced4 refresh 3 2025-11-09 18:52:52 +01:00
0cd0c6de41 refresh 2 2025-11-09 18:40:31 +01:00
ecf5aebc3c refresh 2025-11-09 18:19:20 +01:00
79d87aff6a transformation added 3 2025-11-09 13:31:07 +01:00
b1e9b201d1 transformation added 2 2025-11-09 13:26:55 +01:00
1eff8a2044 transformation added 2025-11-09 12:59:15 +01:00
8fd0921a08 experiment light 1 2025-11-09 12:21:25 +01:00
7304a017c2 disable mode of thermostat 2025-11-09 00:12:43 +01:00
db6da4815c klappbare Räume 4 2025-11-08 23:40:50 +01:00
54f53705c0 klappbare Räume 3 2025-11-08 21:04:51 +01:00
f8144496b3 klappbare Räume 2 2025-11-08 18:29:00 +01:00
50e7402152 klappbare Räume 2025-11-08 18:27:23 +01:00
eb822c0318 fixes 2 2025-11-08 17:48:38 +01:00
acb5e0a209 fixes 2025-11-08 17:36:52 +01:00
4b196c1278 iphone fix 2025-11-08 16:23:11 +01:00
7e04991d64 room cards 2 2025-11-08 16:04:46 +01:00
cc3364068a room cards 2025-11-08 16:03:58 +01:00
c1cbca39bf cors 2025-11-08 15:59:18 +01:00
6271f46019 use correct broker setting 2025-11-08 15:56:03 +01:00
6bf8ac3f99 docs 2025-11-06 16:50:23 +01:00
22 changed files with 3005 additions and 351 deletions

41
DEVICES_BY_ROOM.md Normal file
View File

@@ -0,0 +1,41 @@
Schlafzimmer:
- Bettlicht Patty | 0x0017880108158b32
- Bettlicht Wolfgang | 0x00178801081570bf
- Deckenlampe Schlafzimmer | 0x0017880108a406a7
- Medusa-Lampe Schlafzimmer | 0xf0d1b80000154c7c
Esszimmer:
- Deckenlampe Esszimmer | 0x0017880108a03e45
- Leselampe Esszimmer | 0xec1bbdfffe7b84f2
- Standlampe Esszimmer | 0xbc33acfffe21f547
- kleine Lampe links Esszimmer | 0xf0d1b80000153099
- kleine Lampe rechts Esszimmer | 0xf0d1b80000156645
Wohnzimmer:
- Lampe Naehtischchen Wohnzimmer | 0x842e14fffee560ee
- Lampe Semeniere Wohnzimmer | 0xf0d1b8000015480b
- Sterne Wohnzimmer | 0xf0d1b80000155fc2
- grosse Lampe Wohnzimmer | 0xf0d1b80000151aca
Küche:
- Küche Deckenlampe | 0x001788010d2c40c4
- Kueche | 0x94deb8fffe2e5c06
Arbeitszimmer Patty:
- Leselampe Patty | 0x001788010600ec9d
- Schranklicht hinten Patty | 0x0017880106e29571
- Schranklicht vorne Patty | 0xf0d1b80000154cf5
Arbeitszimmer Wolfgang:
- Wolfgang | 0x540f57fffe7e3cfe
- ExperimentLabTest | 0xf0d1b80000195038
Flur:
- Deckenlampe Flur oben | 0x001788010d2123a7
- Haustür | 0xec1bbdfffea6a3da
- Licht Flur oben am Spiegel | 0x842e14fffefe4ba4
Sportzimmer:
- Sportlicht Regal | 0xf0d1b8be2409f569
- Sportlicht Tisch | 0xf0d1b8be2409f31b
- Sportlicht am Fernseher, Studierzimmer | 0x842e14fffe76a23a

230
DOCKER_GUIDE.md Normal file
View File

@@ -0,0 +1,230 @@
# Docker Guide für Home Automation
Vollständige Anleitung zum Ausführen aller Services mit Docker/finch.
## Quick Start - Alle Services starten
### Linux Server (empfohlen - mit Docker Network)
```bash
# 1. Images bauen
docker build -t api:dev -f apps/api/Dockerfile .
docker build -t ui:dev -f apps/ui/Dockerfile .
docker build -t abstraction:dev -f apps/abstraction/Dockerfile .
docker build -t simulator:dev -f apps/simulator/Dockerfile .
# 2. Netzwerk erstellen
docker network create home-automation
# 3. Abstraction Layer (MQTT Worker)
docker run -d --name abstraction \
--network home-automation \
-v $(pwd)/config:/app/config:ro \
-e MQTT_BROKER=172.16.2.16 \
-e REDIS_HOST=172.23.1.116 \
-e REDIS_DB=8 \
abstraction:dev
# 4. API Server
docker run -d --name api \
--network home-automation \
-p 8001:8001 \
-v $(pwd)/config:/app/config:ro \
-e MQTT_BROKER=172.16.2.16 \
-e REDIS_HOST=172.23.1.116 \
-e REDIS_DB=8 \
api:dev
# 5. Web UI
docker run -d --name ui \
--network home-automation \
-p 8002:8002 \
-e API_BASE=http://api:8001 \
ui:dev
# 6. Device Simulator (optional)
docker run -d --name simulator \
--network home-automation \
-p 8010:8010 \
-e MQTT_BROKER=172.16.2.16 \
simulator:dev
```
### macOS mit finch/nerdctl (Alternative)
```bash
# Images bauen (wie oben)
# Abstraction Layer
docker run -d --name abstraction \
-v $(pwd)/config:/app/config:ro \
-e MQTT_BROKER=172.16.2.16 \
-e REDIS_HOST=172.23.1.116 \
-e REDIS_DB=8 \
abstraction:dev
# API Server
docker run -d --name api \
-p 8001:8001 \
-v $(pwd)/config:/app/config:ro \
-e MQTT_BROKER=172.16.2.16 \
-e REDIS_HOST=172.23.1.116 \
-e REDIS_DB=8 \
api:dev
# Web UI (mit host.docker.internal für macOS)
docker run -d --name ui \
--add-host=host.docker.internal:host-gateway \
-p 8002:8002 \
-e API_BASE=http://host.docker.internal:8001 \
ui:dev
# Device Simulator
docker run -d --name simulator \
-p 8010:8010 \
-e MQTT_BROKER=172.16.2.16 \
simulator:dev
```
## Zugriff
- **Web UI**: http://<server-ip>:8002
- **API Docs**: http://<server-ip>:8001/docs
- **Simulator**: http://<server-ip>:8010
Auf localhost: `127.0.0.1` oder `localhost`
## finch/nerdctl Besonderheiten
### Port-Binding Verhalten (nur macOS/Windows)
**Standard Docker auf Linux:**
- `-p 8001:8001` → bindet auf `0.0.0.0:8001` (von überall erreichbar)
**finch/nerdctl auf macOS:**
- `-p 8001:8001` → bindet auf `127.0.0.1:8001` (nur localhost)
- Dies ist ein **Security-Feature** von nerdctl
- **Auf Linux-Servern ist das KEIN Problem!**
### Container-to-Container Kommunikation
**Linux (empfohlen):**
```bash
# Docker Network verwenden - Container sprechen sich mit Namen an
docker network create home-automation
docker run --network home-automation --name api ...
docker run --network home-automation -e API_BASE=http://api:8001 ui ...
```
**macOS mit finch:**
```bash
# host.docker.internal verwenden
docker run --add-host=host.docker.internal:host-gateway \
-e API_BASE=http://host.docker.internal:8001 ui ...
```
## Container verwalten
```bash
# Alle Container anzeigen
docker ps
# Logs anschauen
docker logs api
docker logs ui -f # Follow mode
# Container stoppen
docker stop api ui abstraction simulator
# Container entfernen
docker rm api ui abstraction simulator
# Alles neu starten
docker stop api ui abstraction simulator && \
docker rm api ui abstraction simulator && \
# ... dann Quick Start Befehle von oben
```
## Troubleshooting
### UI zeigt "Keine Räume oder Geräte konfiguriert"
**Problem:** UI kann API nicht erreichen
**Linux - Lösung:**
```bash
# Verwende Docker Network
docker network create home-automation
docker stop ui && docker rm ui
docker run -d --name ui \
--network home-automation \
-p 8002:8002 \
-e API_BASE=http://api:8001 \
ui:dev
```
**macOS/finch - Lösung:**
```bash
docker stop ui && docker rm ui
docker run -d --name ui \
--add-host=host.docker.internal:host-gateway \
-p 8002:8002 \
-e API_BASE=http://host.docker.internal:8001 \
ui:dev
```
### "Connection refused" in Logs
**Check 1:** Ist die API gestartet?
```bash
docker ps | grep api
curl http://127.0.0.1:8001/health
```
**Check 2:** Hat UI die richtige API_BASE?
```bash
docker inspect ui | grep API_BASE
```
### Port bereits belegt
```bash
# Prüfe welcher Prozess Port 8001 nutzt
lsof -i :8001
# Oder mit netstat
netstat -an | grep 8001
# Alte Container aufräumen
docker ps -a | grep -E "api|ui|abstraction|simulator"
docker rm -f <container-id>
```
## Produktiv-Deployment
Für Produktion auf **Linux-Servern** empfohlen:
1. **Docker Compose** (siehe `infra/docker-compose.yml`)
2. **Docker Network** für Service Discovery (siehe Linux Quick Start oben)
3. **Volume Mounts** für Persistenz
4. **Health Checks** in Kubernetes/Compose (nicht im Dockerfile)
### Beispiel mit Docker Network (Linux)
```bash
# Netzwerk erstellen
docker network create home-automation
# Services starten (alle im gleichen Netzwerk)
docker run -d --name api --network home-automation \
-p 8001:8001 \
-v $(pwd)/config:/app/config:ro \
api:dev
docker run -d --name ui --network home-automation \
-p 8002:8002 \
-e API_BASE=http://api:8001 \
ui:dev
```
**Vorteil:** Service Discovery über Container-Namen, keine `--add-host` Tricks nötig.

223
MAX_INTEGRATION.md Normal file
View File

@@ -0,0 +1,223 @@
# MAX! (eQ-3) Thermostat Integration
## Overview
This document describes the integration of MAX! (eQ-3) thermostats via Homegear into the home automation system.
## Protocol Characteristics
MAX! thermostats use a **simple integer-based protocol** (not JSON):
- **SET messages**: Plain integer temperature value (e.g., `22`)
- **STATE messages**: Plain integer temperature value (e.g., `22`)
- **Topics**: Homegear MQTT format
### MQTT Topics
**SET Command:**
```
homegear/instance1/set/<peerId>/<channel>/SET_TEMPERATURE
Payload: "22" (plain integer as string)
```
**STATE Update:**
```
homegear/instance1/plain/<peerId>/<channel>/SET_TEMPERATURE
Payload: "22" (plain integer as string)
```
## Transformation Layer
The abstraction layer provides automatic transformation between the abstract home protocol and MAX! format.
### Abstract → MAX! (SET)
**Input (Abstract):**
```json
{
"mode": "heat",
"target": 22.5
}
```
**Output (MAX!):**
```
22
```
**Transformation Rules:**
- Extract `target` temperature
- Convert float → integer (round to nearest)
- Return as plain string (no JSON)
- Ignore `mode` field (MAX! always in heating mode)
### MAX! → Abstract (STATE)
**Input (MAX!):**
```
22
```
**Output (Abstract):**
```json
{
"target": 22.0,
"mode": "heat"
}
```
**Transformation Rules:**
- Parse plain string/integer value
- Convert to float
- Add default `mode: "heat"` (MAX! always heating)
- Wrap in abstract payload structure
## Device Configuration
### Example devices.yaml Entry
```yaml
- device_id: "thermostat_wolfgang"
type: "thermostat"
cap_version: "thermostat@1.0.0"
technology: "max"
features:
mode: true
target: true
current: false # SET_TEMPERATURE doesn't report current temp
topics:
set: "homegear/instance1/set/39/1/SET_TEMPERATURE"
state: "homegear/instance1/plain/39/1/SET_TEMPERATURE"
metadata:
friendly_name: "Thermostat Wolfgang"
location: "Arbeitszimmer Wolfgang"
vendor: "eQ-3"
model: "MAX! Thermostat"
peer_id: "39"
channel: "1"
```
### Configuration Notes
1. **technology**: Must be set to `"max"` to activate MAX! transformations
2. **topics.set**: Use Homegear's `/set/` path with `/SET_TEMPERATURE` parameter
3. **topics.state**: Use Homegear's `/plain/` path with `/SET_TEMPERATURE` parameter
4. **features.current**: Set to `false` - SET_TEMPERATURE topic doesn't provide current temperature
5. **metadata**: Include `peer_id` and `channel` for reference
## Temperature Rounding
MAX! only supports **integer temperatures**. The system uses standard rounding:
| Abstract Input | MAX! Output |
|----------------|-------------|
| 20.4°C | 20 |
| 20.5°C | 20 |
| 20.6°C | 21 |
| 21.5°C | 22 |
| 22.5°C | 22 |
Python's `round()` function uses "banker's rounding" (round half to even).
## Limitations
1. **No current temperature**: SET_TEMPERATURE topic only reports target, not actual temperature
2. **No mode control**: MAX! thermostats are always in heating mode
3. **Integer only**: Temperature precision limited to 1°C steps
4. **No battery status**: Not available via SET_TEMPERATURE topic
5. **No window detection**: Not available via SET_TEMPERATURE topic
## Testing
Test the transformation functions:
```bash
poetry run python /tmp/test_max_transform.py
```
Expected output:
```
✅ PASS: Float 22.5 -> Integer string
✅ PASS: Integer string -> Abstract dict
✅ PASS: Integer -> Abstract dict
✅ PASS: Rounding works correctly
🎉 All MAX! transformation tests passed!
```
## Implementation Details
### Files Modified
1. **apps/abstraction/transformation.py**
- Added `_transform_thermostat_max_to_vendor()` - converts abstract → plain integer
- Added `_transform_thermostat_max_to_abstract()` - converts plain integer → abstract
- Registered handlers in `TRANSFORM_HANDLERS` registry
2. **apps/abstraction/main.py**
- Modified `handle_abstract_set()` to send plain string for MAX! devices (not JSON)
- Modified message processing to handle plain text payloads from MAX! STATE topics
### Transformation Functions
```python
def _transform_thermostat_max_to_vendor(payload: dict[str, Any]) -> str:
"""Convert {"target": 22.5} → "22" """
target_temp = payload.get("target", 21.0)
return str(int(round(target_temp)))
def _transform_thermostat_max_to_abstract(payload: str | int | float) -> dict[str, Any]:
"""Convert "22" → {"target": 22.0, "mode": "heat"} """
target_temp = float(payload)
return {"target": target_temp, "mode": "heat"}
```
## Usage Example
### Setting Temperature via API
```bash
curl -X POST http://localhost:8001/devices/thermostat_wolfgang/set \
-H "Content-Type: application/json" \
-d '{
"type": "thermostat",
"payload": {
"mode": "heat",
"target": 22.5
}
}'
```
**Flow:**
1. API receives abstract payload: `{"mode": "heat", "target": 22.5}`
2. Abstraction transforms to MAX!: `"22"`
3. Publishes to: `homegear/instance1/set/39/1/SET_TEMPERATURE` with payload `22`
### Receiving State Updates
**Homegear publishes:**
```
Topic: homegear/instance1/plain/39/1/SET_TEMPERATURE
Payload: 22
```
**Flow:**
1. Abstraction receives plain text: `"22"`
2. Transforms to abstract: `{"target": 22.0, "mode": "heat"}`
3. Publishes to: `home/thermostat/thermostat_wolfgang/state`
4. Publishes to Redis: `ui:updates` channel for real-time UI updates
## Future Enhancements
Potential improvements for better MAX! integration:
1. **Current Temperature**: Subscribe to separate Homegear topic for actual temperature
2. **Battery Status**: Subscribe to LOWBAT or battery level topics
3. **Valve Position**: Monitor actual valve opening percentage
4. **Window Detection**: Subscribe to window open detection status
5. **Mode Control**: Support comfort/eco temperature presets
## Related Documentation
- [Homegear MAX! Documentation](https://doc.homegear.eu/data/homegear-max/)
- [Abstract Protocol Specification](docs/PROTOCOL.md)
- [Transformation Layer Design](apps/abstraction/README.md)

View File

@@ -0,0 +1,54 @@
# Nicht berücksichtigte Zigbee-Geräte
## Switches (0)
~~Gerät "Sterne Wohnzimmer" wurde als Light zu devices.yaml hinzugefügt~~
## Sensoren und andere Geräte (22)
### Tür-/Fenstersensoren (7)
- Wolfgang (MCCGQ11LM) - 0x00158d008b3328da
- Terassentür (MCCGQ11LM) - 0x00158d008b332788
- Garten Kueche (MCCGQ11LM) - 0x00158d008b332785
- Strasse rechts Kueche (MCCGQ11LM) - 0x00158d008b151803
- Strasse links Kueche (MCCGQ11LM) - 0x00158d008b331d0b
- Fenster Bad oben (MCCGQ11LM) - 0x00158d008b333aec
- Fenster Patty Strasse (MCCGQ11LM) - 0x00158d000af457cf
### Temperatur-/Feuchtigkeitssensoren (11)
- Kueche (WSDCGQ11LM) - 0x00158d00083299bb
- Wolfgang (WSDCGQ11LM) - 0x00158d000543fb99
- Patty (WSDCGQ11LM) - 0x00158d0003f052b7
- Schlafzimmer (WSDCGQ01LM) - 0x00158d00043292dc
- Bad oben (WSDCGQ11LM) - 0x00158d00093e8987
- Flur (WSDCGQ11LM) - 0x00158d000836ccc6
- Wohnzimmer (WSDCGQ11LM) - 0x00158d0008975707
- Bad unten (WSDCGQ11LM) - 0x00158d00093e662a
- Waschkueche (WSDCGQ11LM) - 0x00158d000449f3bc
- Studierzimmer (WSDCGQ11LM) - 0x00158d0009421422
- Wolfgang (SONOFF SNZB-02D) - 0x0ceff6fffe39a196
### Schalter (2)
- Schalter Schlafzimmer (Philips 929003017102) - 0x001788010cc490d4
- Schalter Bettlicht Patty (WXKG11LM) - 0x00158d000805d165
### Bewegungsmelder (1)
- Bewegungsmelder 8 (Philips 9290012607) - 0x001788010867d420
### Wasserleck-Sensor (1)
- unter Therme (SJCGQ11LM) - 0x00158d008b3a83a9
## Zusammenfassung
**Unterstützt in devices.yaml:**
- 24 Lampen (lights)
- 2 Thermostate
**Nicht unterstützt:**
- 0 Switches
- 7 Tür-/Fenstersensoren
- 11 Temperatur-/Feuchtigkeitssensoren
- 2 Schalter (Button-Devices)
- 1 Bewegungsmelder
- 1 Wasserleck-Sensor
Die nicht unterstützten Geräte könnten in Zukunft durch Erweiterung des Systems integriert werden.

View File

@@ -32,7 +32,7 @@ docker build -t abstraction:dev -f apps/abstraction/Dockerfile .
```bash ```bash
docker run --rm \ docker run --rm \
-v $(pwd)/config:/app/config:ro \ -v $(pwd)/config:/app/config:ro \
-e MQTT_BROKER=172.16.2.16 \ -e MQTT_BROKER=172.23.1.102 \
-e MQTT_PORT=1883 \ -e MQTT_PORT=1883 \
-e REDIS_HOST=172.23.1.116 \ -e REDIS_HOST=172.23.1.116 \
-e REDIS_PORT=6379 \ -e REDIS_PORT=6379 \

View File

@@ -15,11 +15,15 @@ import uuid
from aiomqtt import Client from aiomqtt import Client
from pydantic import ValidationError from pydantic import ValidationError
from packages.home_capabilities import LightState, ThermostatState from packages.home_capabilities import LightState, ThermostatState, ContactState
from apps.abstraction.transformation import (
transform_abstract_to_vendor,
transform_vendor_to_abstract
)
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,8 +42,8 @@ def load_config(config_path: Path) -> dict[str, Any]:
logger.warning(f"Config file not found: {config_path}, using defaults") logger.warning(f"Config file not found: {config_path}, using defaults")
return { return {
"mqtt": { "mqtt": {
"broker": "172.16.2.16", "broker": os.getenv("MQTT_BROKER", "localhost"),
"port": 1883, "port": int(os.getenv("MQTT_PORT", "1883")),
"client_id": "home-automation-abstraction", "client_id": "home-automation-abstraction",
"keepalive": 60 "keepalive": 60
}, },
@@ -85,11 +89,12 @@ def validate_devices(devices: list[dict[str, Any]]) -> None:
if "topics" not in device: if "topics" not in device:
raise ValueError(f"Device {device_id} missing 'topics'") raise ValueError(f"Device {device_id} missing 'topics'")
if "set" not in device["topics"]: # 'state' topic is required for all devices
raise ValueError(f"Device {device_id} missing 'topics.set'")
if "state" not in device["topics"]: if "state" not in device["topics"]:
raise ValueError(f"Device {device_id} missing 'topics.state'") raise ValueError(f"Device {device_id} missing 'topics.state'")
# 'set' topic is optional (read-only devices like contact sensors don't have it)
# No validation needed for topics.set
# Log loaded devices # Log loaded devices
device_ids = [d["device_id"] for d in devices] device_ids = [d["device_id"] for d in devices]
@@ -127,6 +132,7 @@ async def handle_abstract_set(
mqtt_client: Client, mqtt_client: Client,
device_id: str, device_id: str,
device_type: str, device_type: str,
device_technology: str,
vendor_topic: str, vendor_topic: str,
payload: dict[str, Any] payload: dict[str, Any]
) -> None: ) -> None:
@@ -136,21 +142,22 @@ async def handle_abstract_set(
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', 'thermostat') device_type: Device type (e.g., 'light', 'thermostat')
device_technology: Technology identifier (e.g., 'zigbee2mqtt')
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) abstract_payload = payload.get("payload", payload)
# Validate payload based on device type # Validate payload based on device type
try: try:
if device_type == "light": if device_type == "light":
# Validate light SET payload (power and/or brightness) # Validate light SET payload (power and/or brightness)
LightState.model_validate(vendor_payload) LightState.model_validate(abstract_payload)
elif device_type == "thermostat": elif device_type == "thermostat":
# For thermostat SET: only allow mode and target fields # For thermostat SET: only allow mode and target fields
allowed_set_fields = {"mode", "target"} allowed_set_fields = {"mode", "target"}
invalid_fields = set(vendor_payload.keys()) - allowed_set_fields invalid_fields = set(abstract_payload.keys()) - allowed_set_fields
if invalid_fields: if invalid_fields:
logger.warning( logger.warning(
f"Thermostat SET {device_id} contains invalid fields {invalid_fields}, " f"Thermostat SET {device_id} contains invalid fields {invalid_fields}, "
@@ -159,12 +166,24 @@ async def handle_abstract_set(
return return
# Validate against ThermostatState (current/battery/window_open are optional) # Validate against ThermostatState (current/battery/window_open are optional)
ThermostatState.model_validate(vendor_payload) ThermostatState.model_validate(abstract_payload)
elif device_type in {"contact", "contact_sensor"}:
# Contact sensors are read-only - SET commands should not occur
logger.warning(f"Contact sensor {device_id} received SET command - ignoring (read-only device)")
return
except ValidationError as e: except ValidationError as e:
logger.error(f"Validation failed for {device_type} SET {device_id}: {e}") logger.error(f"Validation failed for {device_type} SET {device_id}: {e}")
return return
vendor_message = json.dumps(vendor_payload) # Transform abstract payload to vendor-specific format
vendor_payload = transform_abstract_to_vendor(device_type, device_technology, abstract_payload)
# For MAX! thermostats, vendor_payload is a plain string (integer temperature)
# For other devices, it's a dict that needs JSON encoding
if device_technology == "max" and device_type == "thermostat":
vendor_message = vendor_payload # Already a string
else:
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}")
await mqtt_client.publish(vendor_topic, vendor_message, qos=1) await mqtt_client.publish(vendor_topic, vendor_message, qos=1)
@@ -175,6 +194,7 @@ async def handle_vendor_state(
redis_client: aioredis.Redis, redis_client: aioredis.Redis,
device_id: str, device_id: str,
device_type: str, device_type: str,
device_technology: str,
payload: dict[str, Any], payload: dict[str, Any],
redis_channel: str = "ui:updates" redis_channel: str = "ui:updates"
) -> None: ) -> None:
@@ -185,23 +205,33 @@ async def handle_vendor_state(
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', 'thermostat') device_type: Device type (e.g., 'light', 'thermostat')
payload: State payload device_technology: Technology identifier (e.g., 'zigbee2mqtt')
payload: State payload (vendor-specific format)
redis_channel: Redis channel for UI updates redis_channel: Redis channel for UI updates
""" """
# Transform vendor-specific payload to abstract format
abstract_payload = transform_vendor_to_abstract(device_type, device_technology, payload)
# Validate state payload based on device type # Validate state payload based on device type
try: try:
if device_type == "light": if device_type == "light":
LightState.model_validate(payload) LightState.model_validate(abstract_payload)
elif device_type == "thermostat": elif device_type == "thermostat":
# Validate thermostat state: mode, target, current (required), battery, window_open # Validate thermostat state: mode, target, current (required), battery, window_open
ThermostatState.model_validate(payload) ThermostatState.model_validate(abstract_payload)
elif device_type in {"contact", "contact_sensor"}:
# Validate contact sensor state
ContactState.model_validate(abstract_payload)
except ValidationError as e: except ValidationError as e:
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}") logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
return return
# Normalize device type for topic (use 'contact' for both 'contact' and 'contact_sensor')
topic_type = "contact" if device_type in {"contact", "contact_sensor"} else device_type
# 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/{topic_type}/{device_id}/state"
abstract_message = json.dumps(payload) abstract_message = json.dumps(abstract_payload)
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)
@@ -210,7 +240,7 @@ async def handle_vendor_state(
ui_update = { ui_update = {
"type": "state", "type": "state",
"device_id": device_id, "device_id": device_id,
"payload": payload, "payload": abstract_payload,
"ts": datetime.now(timezone.utc).isoformat() "ts": datetime.now(timezone.utc).isoformat()
} }
redis_message = json.dumps(ui_update) redis_message = json.dumps(ui_update)
@@ -227,8 +257,8 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
redis_client: Redis client for UI updates redis_client: Redis client for UI updates
""" """
mqtt_config = config.get("mqtt", {}) mqtt_config = config.get("mqtt", {})
broker = mqtt_config.get("broker", "172.16.2.16") broker = os.getenv("MQTT_BROKER") or mqtt_config.get("broker", "localhost")
port = mqtt_config.get("port", 1883) port = int(os.getenv("MQTT_PORT", mqtt_config.get("port", 1883)))
client_id = mqtt_config.get("client_id", "home-automation-abstraction") client_id = mqtt_config.get("client_id", "home-automation-abstraction")
# Append a short suffix (ENV override possible) so multiple processes don't collide # Append a short suffix (ENV override possible) so multiple processes don't collide
client_suffix = os.environ.get("MQTT_CLIENT_ID_SUFFIX") or uuid.uuid4().hex[:6] client_suffix = os.environ.get("MQTT_CLIENT_ID_SUFFIX") or uuid.uuid4().hex[:6]
@@ -254,15 +284,22 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
keepalive=keepalive, keepalive=keepalive,
timeout=10.0 # Add explicit timeout for operations timeout=10.0 # Add explicit timeout for operations
) as client: ) as client:
logger.info(f"Connected to MQTT broker as {client_id}") logger.info(f"Connected to MQTT broker as {unique_client_id}")
# Subscribe to abstract SET topics for all devices # Subscribe to topics for all devices
for device in devices.values(): for device in devices.values():
abstract_set_topic = f"home/{device['type']}/{device['device_id']}/set" device_id = device['device_id']
await client.subscribe(abstract_set_topic) device_type = device['type']
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
# Subscribe to vendor STATE topics # Subscribe to abstract SET topic only if device has a SET topic (not read-only)
if "set" in device["topics"]:
abstract_set_topic = f"home/{device_type}/{device_id}/set"
await client.subscribe(abstract_set_topic)
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
else:
logger.info(f"Skipping SET subscription for read-only device: {device_id}")
# Subscribe to vendor STATE topics (all devices have state)
vendor_state_topic = device["topics"]["state"] vendor_state_topic = device["topics"]["state"]
await client.subscribe(vendor_state_topic) await client.subscribe(vendor_state_topic)
logger.info(f"Subscribed to vendor STATE: {vendor_state_topic}") logger.info(f"Subscribed to vendor STATE: {vendor_state_topic}")
@@ -280,11 +317,30 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
topic = str(message.topic) topic = str(message.topic)
payload_str = message.payload.decode() payload_str = message.payload.decode()
try: # Determine if message is from a MAX! device (requires plain text handling)
payload = json.loads(payload_str) is_max_device = False
except json.JSONDecodeError: max_device_id = None
logger.warning(f"Invalid JSON on {topic}: {payload_str}") max_device_type = None
continue
# Check if topic matches any MAX! device state topic
for device_id, device in devices.items():
if device.get("technology") == "max" and topic == device["topics"]["state"]:
is_max_device = True
max_device_id = device_id
max_device_type = device["type"]
break
# Parse payload based on device technology
if is_max_device:
# MAX! sends plain integer/string, not JSON
payload = payload_str.strip()
else:
# All other technologies use JSON
try:
payload = json.loads(payload_str)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON on {topic}: {payload_str}")
continue
# Check if this is an abstract SET message # Check if this is an abstract SET message
if topic.startswith("home/") and topic.endswith("/set"): if topic.startswith("home/") and topic.endswith("/set"):
@@ -297,19 +353,31 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
if device_id in devices: if device_id in devices:
device = devices[device_id] device = devices[device_id]
vendor_topic = device["topics"]["set"] vendor_topic = device["topics"]["set"]
device_technology = device.get("technology", "unknown")
await handle_abstract_set( await handle_abstract_set(
client, device_id, device_type, vendor_topic, payload client, device_id, device_type, device_technology, vendor_topic, payload
) )
# Check if this is a vendor STATE message # Check if this is a vendor STATE message
else: else:
# Find device by vendor state topic # For MAX! devices, we already identified them above
for device_id, device in devices.items(): if is_max_device:
if topic == device["topics"]["state"]: device = devices[max_device_id]
await handle_vendor_state( device_technology = device.get("technology", "unknown")
client, redis_client, device_id, device["type"], payload, redis_channel await handle_vendor_state(
) client, redis_client, max_device_id, max_device_type,
break device_technology, payload, redis_channel
)
else:
# Find device by vendor state topic for other technologies
for device_id, device in devices.items():
if topic == device["topics"]["state"]:
device_technology = device.get("technology", "unknown")
await handle_vendor_state(
client, redis_client, device_id, device["type"],
device_technology, payload, redis_channel
)
break
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("MQTT worker cancelled") logger.info("MQTT worker cancelled")

View File

@@ -0,0 +1,463 @@
"""Payload transformation functions for vendor-specific device communication.
This module implements a registry-pattern for vendor-specific transformations:
- Each (device_type, technology, direction) tuple maps to a specific handler function
- Handlers transform payloads between abstract and vendor-specific formats
- Unknown combinations fall back to pass-through (no transformation)
"""
import logging
from typing import Any, Callable
logger = logging.getLogger(__name__)
# ============================================================================
# HANDLER FUNCTIONS: simulator technology
# ============================================================================
def _transform_light_simulator_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract light payload to simulator format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
def _transform_light_simulator_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform simulator light payload to abstract format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
def _transform_thermostat_simulator_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract thermostat payload to simulator format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
def _transform_thermostat_simulator_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform simulator thermostat payload to abstract format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
# ============================================================================
# HANDLER FUNCTIONS: zigbee2mqtt technology
# ============================================================================
def _transform_light_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract light payload to zigbee2mqtt format.
Transformations:
- power: 'on'/'off' -> state: 'ON'/'OFF'
- brightness: 0-100 -> brightness: 0-254
Example:
- Abstract: {'power': 'on', 'brightness': 100}
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
"""
vendor_payload = payload.copy()
# Transform power -> state with uppercase values
if "power" in vendor_payload:
power_value = vendor_payload.pop("power")
vendor_payload["state"] = power_value.upper() if isinstance(power_value, str) else power_value
# Transform brightness: 0-100 (%) -> 0-254 (zigbee2mqtt range)
if "brightness" in vendor_payload:
abstract_brightness = vendor_payload["brightness"]
if isinstance(abstract_brightness, (int, float)):
# Convert percentage (0-100) to zigbee2mqtt range (0-254)
vendor_payload["brightness"] = round(abstract_brightness * 254 / 100)
return vendor_payload
def _transform_light_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform zigbee2mqtt light payload to abstract format.
Transformations:
- state: 'ON'/'OFF' -> power: 'on'/'off'
- brightness: 0-254 -> brightness: 0-100
Example:
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
- Abstract: {'power': 'on', 'brightness': 100}
"""
abstract_payload = payload.copy()
# Transform state -> power with lowercase values
if "state" in abstract_payload:
state_value = abstract_payload.pop("state")
abstract_payload["power"] = state_value.lower() if isinstance(state_value, str) else state_value
# Transform brightness: 0-254 (zigbee2mqtt range) -> 0-100 (%)
if "brightness" in abstract_payload:
vendor_brightness = abstract_payload["brightness"]
if isinstance(vendor_brightness, (int, float)):
# Convert zigbee2mqtt range (0-254) to percentage (0-100)
abstract_payload["brightness"] = round(vendor_brightness * 100 / 254)
return abstract_payload
def _transform_thermostat_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract thermostat payload to zigbee2mqtt format.
Transformations:
- target -> current_heating_setpoint (as string)
- mode is ignored (zigbee2mqtt thermostats use system_mode in state only)
Example:
- Abstract: {'target': 22.0}
- zigbee2mqtt: {'current_heating_setpoint': '22.0'}
"""
vendor_payload = {}
if "target" in payload:
# zigbee2mqtt expects current_heating_setpoint as string
vendor_payload["current_heating_setpoint"] = str(payload["target"])
return vendor_payload
def _transform_thermostat_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform zigbee2mqtt thermostat payload to abstract format.
Transformations:
- current_heating_setpoint -> target (as float)
- local_temperature -> current (as float)
- system_mode -> mode
Example:
- zigbee2mqtt: {'current_heating_setpoint': 15, 'local_temperature': 23, 'system_mode': 'heat'}
- Abstract: {'target': 15.0, 'current': 23.0, 'mode': 'heat'}
"""
abstract_payload = {}
# Extract target temperature
if "current_heating_setpoint" in payload:
setpoint = payload["current_heating_setpoint"]
abstract_payload["target"] = float(setpoint)
# Extract current temperature
if "local_temperature" in payload:
current = payload["local_temperature"]
abstract_payload["current"] = float(current)
# Extract mode
if "system_mode" in payload:
abstract_payload["mode"] = payload["system_mode"]
return abstract_payload
# ============================================================================
# HANDLER FUNCTIONS: contact_sensor - zigbee2mqtt technology
# ============================================================================
def _transform_contact_sensor_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract contact sensor payload to zigbee2mqtt format.
Contact sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return payload
def _transform_contact_sensor_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform zigbee2mqtt contact sensor payload to abstract format.
Transformations:
- contact: bool -> "open" | "closed"
- zigbee2mqtt semantics: False = OPEN, True = CLOSED (inverted!)
- battery: pass through (already 0-100)
- linkquality: pass through
- device_temperature: pass through (if present)
- voltage: pass through (if present)
Example:
- zigbee2mqtt: {"contact": false, "battery": 100, "linkquality": 87}
- Abstract: {"contact": "open", "battery": 100, "linkquality": 87}
"""
abstract_payload = {}
# Transform contact state (inverted logic!)
if "contact" in payload:
contact_bool = payload["contact"]
# zigbee2mqtt: False = OPEN, True = CLOSED
abstract_payload["contact"] = "closed" if contact_bool else "open"
# Pass through optional fields
if "battery" in payload:
abstract_payload["battery"] = payload["battery"]
if "linkquality" in payload:
abstract_payload["linkquality"] = payload["linkquality"]
if "device_temperature" in payload:
abstract_payload["device_temperature"] = payload["device_temperature"]
if "voltage" in payload:
abstract_payload["voltage"] = payload["voltage"]
return abstract_payload
# ============================================================================
# HANDLER FUNCTIONS: contact_sensor - max technology (Homegear MAX!)
# ============================================================================
def _transform_contact_sensor_max_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract contact sensor payload to MAX! format.
Contact sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return payload
def _transform_contact_sensor_max_to_abstract(payload: str | bool | dict[str, Any]) -> dict[str, Any]:
"""Transform MAX! (Homegear) contact sensor payload to abstract format.
MAX! sends "true"/"false" (string or bool) on STATE topic.
Transformations:
- "true" or True -> "open" (window/door open)
- "false" or False -> "closed" (window/door closed)
Example:
- MAX!: "true" or True
- Abstract: {"contact": "open"}
"""
try:
# Handle string, bool, or dict input
if isinstance(payload, dict):
# If already a dict, extract contact field
contact_value = payload.get("contact", False)
elif isinstance(payload, str):
# Parse string to bool
contact_value = payload.strip().lower() == "true"
elif isinstance(payload, bool):
# Use bool directly
contact_value = payload
else:
logger.warning(f"MAX! contact sensor unexpected payload type: {type(payload)}, value: {payload}")
contact_value = False
# MAX! semantics: True = OPEN, False = CLOSED
return {
"contact": "open" if contact_value else "closed"
}
except (ValueError, TypeError) as e:
logger.error(f"MAX! contact sensor failed to parse: {payload}, error: {e}")
return {
"contact": "closed" # Default to closed on error
}
# ============================================================================
# HANDLER FUNCTIONS: max technology (Homegear MAX!)
# ============================================================================
def _transform_thermostat_max_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract thermostat payload to MAX! (Homegear) format.
MAX! expects only the integer temperature value (no JSON).
Transformations:
- Extract 'target' temperature from payload
- Convert float to integer (MAX! only accepts integers)
- Return as plain string value
Example:
- Abstract: {'mode': 'heat', 'target': 22.5}
- MAX!: "22"
Note: MAX! ignores mode - it's always in heating mode
"""
if "target" not in payload:
logger.warning(f"MAX! thermostat payload missing 'target': {payload}")
return "21" # Default fallback
target_temp = payload["target"]
# Convert to integer (MAX! protocol requirement)
if isinstance(target_temp, (int, float)):
int_temp = int(round(target_temp))
return str(int_temp)
logger.warning(f"MAX! invalid target temperature type: {type(target_temp)}, value: {target_temp}")
return "21"
def _transform_thermostat_max_to_abstract(payload: str | int | float) -> dict[str, Any]:
"""Transform MAX! (Homegear) thermostat payload to abstract format.
MAX! sends only the integer temperature value (no JSON).
Transformations:
- Parse plain string/int value
- Convert to float for abstract protocol
- Wrap in abstract payload structure with mode='heat'
Example:
- MAX!: "22" or 22
- Abstract: {'target': 22.0, 'mode': 'heat'}
Note: MAX! doesn't send current temperature via SET_TEMPERATURE topic
"""
try:
# Handle both string and numeric input
if isinstance(payload, str):
target_temp = float(payload.strip())
elif isinstance(payload, (int, float)):
target_temp = float(payload)
else:
logger.warning(f"MAX! unexpected payload type: {type(payload)}, value: {payload}")
target_temp = 21.0
return {
"target": target_temp,
"mode": "heat" # MAX! is always in heating mode
}
except (ValueError, TypeError) as e:
logger.error(f"MAX! failed to parse temperature: {payload}, error: {e}")
return {
"target": 21.0,
"mode": "heat"
}
# ============================================================================
# REGISTRY: Maps (device_type, technology, direction) -> handler function
# ============================================================================
TransformHandler = Callable[[dict[str, Any]], dict[str, Any]]
TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = {
# Light transformations
("light", "simulator", "to_vendor"): _transform_light_simulator_to_vendor,
("light", "simulator", "to_abstract"): _transform_light_simulator_to_abstract,
("light", "zigbee2mqtt", "to_vendor"): _transform_light_zigbee2mqtt_to_vendor,
("light", "zigbee2mqtt", "to_abstract"): _transform_light_zigbee2mqtt_to_abstract,
# Thermostat transformations
("thermostat", "simulator", "to_vendor"): _transform_thermostat_simulator_to_vendor,
("thermostat", "simulator", "to_abstract"): _transform_thermostat_simulator_to_abstract,
("thermostat", "zigbee2mqtt", "to_vendor"): _transform_thermostat_zigbee2mqtt_to_vendor,
("thermostat", "zigbee2mqtt", "to_abstract"): _transform_thermostat_zigbee2mqtt_to_abstract,
("thermostat", "max", "to_vendor"): _transform_thermostat_max_to_vendor,
("thermostat", "max", "to_abstract"): _transform_thermostat_max_to_abstract,
# Contact sensor transformations (support both 'contact' and 'contact_sensor' types)
("contact_sensor", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
("contact_sensor", "zigbee2mqtt", "to_abstract"): _transform_contact_sensor_zigbee2mqtt_to_abstract,
("contact_sensor", "max", "to_vendor"): _transform_contact_sensor_max_to_vendor,
("contact_sensor", "max", "to_abstract"): _transform_contact_sensor_max_to_abstract,
("contact", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
("contact", "zigbee2mqtt", "to_abstract"): _transform_contact_sensor_zigbee2mqtt_to_abstract,
("contact", "max", "to_vendor"): _transform_contact_sensor_max_to_vendor,
("contact", "max", "to_abstract"): _transform_contact_sensor_max_to_abstract,
}
def _get_transform_handler(
device_type: str,
device_technology: str,
direction: str
) -> TransformHandler:
"""Get transformation handler for given device type, technology and direction.
Args:
device_type: Type of device (e.g., "light", "thermostat")
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
direction: Transformation direction ("to_vendor" or "to_abstract")
Returns:
Handler function for transformation, or pass-through if not found
"""
key = (device_type, device_technology, direction)
handler = TRANSFORM_HANDLERS.get(key)
if handler is None:
logger.warning(
f"No transformation handler for {key}, using pass-through. "
f"Available: {list(TRANSFORM_HANDLERS.keys())}"
)
return lambda payload: payload # Pass-through fallback
return handler
# ============================================================================
# PUBLIC API: Main transformation functions
# ============================================================================
def transform_abstract_to_vendor(
device_type: str,
device_technology: str,
abstract_payload: dict[str, Any]
) -> dict[str, Any]:
"""Transform abstract payload to vendor-specific format.
Args:
device_type: Type of device (e.g., "light", "thermostat")
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
abstract_payload: Payload in abstract home protocol format
Returns:
Payload in vendor-specific format
"""
logger.debug(
f"transform_abstract_to_vendor IN: type={device_type}, tech={device_technology}, "
f"payload={abstract_payload}"
)
handler = _get_transform_handler(device_type, device_technology, "to_vendor")
vendor_payload = handler(abstract_payload)
logger.debug(
f"transform_abstract_to_vendor OUT: type={device_type}, tech={device_technology}, "
f"payload={vendor_payload}"
)
return vendor_payload
def transform_vendor_to_abstract(
device_type: str,
device_technology: str,
vendor_payload: dict[str, Any]
) -> dict[str, Any]:
"""Transform vendor-specific payload to abstract format.
Args:
device_type: Type of device (e.g., "light", "thermostat")
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
vendor_payload: Payload in vendor-specific format
Returns:
Payload in abstract home protocol format
"""
logger.debug(
f"transform_vendor_to_abstract IN: type={device_type}, tech={device_technology}, "
f"payload={vendor_payload}"
)
handler = _get_transform_handler(device_type, device_technology, "to_abstract")
abstract_payload = handler(vendor_payload)
logger.debug(
f"transform_vendor_to_abstract OUT: type={device_type}, tech={device_technology}, "
f"payload={abstract_payload}"
)
return abstract_payload

View File

@@ -42,7 +42,7 @@ docker build -t api:dev -f apps/api/Dockerfile .
```bash ```bash
docker run --rm -p 8001:8001 \ docker run --rm -p 8001:8001 \
-v $(pwd)/config:/app/config:ro \ -v $(pwd)/config:/app/config:ro \
-e MQTT_BROKER=172.16.2.16 \ -e MQTT_BROKER=172.23.1.102 \
-e MQTT_PORT=1883 \ -e MQTT_PORT=1883 \
-e REDIS_HOST=172.23.1.116 \ -e REDIS_HOST=172.23.1.116 \
-e REDIS_PORT=6379 \ -e REDIS_PORT=6379 \
@@ -51,6 +51,23 @@ docker run --rm -p 8001:8001 \
api:dev api:dev
``` ```
**Mit Docker Network (empfohlen für Linux):**
```bash
docker network create home-automation
docker run --rm -p 8001:8001 \
--network home-automation \
--name api \
-v $(pwd)/config:/app/config:ro \
-e MQTT_BROKER=172.16.2.16 \
-e REDIS_HOST=172.23.1.116 \
-e REDIS_DB=8 \
api:dev
```
**Hinweise:**
- **Linux**: Port wird auf `0.0.0.0:8001` gebunden (von überall erreichbar)
- **macOS/finch**: Port wird auf `127.0.0.1:8001` gebunden (nur localhost)
#### Environment Variables #### Environment Variables
| Variable | Default | Description | | Variable | Default | Description |

View File

@@ -15,10 +15,24 @@ 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 LIGHT_VERSION, THERMOSTAT_VERSION, LightState, ThermostatState from packages.home_capabilities import (
LIGHT_VERSION,
THERMOSTAT_VERSION,
CONTACT_SENSOR_VERSION,
LightState,
ThermostatState,
ContactState
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# In-memory cache for last known device states
# Will be populated from Redis pub/sub messages
device_states: dict[str, dict[str, Any]] = {}
# Background task reference
background_task: asyncio.Task | None = None
app = FastAPI( app = FastAPI(
title="Home Automation API", title="Home Automation API",
description="API for home automation system", description="API for home automation system",
@@ -30,6 +44,7 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[
"http://localhost:8002", "http://localhost:8002",
"http://172.19.1.11:8002",
"http://127.0.0.1:8002", "http://127.0.0.1:8002",
], ],
allow_credentials=True, allow_credentials=True,
@@ -48,6 +63,77 @@ async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
async def redis_state_listener():
"""Background task that listens to Redis pub/sub and updates state cache."""
redis_client = None
pubsub = None
try:
redis_url, redis_channel = get_redis_settings()
logger.info(f"Starting Redis state listener for channel {redis_channel}")
redis_client = await aioredis.from_url(redis_url, decode_responses=True)
pubsub = redis_client.pubsub()
await pubsub.subscribe(redis_channel)
logger.info("Redis state listener connected")
while True:
try:
message = await asyncio.wait_for(
pubsub.get_message(ignore_subscribe_messages=True),
timeout=1.0
)
if message and message["type"] == "message":
data = message["data"]
try:
state_data = json.loads(data)
if state_data.get("type") == "state" and state_data.get("device_id"):
device_id = state_data["device_id"]
payload = state_data.get("payload", {})
device_states[device_id] = payload
logger.debug(f"Updated state cache for {device_id}: {payload}")
except Exception as e:
logger.warning(f"Failed to parse state data: {e}")
except asyncio.TimeoutError:
pass # No message, continue
except asyncio.CancelledError:
logger.info("Redis state listener cancelled")
raise
except Exception as e:
logger.error(f"Redis state listener error: {e}")
finally:
if pubsub:
await pubsub.unsubscribe(redis_channel)
await pubsub.close()
if redis_client:
await redis_client.close()
@app.on_event("startup")
async def startup_event():
"""Start background tasks on application startup."""
global background_task
background_task = asyncio.create_task(redis_state_listener())
logger.info("Started background Redis state listener")
@app.on_event("shutdown")
async def shutdown_event():
"""Clean up background tasks on application shutdown."""
global background_task
if background_task:
background_task.cancel()
try:
await background_task
except asyncio.CancelledError:
pass
logger.info("Stopped background Redis state listener")
@app.get("/spec") @app.get("/spec")
async def spec() -> dict[str, dict[str, str]]: async def spec() -> dict[str, dict[str, str]]:
"""Capability specification endpoint. """Capability specification endpoint.
@@ -58,7 +144,8 @@ async def spec() -> dict[str, dict[str, str]]:
return { return {
"capabilities": { "capabilities": {
"light": LIGHT_VERSION, "light": LIGHT_VERSION,
"thermostat": THERMOSTAT_VERSION "thermostat": THERMOSTAT_VERSION,
"contact": CONTACT_SENSOR_VERSION
} }
} }
@@ -181,6 +268,16 @@ async def get_devices() -> list[DeviceInfo]:
] ]
@app.get("/devices/states")
async def get_device_states() -> dict[str, dict[str, Any]]:
"""Get current states of all devices from in-memory cache.
Returns:
dict: Dictionary mapping device_id to state payload
"""
return device_states
@app.get("/layout") @app.get("/layout")
async def get_layout() -> dict[str, Any]: async def get_layout() -> dict[str, Any]:
"""Get UI layout configuration. """Get UI layout configuration.
@@ -242,6 +339,13 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
detail=f"Device {device_id} not found" detail=f"Device {device_id} not found"
) )
# Check if device is read-only (contact sensors, etc.)
if "topics" in device and "set" not in device["topics"]:
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Device is read-only"
)
# Validate payload based on device type # Validate payload based on device type
if request.type == "light": if request.type == "light":
try: try:
@@ -267,6 +371,12 @@ 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 thermostat: {e}" detail=f"Invalid payload for thermostat: {e}"
) )
elif request.type in {"contact", "contact_sensor"}:
# Contact sensors are read-only
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Contact sensors are read-only devices"
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -286,7 +396,13 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
async def event_generator(request: Request) -> AsyncGenerator[str, None]: async def event_generator(request: Request) -> AsyncGenerator[str, None]:
"""Generate SSE events from Redis Pub/Sub. """Generate SSE events from Redis Pub/Sub with Safari compatibility.
Safari-compatible features:
- Immediate retry hint on connection
- Regular heartbeats every 15s (comment-only, no data)
- Proper flushing after each yield
- Graceful disconnect handling
Args: Args:
request: FastAPI request object for disconnect detection request: FastAPI request object for disconnect detection
@@ -294,70 +410,125 @@ async def event_generator(request: Request) -> AsyncGenerator[str, None]:
Yields: Yields:
str: SSE formatted event strings str: SSE formatted event strings
""" """
redis_url, redis_channel = get_redis_settings() redis_client = None
redis_client = await aioredis.from_url(redis_url, decode_responses=True) pubsub = None
pubsub = redis_client.pubsub()
try: try:
await pubsub.subscribe(redis_channel) # Send retry hint immediately for EventSource reconnect behavior
yield "retry: 2500\n\n"
# Create heartbeat task # Try to connect to Redis
redis_url, redis_channel = get_redis_settings()
try:
redis_client = await aioredis.from_url(redis_url, decode_responses=True)
pubsub = redis_client.pubsub()
await pubsub.subscribe(redis_channel)
logger.info(f"SSE client connected, subscribed to {redis_channel}")
except Exception as e:
logger.warning(f"Redis unavailable, running in heartbeat-only mode: {e}")
redis_client = None
pubsub = None
# Heartbeat tracking
last_heartbeat = asyncio.get_event_loop().time() last_heartbeat = asyncio.get_event_loop().time()
heartbeat_interval = 15 # Safari-friendly: shorter interval
while True: while True:
# Check if client disconnected # Check if client disconnected
if await request.is_disconnected(): if await request.is_disconnected():
logger.info("SSE client disconnected")
break break
# Get message with timeout for heartbeat # Try to get message from Redis (if available)
try: if pubsub:
message = await asyncio.wait_for( try:
pubsub.get_message(ignore_subscribe_messages=True), message = await asyncio.wait_for(
timeout=1.0 pubsub.get_message(ignore_subscribe_messages=True),
) timeout=0.1
)
if message and message["type"] == "message":
# Send data event if message and message["type"] == "message":
data = message["data"] data = message["data"]
yield f"event: message\ndata: {data}\n\n" logger.debug(f"Sending SSE message: {data[:100]}...")
last_heartbeat = asyncio.get_event_loop().time()
# Update in-memory cache with latest state
except asyncio.TimeoutError: try:
pass state_data = json.loads(data)
if state_data.get("type") == "state" and state_data.get("device_id"):
device_states[state_data["device_id"]] = state_data.get("payload", {})
except Exception as e:
logger.warning(f"Failed to parse state data for cache: {e}")
yield f"event: message\ndata: {data}\n\n"
last_heartbeat = asyncio.get_event_loop().time()
continue # Skip sleep, check for more messages immediately
except asyncio.TimeoutError:
pass # No message, continue to heartbeat check
except Exception as e:
logger.error(f"Redis error: {e}")
# Continue with heartbeats even if Redis fails
# Send heartbeat every 25 seconds # Sleep briefly to avoid busy loop
await asyncio.sleep(0.1)
# Send heartbeat if interval elapsed
current_time = asyncio.get_event_loop().time() current_time = asyncio.get_event_loop().time()
if current_time - last_heartbeat >= 25: if current_time - last_heartbeat >= heartbeat_interval:
yield "event: ping\ndata: heartbeat\n\n" # Comment-style ping (Safari-compatible, no event type)
yield ": ping\n\n"
last_heartbeat = current_time last_heartbeat = current_time
except asyncio.CancelledError:
logger.info("SSE connection cancelled by client")
raise
except Exception as e:
logger.error(f"SSE error: {e}")
raise
finally: finally:
await pubsub.unsubscribe(redis_channel) # Cleanup Redis connection
await pubsub.close() if pubsub:
await redis_client.close() try:
await pubsub.unsubscribe(redis_channel)
await pubsub.aclose()
except Exception as e:
logger.error(f"Error closing pubsub: {e}")
if redis_client:
try:
await redis_client.aclose()
except Exception as e:
logger.error(f"Error closing redis: {e}")
logger.info("SSE connection closed")
@app.get("/realtime") @app.get("/realtime")
async def realtime_events(request: Request) -> StreamingResponse: async def realtime_events(request: Request) -> StreamingResponse:
"""Server-Sent Events endpoint for real-time updates. """Server-Sent Events endpoint for real-time updates.
Safari-compatible SSE implementation:
- Immediate retry hint (2.5s reconnect delay)
- Heartbeat every 15s using comment syntax ": ping"
- Proper Cache-Control headers
- No buffering (nginx compatibility)
- Graceful Redis fallback (heartbeat-only mode)
Args: Args:
request: FastAPI request object request: FastAPI request object
Returns: Returns:
StreamingResponse: SSE stream of Redis messages StreamingResponse: SSE stream with Redis messages and heartbeats
""" """
return StreamingResponse( return StreamingResponse(
event_generator(request), event_generator(request),
media_type="text/event-stream", media_type="text/event-stream",
headers={ headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive", "Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering "X-Accel-Buffering": "no", # Disable nginx buffering
} }
) )
return {"message": f"Command sent to {device_id}"}
def main() -> None: def main() -> None:

View File

@@ -53,12 +53,20 @@ docker build -t simulator:dev -f apps/simulator/Dockerfile .
```bash ```bash
docker run --rm -p 8010:8010 \ docker run --rm -p 8010:8010 \
-e MQTT_BROKER=172.16.2.16 \ -e MQTT_BROKER=172.23.1.102 \
-e MQTT_PORT=1883 \ -e MQTT_PORT=1883 \
-e SIM_PORT=8010 \ -e SIM_PORT=8010 \
simulator:dev simulator:dev
``` ```
**Mit Docker Network (optional):**
```bash
docker run --rm -p 8010:8010 \
--name simulator \
-e MQTT_BROKER=172.23.1.102 \
simulator:dev
```
#### Environment Variables #### Environment Variables
| Variable | Default | Description | | Variable | Default | Description |

View File

@@ -37,14 +37,32 @@ docker build -t ui:dev -f apps/ui/Dockerfile .
#### Run Container #### Run Container
**Linux Server (empfohlen):**
```bash ```bash
# Mit Docker Network für Container-to-Container Kommunikation
docker run --rm -p 8002:8002 \ docker run --rm -p 8002:8002 \
-e UI_PORT=8002 \ -e UI_PORT=8002 \
-e API_BASE=http://localhost:8001 \ -e API_BASE=http://172.19.1.11:8001 \
-e BASE_PATH=/ \ -e BASE_PATH=/ \
ui:dev ui:dev
``` ```
**macOS mit finch/nerdctl:**
```bash
docker run --rm -p 8002:8002 \
--add-host=host.docker.internal:host-gateway \
-e UI_PORT=8002 \
-e API_BASE=http://host.docker.internal:8001 \
-e BASE_PATH=/ \
ui:dev
```
**Hinweise:**
- **Linux**: Verwende Docker Network und Service-Namen (`http://api:8001`)
- **macOS/finch**: Verwende `host.docker.internal` mit `--add-host` flag
- Die UI macht Server-Side API-Aufrufe beim Rendern der Seite
- Browser-seitige Realtime-Updates (SSE) gehen direkt vom Browser zur API
#### Environment Variables #### Environment Variables
| Variable | Default | Description | | Variable | Default | Description |

View File

@@ -29,6 +29,16 @@
padding: 2rem; padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem; margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.header-content {
flex: 1;
min-width: 200px;
} }
h1 { h1 {
@@ -36,6 +46,65 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.header-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.refresh-btn,
.collapse-all-btn {
padding: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
min-height: 44px;
min-width: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.refresh-btn:hover,
.collapse-all-btn:hover {
background: #5568d3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.refresh-btn:active,
.collapse-all-btn:active {
transform: translateY(0);
}
.refresh-icon {
font-size: 1.5rem;
line-height: 1;
transition: transform 0.3s;
}
.refresh-icon.spinning {
animation: spin 0.6s linear;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.collapse-all-icon {
font-size: 1.25rem;
transition: transform 0.3s;
line-height: 1;
}
.collapse-all-icon.collapsed {
transform: rotate(-90deg);
}
.status { .status {
display: inline-block; display: inline-block;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
@@ -55,14 +124,69 @@
} }
.room { .room {
margin-bottom: 2rem; background: white;
border-radius: 20px;
margin-bottom: 1rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
overflow: hidden;
transition: box-shadow 0.2s;
}
.room:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
.room-header {
padding: 1.5rem 2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
background: white;
transition: background-color 0.2s;
user-select: none;
}
.room-header:hover {
background: #f8f9fa;
}
.room-header:active {
background: #e9ecef;
} }
.room-title { .room-title {
color: white; color: #333;
font-size: 1.5rem; font-size: 1.5rem;
margin-bottom: 1rem; font-weight: 700;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
}
.room-toggle {
font-size: 1.5rem;
color: #667eea;
transition: transform 0.3s;
line-height: 1;
}
.room-toggle.collapsed {
transform: rotate(-90deg);
}
.room-content {
padding: 0 2rem 2rem 2rem;
max-height: 5000px;
overflow: hidden;
transition: max-height 0.3s ease-out, padding 0.3s ease-out;
}
.room-content.collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
} }
.devices { .devices {
@@ -234,27 +358,6 @@
color: #999; 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 { .temp-controls {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -283,39 +386,44 @@
transform: scale(0.95); transform: scale(0.95);
} }
.mode-controls { /* Contact Sensor Styles */
display: grid; .contact-status {
grid-template-columns: repeat(3, 1fr); display: flex;
gap: 0.5rem; align-items: center;
} gap: 0.75rem;
padding: 1rem;
.mode-button { background: #f8f9fa;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 8px; border-radius: 8px;
font-size: 0.875rem; margin: 1rem 0;
}
.contact-badge {
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 600; font-weight: 600;
cursor: pointer; font-size: 0.875rem;
transition: all 0.2s;
background: white;
color: #666;
min-height: 44px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px;
} }
.mode-button:hover { .contact-badge.open {
border-color: #667eea; background: #dc3545;
color: #667eea;
}
.mode-button.active {
background: #667eea;
border-color: #667eea;
color: white; color: white;
} }
.mode-button:active { .contact-badge.closed {
transform: scale(0.95); background: #28a745;
color: white;
}
.contact-info {
font-size: 0.75rem;
color: #999;
margin-top: 1rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
text-align: center;
} }
.events { .events {
@@ -379,16 +487,30 @@
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>🏠 Home Automation</h1> <div class="header-content">
<p>Realtime Status: <span class="status disconnected" id="connection-status">Verbinde...</span></p> <h1>🏠 Home Automation</h1>
<p>Realtime Status: <span class="status disconnected" id="connection-status">Verbinde...</span></p>
</div>
<div class="header-buttons">
<button class="refresh-btn" onclick="refreshPage()" title="Seite aktualisieren">
<span class="refresh-icon" id="refresh-icon"></span>
</button>
<button class="collapse-all-btn" onclick="toggleAllRooms()" title="Alle Räume ein-/ausklappen">
<span class="collapse-all-icon collapsed" id="collapse-all-icon"></span>
</button>
</div>
</header> </header>
{% if rooms %} {% if rooms %}
{% for room in rooms %} {% for room in rooms %}
<section class="room"> <section class="room">
<h2 class="room-title">{{ room.name }}</h2> <div class="room-header" onclick="toggleRoom('room-{{ loop.index }}')">
<h2 class="room-title">{{ room.name }}</h2>
<span class="room-toggle collapsed" id="toggle-room-{{ loop.index }}"></span>
</div>
<div class="devices"> <div class="room-content collapsed" id="room-{{ loop.index }}">
<div class="devices">
{% for device in room.devices %} {% for device in room.devices %}
<div class="device-card" data-device-id="{{ device.device_id }}"> <div class="device-card" data-device-id="{{ device.device_id }}">
<div class="device-header"> <div class="device-header">
@@ -399,6 +521,8 @@
{% if device.features.brightness %}• Dimmbar{% endif %} {% if device.features.brightness %}• Dimmbar{% endif %}
{% elif device.type == "thermostat" %} {% elif device.type == "thermostat" %}
Thermostat Thermostat
{% elif device.type == "contact" or device.type == "contact_sensor" %}
Contact Sensor • Read-Only
{% else %} {% else %}
{{ device.type or "Unknown" }} {{ device.type or "Unknown" }}
{% endif %} {% endif %}
@@ -463,43 +587,30 @@
</div> </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"> <div class="temp-controls">
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', -0.5)"> <button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', -1.0)">
-0.5 -1.0
</button> </button>
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', 0.5)"> <button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', 1.0)">
+0.5 +1.0
</button> </button>
</div> </div>
<div class="mode-controls"> {% elif device.type == "contact" or device.type == "contact_sensor" %}
<button <div class="contact-status">
class="mode-button" <span class="contact-badge closed" id="state-{{ device.device_id }}">
id="mode-{{ device.device_id }}-off" Geschlossen
onclick="setMode('{{ device.device_id }}', 'off')"> </span>
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> </div>
<div class="contact-info">
🔒 Nur-Lesen Gerät • Keine Steuerung möglich
</div>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div>
</div> </div>
</section> </section>
{% endfor %} {% endfor %}
@@ -519,18 +630,109 @@
</div> </div>
<script> <script>
// Toggle room visibility
function toggleRoom(roomId) {
const content = document.getElementById(roomId);
const toggle = document.getElementById(`toggle-${roomId}`);
if (content && toggle) {
content.classList.toggle('collapsed');
toggle.classList.toggle('collapsed');
}
}
// Refresh page with animation
function refreshPage() {
const icon = document.getElementById('refresh-icon');
icon.classList.add('spinning');
// Reload page after brief animation
setTimeout(() => {
window.location.reload();
}, 300);
}
// Toggle all rooms
function toggleAllRooms() {
const allContents = document.querySelectorAll('.room-content');
const allToggles = document.querySelectorAll('.room-toggle');
const buttonIcon = document.getElementById('collapse-all-icon');
// Check if any room is expanded
const anyExpanded = Array.from(allContents).some(content => !content.classList.contains('collapsed'));
if (anyExpanded) {
// Collapse all
allContents.forEach(content => content.classList.add('collapsed'));
allToggles.forEach(toggle => toggle.classList.add('collapsed'));
buttonIcon.classList.add('collapsed');
} else {
// Expand all
allContents.forEach(content => content.classList.remove('collapsed'));
allToggles.forEach(toggle => toggle.classList.remove('collapsed'));
buttonIcon.classList.remove('collapsed');
}
}
// Set room icons based on room name
document.addEventListener('DOMContentLoaded', () => {
const roomTitles = document.querySelectorAll('.room-title');
roomTitles.forEach(title => {
const roomName = title.textContent.trim().toLowerCase();
let icon = '🏠'; // Default
if (roomName.includes('wohn') || roomName.includes('living')) icon = '🛋️';
else if (roomName.includes('schlaf') || roomName.includes('bed')) icon = '🛏️';
else if (roomName.includes('küch') || roomName.includes('kitchen')) icon = '🍳';
else if (roomName.includes('bad') || roomName.includes('bath')) icon = '🛁';
else if (roomName.includes('büro') || roomName.includes('office')) icon = '💼';
else if (roomName.includes('kind') || roomName.includes('child')) icon = '🧸';
else if (roomName.includes('garten') || roomName.includes('garden')) icon = '🌿';
else if (roomName.includes('garage')) icon = '🚗';
else if (roomName.includes('keller') || roomName.includes('basement')) icon = '📦';
else if (roomName.includes('dach') || roomName.includes('attic')) icon = '🏚️';
// Replace the ::before pseudo-element with actual emoji
const originalText = title.textContent.trim();
title.innerHTML = `${icon} ${originalText}`;
});
});
// Clean up SSE connection before page unload
window.addEventListener('beforeunload', () => {
if (eventSource) {
console.log('Closing SSE connection before unload');
eventSource.close();
eventSource = null;
}
});
// API_BASE injected from backend (supports Docker/K8s environments) // API_BASE injected from backend (supports Docker/K8s environments)
window.API_BASE = '{{ api_base }}'; window.API_BASE = '{{ api_base }}';
window.RUNTIME_CONFIG = window.RUNTIME_CONFIG || {};
// Helper function to construct API URLs // Helper function to construct API URLs
function api(url) { function api(url) {
return `${window.API_BASE}${url}`; return `${window.API_BASE}${url}`;
} }
// iOS/Safari Polyfill laden (nur wenn nötig)
(function() {
var isIOS = /iP(hone|od|ad)/.test(navigator.platform) ||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
if (isIOS && typeof window.EventSourcePolyfill === "undefined") {
var s = document.createElement("script");
s.src = "https://cdn.jsdelivr.net/npm/event-source-polyfill@1.0.31/src/eventsource.min.js";
s.onerror = function() {
console.warn("EventSource polyfill konnte nicht geladen werden");
};
document.head.appendChild(s);
}
})();
let eventSource = null; let eventSource = null;
let currentState = {}; let currentState = {};
let thermostatTargets = {}; let thermostatTargets = {};
let thermostatModes = {};
// Initialize device states // Initialize device states
{% for room in rooms %} {% for room in rooms %}
@@ -539,7 +741,6 @@
currentState['{{ device.device_id }}'] = 'off'; currentState['{{ device.device_id }}'] = 'off';
{% elif device.type == "thermostat" %} {% elif device.type == "thermostat" %}
thermostatTargets['{{ device.device_id }}'] = 21.0; thermostatTargets['{{ device.device_id }}'] = 21.0;
thermostatModes['{{ device.device_id }}'] = 'off';
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
@@ -615,7 +816,6 @@
// Adjust thermostat target temperature // Adjust thermostat target temperature
async function adjustTarget(deviceId, delta) { async function adjustTarget(deviceId, delta) {
const currentTarget = thermostatTargets[deviceId] || 21.0; const currentTarget = thermostatTargets[deviceId] || 21.0;
const currentMode = thermostatModes[deviceId] || 'off';
const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta)); const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta));
try { try {
@@ -627,14 +827,12 @@
body: JSON.stringify({ body: JSON.stringify({
type: 'thermostat', type: 'thermostat',
payload: { payload: {
mode: currentMode,
target: newTarget target: newTarget
} }
}) })
}); });
if (response.ok) { if (response.ok) {
thermostatTargets[deviceId] = newTarget;
console.log(`Sent target ${newTarget} to ${deviceId}`); console.log(`Sent target ${newTarget} to ${deviceId}`);
addEvent({ addEvent({
action: 'target_adjusted', action: 'target_adjusted',
@@ -647,38 +845,6 @@
} }
} }
// Set thermostat mode
async function setMode(deviceId, mode) {
const currentTarget = thermostatTargets[deviceId] || 21.0;
try {
const response = await fetch(api(`/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;
@@ -699,6 +865,8 @@
toggleButton.textContent = 'Einschalten'; toggleButton.textContent = 'Einschalten';
toggleButton.className = 'toggle-button off'; toggleButton.className = 'toggle-button off';
} }
// Force reflow for iOS Safari
void toggleButton.offsetHeight;
} }
// Update brightness display and slider // Update brightness display and slider
@@ -723,7 +891,6 @@
function updateThermostatUI(deviceId, current, target, mode) { function updateThermostatUI(deviceId, current, target, mode) {
const currentSpan = document.getElementById(`state-${deviceId}-current`); const currentSpan = document.getElementById(`state-${deviceId}-current`);
const targetSpan = document.getElementById(`state-${deviceId}-target`); const targetSpan = document.getElementById(`state-${deviceId}-target`);
const modeSpan = document.getElementById(`state-${deviceId}-mode`);
if (current !== undefined && currentSpan) { if (current !== undefined && currentSpan) {
currentSpan.textContent = current.toFixed(1); currentSpan.textContent = current.toFixed(1);
@@ -735,24 +902,23 @@
} }
thermostatTargets[deviceId] = target; thermostatTargets[deviceId] = target;
} }
}
// Update contact sensor UI
function updateContactUI(deviceId, contactState) {
const badge = document.getElementById(`state-${deviceId}`);
if (!badge) {
console.warn(`No contact badge found for device ${deviceId}`);
return;
}
if (mode !== undefined) { // contactState is either "open" or "closed"
if (modeSpan) { if (contactState === "open") {
modeSpan.textContent = mode.toUpperCase(); badge.textContent = "Geöffnet";
} badge.className = "contact-badge open";
thermostatModes[deviceId] = mode; } else if (contactState === "closed") {
badge.textContent = "Geschlossen";
// Update mode button states badge.className = "contact-badge closed";
['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');
}
}
});
} }
} }
@@ -782,77 +948,182 @@
} }
} }
// Connect to SSE // Safari/iOS-kompatibler SSE Client mit Auto-Reconnect
function connectSSE() { let reconnectDelay = 2500;
eventSource = new EventSource(api('/realtime')); let reconnectTimer = null;
// Global handleSSE function für SSE-Nachrichten
window.handleSSE = function(data) {
console.log('SSE message:', data);
eventSource.onopen = () => { addEvent(data);
console.log('SSE connected');
document.getElementById('connection-status').textContent = 'Verbunden';
document.getElementById('connection-status').className = 'status connected';
};
eventSource.addEventListener('message', (e) => { // Update device state
const data = JSON.parse(e.data); if (data.type === 'state' && data.device_id && data.payload) {
console.log('SSE message:', data); const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
if (!card) {
addEvent(data); console.warn(`No card found for device ${data.device_id}`);
return;
// Update device state
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
);
}
} }
});
eventSource.addEventListener('ping', (e) => {
console.log('Heartbeat received');
});
eventSource.onerror = (error) => {
console.error('SSE error:', error);
document.getElementById('connection-status').textContent = 'Getrennt';
document.getElementById('connection-status').className = 'status disconnected';
eventSource.close();
// Reconnect after 5 seconds // Check if it's a light
setTimeout(connectSSE, 5000); if (data.payload.power !== undefined) {
}; currentState[data.device_id] = data.payload.power;
} updateDeviceUI(
data.device_id,
data.payload.power,
data.payload.brightness
);
}
// Check if it's a thermostat
if (data.payload.target !== undefined || data.payload.current !== undefined) {
if (data.payload.target !== undefined) {
thermostatTargets[data.device_id] = data.payload.target;
}
updateThermostatUI(
data.device_id,
data.payload.current,
data.payload.target
);
}
// Check if it's a contact sensor
if (data.payload.contact !== undefined) {
updateContactUI(data.device_id, data.payload.contact);
}
}
};
// Initialize function cleanupSSE() {
connectSSE(); if (eventSource) {
try {
// Optional: Load initial state from API eventSource.close();
async function loadDevices() { } catch(e) {
try { console.error('Error closing EventSource:', e);
const response = await fetch(api('/devices')); }
const devices = await response.json(); eventSource = null;
console.log('Loaded devices:', devices); }
} catch (error) { if (reconnectTimer) {
console.error('Failed to load devices:', error); clearTimeout(reconnectTimer);
reconnectTimer = null;
} }
} }
loadDevices(); function scheduleReconnect() {
if (reconnectTimer) return;
console.log(`Reconnecting in ${reconnectDelay}ms...`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connectSSE();
// Backoff bis 10s
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
}, reconnectDelay);
}
function connectSSE() {
cleanupSSE();
const REALTIME_URL = (window.RUNTIME_CONFIG && window.RUNTIME_CONFIG.REALTIME_URL)
? window.RUNTIME_CONFIG.REALTIME_URL
: api('/realtime');
console.log('Connecting to SSE:', REALTIME_URL);
try {
// Verwende Polyfill wenn verfügbar, sonst native EventSource
const EventSourceImpl = window.EventSourcePolyfill || window.EventSource;
eventSource = new EventSourceImpl(REALTIME_URL, {
withCredentials: false
});
eventSource.onopen = function() {
console.log('SSE connected successfully');
reconnectDelay = 2500; // Reset backoff
document.getElementById('connection-status').textContent = 'Verbunden';
document.getElementById('connection-status').className = 'status connected';
};
eventSource.onmessage = function(evt) {
if (!evt || !evt.data) return;
// Heartbeats beginnen mit ":" -> ignorieren
if (typeof evt.data === "string" && evt.data.charAt(0) === ":") {
return;
}
try {
const data = JSON.parse(evt.data);
if (window.handleSSE) {
window.handleSSE(data);
}
} catch (e) {
console.error('Error parsing SSE message:', e);
}
};
eventSource.onerror = function(error) {
console.error('SSE error:', error, 'readyState:', eventSource?.readyState);
document.getElementById('connection-status').textContent = 'Getrennt';
document.getElementById('connection-status').className = 'status disconnected';
// Safari/iOS verliert Netz beim App-Switch: ruhig reconnecten
scheduleReconnect();
};
} catch (error) {
console.error('Failed to create EventSource:', error);
document.getElementById('connection-status').textContent = 'Getrennt';
document.getElementById('connection-status').className = 'status disconnected';
scheduleReconnect();
}
}
// Visibility-Change Handler für iOS App-Switch
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
// Wenn wieder sichtbar & keine offene Verbindung: verbinden
if (!eventSource || eventSource.readyState !== 1) {
console.log('Page visible again, reconnecting SSE...');
connectSSE();
}
}
});
// Start SSE connection
connectSSE();
// Load initial device states
async function loadDevices() {
try {
const response = await fetch(api('/devices/states'));
const states = await response.json();
console.log('Loaded initial device states:', states);
// Update UI with initial states
for (const [deviceId, state] of Object.entries(states)) {
if (state.power !== undefined) {
// It's a light
currentState[deviceId] = state.power;
updateDeviceUI(deviceId, state.power, state.brightness);
} else if (state.target !== undefined) {
// It's a thermostat
if (state.target) thermostatTargets[deviceId] = state.target;
updateThermostatUI(deviceId, state.current, state.target);
} else if (state.contact !== undefined) {
// It's a contact sensor
updateContactUI(deviceId, state.contact);
}
}
} catch (error) {
console.error('Failed to load initial device states:', error);
}
}
// Load initial states before connecting SSE
loadDevices().then(() => {
console.log('Initial states loaded, now connecting SSE...');
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,4 @@
version: 1 version: 1
mqtt: mqtt:
broker: "172.16.2.16" broker: "172.16.2.16"
port: 1883 port: 1883
@@ -7,50 +6,637 @@ mqtt:
username: null username: null
password: null password: null
keepalive: 60 keepalive: 60
redis: redis:
url: "redis://172.23.1.116:6379/8" url: "redis://172.23.1.116:6379/8"
channel: "ui:updates" channel: "ui:updates"
devices: devices:
- device_id: test_lampe_1 - device_id: lampe_semeniere_wohnzimmer
type: light type: light
cap_version: "light@1.2.0" cap_version: "light@1.2.0"
technology: zigbee2mqtt technology: zigbee2mqtt
features: features:
power: true power: true
brightness: true brightness: false
topics: topics:
set: "vendor/test_lampe_1/set" state: "zigbee2mqtt/0xf0d1b8000015480b"
state: "vendor/test_lampe_1/state" set: "zigbee2mqtt/0xf0d1b8000015480b/set"
- device_id: test_lampe_2 metadata:
type: light friendly_name: "Lampe Semeniere Wohnzimmer"
cap_version: "light@1.2.0" ieee_address: "0xf0d1b8000015480b"
technology: zigbee2mqtt model: "AC10691"
features: vendor: "OSRAM"
power: true - device_id: grosse_lampe_wohnzimmer
topics: type: light
set: "vendor/test_lampe_2/set" cap_version: "light@1.2.0"
state: "vendor/test_lampe_2/state" technology: zigbee2mqtt
- device_id: test_lampe_3 features:
type: light power: true
cap_version: "light@1.2.0" brightness: false
technology: zigbee2mqtt topics:
features: state: "zigbee2mqtt/0xf0d1b80000151aca"
power: true set: "zigbee2mqtt/0xf0d1b80000151aca/set"
brightness: true metadata:
topics: friendly_name: "grosse Lampe Wohnzimmer"
set: "vendor/test_lampe_3/set" ieee_address: "0xf0d1b80000151aca"
state: "vendor/test_lampe_3/state" model: "AC10691"
- device_id: test_thermo_1 vendor: "OSRAM"
type: thermostat - device_id: lampe_naehtischchen_wohnzimmer
cap_version: "thermostat@2.0.0" type: light
technology: zigbee2mqtt cap_version: "light@1.2.0"
features: technology: zigbee2mqtt
mode: true features:
target: true power: true
current: true brightness: false
battery: true topics:
topics: state: "zigbee2mqtt/0x842e14fffee560ee"
set: "vendor/test_thermo_1/set" set: "zigbee2mqtt/0x842e14fffee560ee/set"
state: "vendor/test_thermo_1/state" metadata:
friendly_name: "Lampe Naehtischchen Wohnzimmer"
ieee_address: "0x842e14fffee560ee"
model: "HG06337"
vendor: "Lidl"
- device_id: kleine_lampe_rechts_esszimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000156645"
set: "zigbee2mqtt/0xf0d1b80000156645/set"
metadata:
friendly_name: "kleine Lampe rechts Esszimmer"
ieee_address: "0xf0d1b80000156645"
model: "AC10691"
vendor: "OSRAM"
- device_id: kleine_lampe_links_esszimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000153099"
set: "zigbee2mqtt/0xf0d1b80000153099/set"
metadata:
friendly_name: "kleine Lampe links Esszimmer"
ieee_address: "0xf0d1b80000153099"
model: "AC10691"
vendor: "OSRAM"
- device_id: leselampe_esszimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0xec1bbdfffe7b84f2"
set: "zigbee2mqtt/0xec1bbdfffe7b84f2/set"
metadata:
friendly_name: "Leselampe Esszimmer"
ieee_address: "0xec1bbdfffe7b84f2"
model: "LED1842G3"
vendor: "IKEA"
- device_id: medusalampe_schlafzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000154c7c"
set: "zigbee2mqtt/0xf0d1b80000154c7c/set"
metadata:
friendly_name: "Medusa-Lampe Schlafzimmer"
ieee_address: "0xf0d1b80000154c7c"
model: "AC10691"
vendor: "OSRAM"
- device_id: sportlicht_am_fernseher_studierzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
color_temperature: true
topics:
state: "zigbee2mqtt/0x842e14fffe76a23a"
set: "zigbee2mqtt/0x842e14fffe76a23a/set"
metadata:
friendly_name: "Sportlicht am Fernseher, Studierzimmer"
ieee_address: "0x842e14fffe76a23a"
model: "LED1733G7"
vendor: "IKEA"
- device_id: deckenlampe_schlafzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x0017880108a406a7"
set: "zigbee2mqtt/0x0017880108a406a7/set"
metadata:
friendly_name: "Deckenlampe Schlafzimmer"
ieee_address: "0x0017880108a406a7"
model: "8718699688882"
vendor: "Philips"
- device_id: bettlicht_wolfgang
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x00178801081570bf"
set: "zigbee2mqtt/0x00178801081570bf/set"
metadata:
friendly_name: "Bettlicht Wolfgang"
ieee_address: "0x00178801081570bf"
model: "9290020399"
vendor: "Philips"
- device_id: bettlicht_patty
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x0017880108158b32"
set: "zigbee2mqtt/0x0017880108158b32/set"
metadata:
friendly_name: "Bettlicht Patty"
ieee_address: "0x0017880108158b32"
model: "9290020399"
vendor: "Philips"
- device_id: schranklicht_hinten_patty
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x0017880106e29571"
set: "zigbee2mqtt/0x0017880106e29571/set"
metadata:
friendly_name: "Schranklicht hinten Patty"
ieee_address: "0x0017880106e29571"
model: "8718699673147"
vendor: "Philips"
- device_id: schranklicht_vorne_patty
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000154cf5"
set: "zigbee2mqtt/0xf0d1b80000154cf5/set"
metadata:
friendly_name: "Schranklicht vorne Patty"
ieee_address: "0xf0d1b80000154cf5"
model: "AC10691"
vendor: "OSRAM"
- device_id: leselampe_patty
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x001788010600ec9d"
set: "zigbee2mqtt/0x001788010600ec9d/set"
metadata:
friendly_name: "Leselampe Patty"
ieee_address: "0x001788010600ec9d"
model: "8718699673147"
vendor: "Philips"
- device_id: deckenlampe_esszimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x0017880108a03e45"
set: "zigbee2mqtt/0x0017880108a03e45/set"
metadata:
friendly_name: "Deckenlampe Esszimmer"
ieee_address: "0x0017880108a03e45"
model: "929002241201"
vendor: "Philips"
- device_id: standlampe_esszimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
color_temperature: true
topics:
state: "zigbee2mqtt/0xbc33acfffe21f547"
set: "zigbee2mqtt/0xbc33acfffe21f547/set"
metadata:
friendly_name: "Standlampe Esszimmer"
ieee_address: "0xbc33acfffe21f547"
model: "LED1732G11"
vendor: "IKEA"
- device_id: haustuer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0xec1bbdfffea6a3da"
set: "zigbee2mqtt/0xec1bbdfffea6a3da/set"
metadata:
friendly_name: "Haustür"
ieee_address: "0xec1bbdfffea6a3da"
model: "LED1842G3"
vendor: "IKEA"
- device_id: deckenlampe_flur_oben
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
color_temperature: true
topics:
state: "zigbee2mqtt/0x001788010d2123a7"
set: "zigbee2mqtt/0x001788010d2123a7/set"
metadata:
friendly_name: "Deckenlampe Flur oben"
ieee_address: "0x001788010d2123a7"
model: "929003099001"
vendor: "Philips"
- device_id: kueche_deckenlampe
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x001788010d2c40c4"
set: "zigbee2mqtt/0x001788010d2c40c4/set"
metadata:
friendly_name: "Küche Deckenlampe"
ieee_address: "0x001788010d2c40c4"
model: "929002469202"
vendor: "Philips"
- device_id: sportlicht_tisch
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0xf0d1b8be2409f31b"
set: "zigbee2mqtt/0xf0d1b8be2409f31b/set"
metadata:
friendly_name: "Sportlicht Tisch"
ieee_address: "0xf0d1b8be2409f31b"
model: "4058075729063"
vendor: "LEDVANCE"
- device_id: sportlicht_regal
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0xf0d1b8be2409f569"
set: "zigbee2mqtt/0xf0d1b8be2409f569/set"
metadata:
friendly_name: "Sportlicht Regal"
ieee_address: "0xf0d1b8be2409f569"
model: "4058075729063"
vendor: "LEDVANCE"
- device_id: licht_flur_oben_am_spiegel
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
color_temperature: true
topics:
state: "zigbee2mqtt/0x842e14fffefe4ba4"
set: "zigbee2mqtt/0x842e14fffefe4ba4/set"
metadata:
friendly_name: "Licht Flur oben am Spiegel"
ieee_address: "0x842e14fffefe4ba4"
model: "LED1732G11"
vendor: "IKEA"
- device_id: experimentlabtest
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0xf0d1b80000195038"
set: "zigbee2mqtt/0xf0d1b80000195038/set"
metadata:
friendly_name: "ExperimentLabTest"
ieee_address: "0xf0d1b80000195038"
model: "4058075208421"
vendor: "LEDVANCE"
- device_id: thermostat_wolfgang
type: thermostat
cap_version: "thermostat@1.0.0"
technology: zigbee2mqtt
features:
heating: true
temperature_range:
- 5
- 30
temperature_step: 0.5
topics:
state: "zigbee2mqtt/0x540f57fffe7e3cfe"
set: "zigbee2mqtt/0x540f57fffe7e3cfe/set"
metadata:
friendly_name: "Wolfgang"
ieee_address: "0x540f57fffe7e3cfe"
model: "GS361A-H04"
vendor: "Siterwell"
- device_id: thermostat_kueche
type: thermostat
cap_version: "thermostat@1.0.0"
technology: zigbee2mqtt
features:
heating: true
temperature_range:
- 5
- 30
temperature_step: 0.5
topics:
state: "zigbee2mqtt/0x94deb8fffe2e5c06"
set: "zigbee2mqtt/0x94deb8fffe2e5c06/set"
metadata:
friendly_name: "Kueche"
ieee_address: "0x94deb8fffe2e5c06"
model: "GS361A-H04"
vendor: "Siterwell"
- device_id: thermostat_schlafzimmer
type: thermostat
cap_version: "thermostat@1.0.0"
technology: max
features:
mode: true
target: true
current: false
topics:
set: "homegear/instance1/set/42/1/SET_TEMPERATURE"
state: "homegear/instance1/plain/42/1/SET_TEMPERATURE"
metadata:
friendly_name: "Thermostat Schlafzimmer"
location: "Schlafzimmer"
vendor: "eQ-3"
model: "MAX! Thermostat"
peer_id: "42"
channel: "1"
- device_id: thermostat_esszimmer
type: thermostat
cap_version: "thermostat@1.0.0"
technology: max
features:
mode: true
target: true
current: false
topics:
set: "homegear/instance1/set/45/1/SET_TEMPERATURE"
state: "homegear/instance1/plain/45/1/SET_TEMPERATURE"
metadata:
friendly_name: "Thermostat Esszimmer"
location: "Esszimmer"
vendor: "eQ-3"
model: "MAX! Thermostat"
peer_id: "45"
channel: "1"
- device_id: thermostat_wohnzimmer
type: thermostat
cap_version: "thermostat@1.0.0"
technology: max
features:
mode: true
target: true
current: false
topics:
set: "homegear/instance1/set/46/1/SET_TEMPERATURE"
state: "homegear/instance1/plain/46/1/SET_TEMPERATURE"
metadata:
friendly_name: "Thermostat Wohnzimmer"
location: "Wohnzimmer"
vendor: "eQ-3"
model: "MAX! Thermostat"
peer_id: "46"
channel: "1"
- device_id: thermostat_patty
type: thermostat
cap_version: "thermostat@1.0.0"
technology: max
features:
mode: true
target: true
current: false
topics:
set: "homegear/instance1/set/39/1/SET_TEMPERATURE"
state: "homegear/instance1/plain/39/1/SET_TEMPERATURE"
metadata:
friendly_name: "Thermostat Patty"
location: "Arbeitszimmer Patty"
vendor: "eQ-3"
model: "MAX! Thermostat"
peer_id: "39"
channel: "1"
- device_id: thermostat_bad_oben
type: thermostat
cap_version: "thermostat@1.0.0"
technology: max
features:
mode: true
target: true
current: false
topics:
set: "homegear/instance1/set/41/1/SET_TEMPERATURE"
state: "homegear/instance1/plain/41/1/SET_TEMPERATURE"
metadata:
friendly_name: "Thermostat Bad Oben"
location: "Bad Oben"
vendor: "eQ-3"
model: "MAX! Thermostat"
peer_id: "41"
channel: "1"
- device_id: thermostat_bad_unten
type: thermostat
cap_version: "thermostat@1.0.0"
technology: max
features:
mode: true
target: true
current: false
topics:
set: "homegear/instance1/set/48/1/SET_TEMPERATURE"
state: "homegear/instance1/plain/48/1/SET_TEMPERATURE"
metadata:
friendly_name: "Thermostat Bad Unten"
location: "Bad Unten"
vendor: "eQ-3"
model: "MAX! Thermostat"
peer_id: "48"
channel: "1"
- device_id: sterne_wohnzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000155fc2"
set: "zigbee2mqtt/0xf0d1b80000155fc2/set"
metadata:
friendly_name: "Sterne Wohnzimmer"
ieee_address: "0xf0d1b80000155fc2"
model: "AC10691"
vendor: "OSRAM"
- device_id: kontakt_schlafzimmer_strasse
type: contact
name: Kontakt Schlafzimmer Straße
cap_version: contact_sensor@1.0.0
technology: max
topics:
state: homegear/instance1/plain/52/1/STATE
features: {}
- device_id: kontakt_esszimmer_strasse_rechts
type: contact
name: Kontakt Esszimmer Straße rechts
cap_version: contact_sensor@1.0.0
technology: max
topics:
state: homegear/instance1/plain/26/1/STATE
features: {}
- device_id: kontakt_esszimmer_strasse_links
type: contact
name: Kontakt Esszimmer Straße links
cap_version: contact_sensor@1.0.0
technology: max
topics:
state: homegear/instance1/plain/27/1/STATE
features: {}
- device_id: kontakt_wohnzimmer_garten_rechts
type: contact
name: Kontakt Wohnzimmer Garten rechts
cap_version: contact_sensor@1.0.0
technology: max
topics:
state: homegear/instance1/plain/28/1/STATE
features: {}
- device_id: kontakt_wohnzimmer_garten_links
type: contact
name: Kontakt Wohnzimmer Garten links
cap_version: contact_sensor@1.0.0
technology: max
topics:
state: homegear/instance1/plain/29/1/STATE
features: {}
- device_id: kontakt_kueche_garten_fenster
type: contact
name: Kontakt Küche Garten Fenster
cap_version: contact_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d008b332785
features: {}
- device_id: kontakt_kueche_garten_tuer
type: contact
name: Kontakt Küche Garten Tür
cap_version: contact_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d008b332788
features: {}
- device_id: kontakt_kueche_strasse_rechts
type: contact
name: Kontakt Küche Straße rechts
cap_version: contact_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d008b151803
features: {}
- device_id: kontakt_kueche_strasse_links
type: contact
name: Kontakt Küche Straße links
cap_version: contact_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d008b331d0b
features: {}
- device_id: kontakt_patty_garten_rechts
type: contact
name: Kontakt Patty Garten rechts
cap_version: contact_sensor@1.0.0
technology: max
topics:
state: homegear/instance1/plain/18/1/STATE
features: {}
- device_id: kontakt_patty_garten_links
type: contact
name: Kontakt Patty Garten links
cap_version: contact_sensor@1.0.0
technology: max
topics:
state: homegear/instance1/plain/22/1/STATE
features: {}
- device_id: kontakt_patty_strasse
type: contact
name: Kontakt Patty Straße
cap_version: contact_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d000af457cf
features: {}
- device_id: kontakt_wolfgang_garten
type: contact
name: Kontakt Wolfgang Garten
cap_version: contact_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d008b3328da
features: {}
- device_id: kontakt_bad_oben_strasse
type: contact
name: Kontakt Bad Oben Straße
cap_version: contact_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d008b333aec
features: {}
- device_id: kontakt_bad_unten_strasse
type: contact
name: Kontakt Bad Unten Straße
cap_version: contact_sensor@1.0.0
technology: max
topics:
state: homegear/instance1/plain/44/1/STATE
features: {}

View File

@@ -0,0 +1,66 @@
version: 1
mqtt:
broker: "172.16.2.16"
port: 1883
client_id: "home-automation-abstraction"
username: null
password: null
keepalive: 60
redis:
url: "redis://172.23.1.116:6379/8"
channel: "ui:updates"
devices:
- device_id: test_lampe_1
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
brightness: true
topics:
set: "vendor/test_lampe_1/set"
state: "vendor/test_lampe_1/state"
- device_id: test_lampe_2
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
topics:
set: "vendor/test_lampe_2/set"
state: "vendor/test_lampe_2/state"
- device_id: test_lampe_3
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
brightness: true
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: simulator
features:
mode: false
target: true
current: true
battery: true
topics:
set: "vendor/test_thermo_1/set"
state: "vendor/test_thermo_1/state"
- device_id: experiment_light_1
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
set: "zigbee2mqtt/0xf0d1b80000195038/set"
state: "zigbee2mqtt/0xf0d1b80000195038"

View File

@@ -0,0 +1,66 @@
version: 1
mqtt:
broker: "172.16.2.16"
port: 1883
client_id: "home-automation-abstraction"
username: null
password: null
keepalive: 60
redis:
url: "redis://172.23.1.116:6379/8"
channel: "ui:updates"
devices:
- device_id: test_lampe_1
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
brightness: true
topics:
set: "vendor/test_lampe_1/set"
state: "vendor/test_lampe_1/state"
- device_id: test_lampe_2
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
topics:
set: "vendor/test_lampe_2/set"
state: "vendor/test_lampe_2/state"
- device_id: test_lampe_3
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
brightness: true
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: simulator
features:
mode: false
target: true
current: true
battery: true
topics:
set: "vendor/test_thermo_1/set"
state: "vendor/test_thermo_1/state"
- device_id: experiment_light_1
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
set: "zigbee2mqtt/0xf0d1b80000195038/set"
state: "zigbee2mqtt/0xf0d1b80000195038"

View File

@@ -1,29 +1,210 @@
# UI Layout Configuration
# Defines rooms and device tiles for the home automation UI
rooms: rooms:
- name: Wohnzimmer - name: Schlafzimmer
devices: devices:
- device_id: test_lampe_2 - device_id: bettlicht_patty
title: Deckenlampe title: Bettlicht Patty
icon: "💡" icon: 🛏️
rank: 5 rank: 10
- device_id: test_lampe_1 - device_id: bettlicht_wolfgang
title: Stehlampe title: Bettlicht Wolfgang
icon: "🔆" icon: 🛏️
rank: 10 rank: 20
- device_id: test_thermo_1 - device_id: deckenlampe_schlafzimmer
title: Thermostat title: Deckenlampe Schlafzimmer
icon: "🌡️" icon: 💡
rank: 15 rank: 30
- device_id: medusalampe_schlafzimmer
- name: Schlafzimmer title: Medusa-Lampe Schlafzimmer
devices: icon: 💡
- device_id: test_lampe_3 rank: 40
title: Nachttischlampe - device_id: thermostat_schlafzimmer
icon: "🛏️" title: Thermostat Schlafzimmer
rank: 10 icon: 🌡️
rank: 45
- device_id: kontakt_schlafzimmer_strasse
title: Kontakt Straße
icon: 🪟
rank: 46
- name: Esszimmer
devices:
- device_id: deckenlampe_esszimmer
title: Deckenlampe Esszimmer
icon: 💡
rank: 50
- device_id: leselampe_esszimmer
title: Leselampe Esszimmer
icon: 💡
rank: 60
- device_id: standlampe_esszimmer
title: Standlampe Esszimmer
icon: 💡
rank: 70
- device_id: kleine_lampe_links_esszimmer
title: kleine Lampe links Esszimmer
icon: 💡
rank: 80
- device_id: kleine_lampe_rechts_esszimmer
title: kleine Lampe rechts Esszimmer
icon: 💡
rank: 90
- device_id: thermostat_esszimmer
title: Thermostat Esszimmer
icon: 🌡️
rank: 95
- device_id: kontakt_esszimmer_strasse_rechts
title: Kontakt Straße rechts
icon: 🪟
rank: 96
- device_id: kontakt_esszimmer_strasse_links
title: Kontakt Straße links
icon: 🪟
rank: 97
- name: Wohnzimmer
devices:
- device_id: lampe_naehtischchen_wohnzimmer
title: Lampe Naehtischchen Wohnzimmer
icon: 💡
rank: 100
- device_id: lampe_semeniere_wohnzimmer
title: Lampe Semeniere Wohnzimmer
icon: 💡
rank: 110
- device_id: sterne_wohnzimmer
title: Sterne Wohnzimmer
icon: 💡
rank: 120
- device_id: grosse_lampe_wohnzimmer
title: grosse Lampe Wohnzimmer
icon: 💡
rank: 130
- device_id: thermostat_wohnzimmer
title: Thermostat Wohnzimmer
icon: 🌡️
rank: 135
- device_id: kontakt_wohnzimmer_garten_rechts
title: Kontakt Garten rechts
icon: 🪟
rank: 136
- device_id: kontakt_wohnzimmer_garten_links
title: Kontakt Garten links
icon: 🪟
rank: 137
- name: Küche
devices:
- device_id: kueche_deckenlampe
title: Küche Deckenlampe
icon: 💡
rank: 140
- device_id: thermostat_kueche
title: Kueche
icon: 🌡️
rank: 150
- device_id: kontakt_kueche_garten_fenster
title: Kontakt Garten Fenster
icon: 🪟
rank: 151
- device_id: kontakt_kueche_garten_tuer
title: Kontakt Garten Tür
icon: 🪟
rank: 152
- device_id: kontakt_kueche_strasse_rechts
title: Kontakt Straße rechts
icon: 🪟
rank: 153
- device_id: kontakt_kueche_strasse_links
title: Kontakt Straße links
icon: 🪟
rank: 154
- name: Arbeitszimmer Patty
devices:
- device_id: leselampe_patty
title: Leselampe Patty
icon: 💡
rank: 160
- device_id: schranklicht_hinten_patty
title: Schranklicht hinten Patty
icon: 💡
rank: 170
- device_id: schranklicht_vorne_patty
title: Schranklicht vorne Patty
icon: 💡
rank: 180
- device_id: thermostat_patty
title: Thermostat Patty
icon: 🌡️
rank: 185
- device_id: kontakt_patty_garten_rechts
title: Kontakt Garten rechts
icon: 🪟
rank: 186
- device_id: kontakt_patty_garten_links
title: Kontakt Garten links
icon: 🪟
rank: 187
- device_id: kontakt_patty_strasse
title: Kontakt Straße
icon: 🪟
rank: 188
- name: Arbeitszimmer Wolfgang
devices:
- device_id: thermostat_wolfgang
title: Wolfgang
icon: 🌡️
rank: 190
- device_id: experimentlabtest
title: ExperimentLabTest
icon: 💡
rank: 200
- device_id: kontakt_wolfgang_garten
title: Kontakt Garten
icon: 🪟
rank: 201
- name: Flur
devices:
- device_id: deckenlampe_flur_oben
title: Deckenlampe Flur oben
icon: 💡
rank: 210
- device_id: haustuer
title: Haustür
icon: 💡
rank: 220
- device_id: licht_flur_oben_am_spiegel
title: Licht Flur oben am Spiegel
icon: 💡
rank: 230
- name: Sportzimmer
devices:
- device_id: sportlicht_regal
title: Sportlicht Regal
icon: 🏃
rank: 240
- device_id: sportlicht_tisch
title: Sportlicht Tisch
icon: 🏃
rank: 250
- device_id: sportlicht_am_fernseher_studierzimmer
title: Sportlicht am Fernseher, Studierzimmer
icon: 🏃
rank: 260
- name: Bad Oben
devices:
- device_id: thermostat_bad_oben
title: Thermostat Bad Oben
icon: 🌡️
rank: 270
- device_id: kontakt_bad_oben_strasse
title: Kontakt Straße
icon: 🪟
rank: 271
- name: Bad Unten
devices:
- device_id: thermostat_bad_unten
title: Thermostat Bad Unten
icon: 🌡️
rank: 280
- device_id: kontakt_bad_unten_strasse
title: Kontakt Straße
icon: 🪟
rank: 281

View File

@@ -0,0 +1,35 @@
# UI Layout Configuration
# Defines rooms and device tiles for the home automation UI
rooms:
- name: Wohnzimmer
devices:
- device_id: test_lampe_2
title: Deckenlampe
icon: "💡"
rank: 5
- device_id: test_lampe_1
title: Stehlampe
icon: "🔆"
rank: 10
- device_id: test_thermo_1
title: Thermostat
icon: "🌡️"
rank: 15
- name: Schlafzimmer
devices:
- device_id: test_lampe_3
title: Nachttischlampe
icon: "🛏️"
rank: 10
- name: Lab
devices:
- device_id: experiment_light_1
title: Experimentierlampe
icon: "💡"
rank: 10

View File

@@ -0,0 +1,23 @@
# MAX! Thermostats - Room Assignment
#
# Extracted from layout.yaml
# Format: Room Name | Device ID (if thermostat exists)
#
Schlafzimmer
42
Esszimmer
45
Wohnzimmer
46
Arbeitszimmer Patty
39
Bad Oben
41
Bad Unten
48

33
config/raeume.txt Normal file
View File

@@ -0,0 +1,33 @@
Schlafzimmer
52 Straße
Esszimmer
26 Straße rechts
27 Straße links
Wohnzimmer
28 Garten rechts
29 Garten links
Küche
0x00158d008b332785 Garten Fenster
0x00158d008b332788 Garten Tür
0x00158d008b151803 Straße rechts
0x00158d008b331d0b Straße links
Arbeitszimmer Patty
18 Garten rechts
22 Garten links
0x00158d000af457cf Straße
Arbeitszimmer Wolfgang
0x00158d008b3328da Garten
Bad Oben
0x00158d008b333aec Straße
Bad Unten
44 Straße

View File

@@ -4,6 +4,8 @@ from packages.home_capabilities.light import CAP_VERSION as LIGHT_VERSION
from packages.home_capabilities.light import LightState from packages.home_capabilities.light import LightState
from packages.home_capabilities.thermostat import CAP_VERSION as THERMOSTAT_VERSION from packages.home_capabilities.thermostat import CAP_VERSION as THERMOSTAT_VERSION
from packages.home_capabilities.thermostat import ThermostatState from packages.home_capabilities.thermostat import ThermostatState
from packages.home_capabilities.contact_sensor import CAP_VERSION as CONTACT_SENSOR_VERSION
from packages.home_capabilities.contact_sensor import ContactState
from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout
__all__ = [ __all__ = [
@@ -11,6 +13,8 @@ __all__ = [
"LIGHT_VERSION", "LIGHT_VERSION",
"ThermostatState", "ThermostatState",
"THERMOSTAT_VERSION", "THERMOSTAT_VERSION",
"ContactState",
"CONTACT_SENSOR_VERSION",
"DeviceTile", "DeviceTile",
"Room", "Room",
"UiLayout", "UiLayout",

View File

@@ -0,0 +1,96 @@
"""Contact Sensor Capability - Fensterkontakt (read-only).
This module defines the ContactState model for door/window contact sensors.
These sensors report their open/closed state and are read-only devices.
Capability Version: contact_sensor@1.0.0
"""
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, Field, field_validator
# Capability metadata
CAP_VERSION = "contact_sensor@1.0.0"
DISPLAY_NAME = "Contact Sensor"
class ContactState(BaseModel):
"""State model for contact sensors (door/window sensors).
Contact sensors are read-only devices that report whether a door or window
is open or closed. They typically also report battery level and signal quality.
Attributes:
contact: Current state of the contact ("open" or "closed")
battery: Battery level percentage (0-100), optional
linkquality: MQTT link quality indicator, optional
device_temperature: Internal device temperature in °C, optional
voltage: Battery voltage in mV, optional
ts: Timestamp of the state reading, optional
Examples:
>>> ContactState(contact="open")
ContactState(contact='open', battery=None, ...)
>>> ContactState(contact="closed", battery=95, linkquality=87)
ContactState(contact='closed', battery=95, linkquality=87, ...)
"""
contact: Literal["open", "closed"] = Field(
...,
description="Contact state: 'open' for open door/window, 'closed' for closed"
)
battery: Annotated[int, Field(ge=0, le=100)] | None = Field(
None,
description="Battery level in percent (0-100)"
)
linkquality: int | None = Field(
None,
description="Link quality indicator (typically 0-255)"
)
device_temperature: float | None = Field(
None,
description="Internal device temperature in degrees Celsius"
)
voltage: int | None = Field(
None,
description="Battery voltage in millivolts"
)
ts: datetime | None = Field(
None,
description="Timestamp of the state reading"
)
@staticmethod
def normalize_bool(is_open: bool) -> "ContactState":
"""Convert boolean to ContactState.
Helper method to convert a boolean value to a ContactState instance.
Useful when integrating with systems that use True/False for contact state.
Args:
is_open: True if contact is open, False if closed
Returns:
ContactState instance with appropriate contact value
Examples:
>>> ContactState.normalize_bool(True)
ContactState(contact='open', ...)
>>> ContactState.normalize_bool(False)
ContactState(contact='closed', ...)
"""
return ContactState(contact="open" if is_open else "closed")
# Public API
__all__ = ["ContactState", "CAP_VERSION", "DISPLAY_NAME"]

View File

@@ -16,16 +16,16 @@ class ThermostatState(BaseModel):
Thermostat state model with validation. Thermostat state model with validation.
Attributes: Attributes:
mode: Operating mode (off, heat, auto) mode: Operating mode (off, heat, auto) - optional for SET commands
target: Target temperature in °C [5.0..30.0] target: Target temperature in °C [5.0..30.0]
current: Current temperature in °C (optional in SET, required in STATE) current: Current temperature in °C (optional in SET, required in STATE)
battery: Battery level 0-100% (optional) battery: Battery level 0-100% (optional)
window_open: Window open detection (optional) window_open: Window open detection (optional)
""" """
mode: Literal["off", "heat", "auto"] = Field( mode: Literal["off", "heat", "auto"] | None = Field(
..., None,
description="Operating mode of the thermostat" description="Operating mode of the thermostat (optional for SET commands)"
) )
target: float | Decimal = Field( target: float | Decimal = Field(