31 Commits

Author SHA1 Message Date
86d1933c1f sensoren 2 2025-11-11 09:13:46 +01:00
9458381593 sensoren 2025-11-11 09:12:35 +01:00
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
18 changed files with 2982 additions and 398 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

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

@@ -15,11 +15,15 @@ import uuid
from aiomqtt import Client
from pydantic import ValidationError
from packages.home_capabilities import LightState, ThermostatState
from packages.home_capabilities import LightState, ThermostatState, ContactState, TempHumidityState
from apps.abstraction.transformation import (
transform_abstract_to_vendor,
transform_vendor_to_abstract
)
# Configure logging
logging.basicConfig(
level=logging.INFO,
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
@@ -85,11 +89,12 @@ def validate_devices(devices: list[dict[str, Any]]) -> None:
if "topics" not in device:
raise ValueError(f"Device {device_id} missing 'topics'")
if "set" not in device["topics"]:
raise ValueError(f"Device {device_id} missing 'topics.set'")
# 'state' topic is required for all devices
if "state" not in device["topics"]:
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
device_ids = [d["device_id"] for d in devices]
@@ -127,6 +132,7 @@ async def handle_abstract_set(
mqtt_client: Client,
device_id: str,
device_type: str,
device_technology: str,
vendor_topic: str,
payload: dict[str, Any]
) -> None:
@@ -136,21 +142,22 @@ async def handle_abstract_set(
mqtt_client: MQTT client instance
device_id: Device identifier
device_type: Device type (e.g., 'light', 'thermostat')
device_technology: Technology identifier (e.g., 'zigbee2mqtt')
vendor_topic: Vendor-specific SET topic
payload: Message payload
"""
# 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
try:
if device_type == "light":
# Validate light SET payload (power and/or brightness)
LightState.model_validate(vendor_payload)
LightState.model_validate(abstract_payload)
elif device_type == "thermostat":
# For thermostat SET: only allow mode and target fields
allowed_set_fields = {"mode", "target"}
invalid_fields = set(vendor_payload.keys()) - allowed_set_fields
invalid_fields = set(abstract_payload.keys()) - allowed_set_fields
if invalid_fields:
logger.warning(
f"Thermostat SET {device_id} contains invalid fields {invalid_fields}, "
@@ -159,12 +166,24 @@ async def handle_abstract_set(
return
# 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:
logger.error(f"Validation failed for {device_type} SET {device_id}: {e}")
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}")
await mqtt_client.publish(vendor_topic, vendor_message, qos=1)
@@ -175,6 +194,7 @@ async def handle_vendor_state(
redis_client: aioredis.Redis,
device_id: str,
device_type: str,
device_technology: str,
payload: dict[str, Any],
redis_channel: str = "ui:updates"
) -> None:
@@ -185,23 +205,37 @@ async def handle_vendor_state(
redis_client: Redis client instance
device_id: Device identifier
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
"""
# 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
try:
if device_type == "light":
LightState.model_validate(payload)
LightState.model_validate(abstract_payload)
elif device_type == "thermostat":
# 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)
elif device_type in {"temp_humidity", "temp_humidity_sensor"}:
# Validate temperature & humidity sensor state
TempHumidityState.model_validate(abstract_payload)
except ValidationError as e:
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
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
topic_type = "temp_humidity" if device_type in {"temp_humidity", "temp_humidity_sensor"} else topic_type
# Publish to abstract state topic (retained)
abstract_topic = f"home/{device_type}/{device_id}/state"
abstract_message = json.dumps(payload)
abstract_topic = f"home/{topic_type}/{device_id}/state"
abstract_message = json.dumps(abstract_payload)
logger.info(f"← abstract STATE {device_id}: {abstract_topic}{abstract_message}")
await mqtt_client.publish(abstract_topic, abstract_message, qos=1, retain=True)
@@ -210,7 +244,7 @@ async def handle_vendor_state(
ui_update = {
"type": "state",
"device_id": device_id,
"payload": payload,
"payload": abstract_payload,
"ts": datetime.now(timezone.utc).isoformat()
}
redis_message = json.dumps(ui_update)
@@ -254,15 +288,22 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
keepalive=keepalive,
timeout=10.0 # Add explicit timeout for operations
) 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():
abstract_set_topic = f"home/{device['type']}/{device['device_id']}/set"
await client.subscribe(abstract_set_topic)
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
device_id = device['device_id']
device_type = device['type']
# 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"]
await client.subscribe(vendor_state_topic)
logger.info(f"Subscribed to vendor STATE: {vendor_state_topic}")
@@ -280,11 +321,30 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
topic = str(message.topic)
payload_str = message.payload.decode()
try:
payload = json.loads(payload_str)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON on {topic}: {payload_str}")
continue
# Determine if message is from a MAX! device (requires plain text handling)
is_max_device = False
max_device_id = None
max_device_type = None
# 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
if topic.startswith("home/") and topic.endswith("/set"):
@@ -297,19 +357,31 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
if device_id in devices:
device = devices[device_id]
vendor_topic = device["topics"]["set"]
device_technology = device.get("technology", "unknown")
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
else:
# Find device by vendor state topic
for device_id, device in devices.items():
if topic == device["topics"]["state"]:
await handle_vendor_state(
client, redis_client, device_id, device["type"], payload, redis_channel
)
break
# For MAX! devices, we already identified them above
if is_max_device:
device = devices[max_device_id]
device_technology = device.get("technology", "unknown")
await handle_vendor_state(
client, redis_client, max_device_id, max_device_type,
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:
logger.info("MQTT worker cancelled")

View File

@@ -0,0 +1,515 @@
"""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: temp_humidity_sensor - zigbee2mqtt technology
# ============================================================================
def _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract temp/humidity sensor payload to zigbee2mqtt format.
Temp/humidity sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
return payload
def _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform zigbee2mqtt temp/humidity sensor payload to abstract format.
Passthrough - zigbee2mqtt provides temperature, humidity, battery, linkquality directly.
"""
return payload
# ============================================================================
# HANDLER FUNCTIONS: temp_humidity_sensor - MAX! technology
# ============================================================================
def _transform_temp_humidity_sensor_max_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract temp/humidity sensor payload to MAX! format.
Temp/humidity sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
return payload
def _transform_temp_humidity_sensor_max_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform MAX! temp/humidity sensor payload to abstract format.
Passthrough - MAX! provides temperature, humidity, battery directly.
"""
return payload
# ============================================================================
# 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,
# Temperature & humidity sensor transformations (support both type aliases)
("temp_humidity_sensor", "zigbee2mqtt", "to_vendor"): _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor,
("temp_humidity_sensor", "zigbee2mqtt", "to_abstract"): _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract,
("temp_humidity_sensor", "max", "to_vendor"): _transform_temp_humidity_sensor_max_to_vendor,
("temp_humidity_sensor", "max", "to_abstract"): _transform_temp_humidity_sensor_max_to_abstract,
("temp_humidity", "zigbee2mqtt", "to_vendor"): _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor,
("temp_humidity", "zigbee2mqtt", "to_abstract"): _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract,
("temp_humidity", "max", "to_vendor"): _transform_temp_humidity_sensor_max_to_vendor,
("temp_humidity", "max", "to_abstract"): _transform_temp_humidity_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

@@ -15,10 +15,26 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
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,
TEMP_HUMIDITY_SENSOR_VERSION,
LightState,
ThermostatState,
ContactState,
TempHumidityState
)
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(
title="Home Automation API",
description="API for home automation system",
@@ -49,6 +65,77 @@ async def health() -> dict[str, str]:
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")
async def spec() -> dict[str, dict[str, str]]:
"""Capability specification endpoint.
@@ -59,7 +146,9 @@ async def spec() -> dict[str, dict[str, str]]:
return {
"capabilities": {
"light": LIGHT_VERSION,
"thermostat": THERMOSTAT_VERSION
"thermostat": THERMOSTAT_VERSION,
"contact": CONTACT_SENSOR_VERSION,
"temp_humidity": TEMP_HUMIDITY_SENSOR_VERSION
}
}
@@ -182,6 +271,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")
async def get_layout() -> dict[str, Any]:
"""Get UI layout configuration.
@@ -243,6 +342,13 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
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
if request.type == "light":
try:
@@ -268,6 +374,18 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
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"
)
elif request.type in {"temp_humidity", "temp_humidity_sensor"}:
# Temperature & humidity sensors are read-only
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Temperature & humidity sensors are read-only devices"
)
else:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -287,7 +405,13 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
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:
request: FastAPI request object for disconnect detection
@@ -295,17 +419,28 @@ async def event_generator(request: Request) -> AsyncGenerator[str, None]:
Yields:
str: SSE formatted event strings
"""
redis_url, redis_channel = get_redis_settings()
redis_client = await aioredis.from_url(redis_url, decode_responses=True)
pubsub = redis_client.pubsub()
redis_client = None
pubsub = None
try:
await pubsub.subscribe(redis_channel)
logger.info(f"SSE client connected, subscribed to {redis_channel}")
# Send retry hint immediately for EventSource reconnect behavior
yield "retry: 2500\n\n"
# Create heartbeat tracking
# 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()
heartbeat_interval = 25
heartbeat_interval = 15 # Safari-friendly: shorter interval
while True:
# Check if client disconnected
@@ -313,29 +448,67 @@ async def event_generator(request: Request) -> AsyncGenerator[str, None]:
logger.info("SSE client disconnected")
break
# Try to get message (non-blocking)
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=0.1)
# Try to get message from Redis (if available)
if pubsub:
try:
message = await asyncio.wait_for(
pubsub.get_message(ignore_subscribe_messages=True),
timeout=0.1
)
if message and message["type"] == "message":
data = message["data"]
logger.debug(f"Sending SSE message: {data[:100]}...")
# Update in-memory cache with latest state
try:
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
# Handle actual data messages
if message and message["type"] == "message":
data = message["data"]
logger.debug(f"Sending SSE message: {data[:100]}...")
yield f"event: message\ndata: {data}\n\n"
last_heartbeat = asyncio.get_event_loop().time()
else:
# No message, sleep a bit to avoid busy loop
await asyncio.sleep(0.1)
# Sleep briefly to avoid busy loop
await asyncio.sleep(0.1)
# Send heartbeat every 25 seconds
# Send heartbeat if interval elapsed
current_time = asyncio.get_event_loop().time()
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
except asyncio.CancelledError:
logger.info("SSE connection cancelled by client")
raise
except Exception as e:
logger.error(f"SSE error: {e}")
raise
finally:
await pubsub.unsubscribe(redis_channel)
await pubsub.aclose()
await redis_client.aclose()
# Cleanup Redis connection
if pubsub:
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")
@@ -343,23 +516,28 @@ async def event_generator(request: Request) -> AsyncGenerator[str, None]:
async def realtime_events(request: Request) -> StreamingResponse:
"""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:
request: FastAPI request object
Returns:
StreamingResponse: SSE stream of Redis messages
StreamingResponse: SSE stream with Redis messages and heartbeats
"""
return StreamingResponse(
event_generator(request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
}
)
return {"message": f"Command sent to {device_id}"}
def main() -> None:

View File

@@ -29,6 +29,16 @@
padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
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 {
@@ -36,6 +46,65 @@
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 {
display: inline-block;
padding: 0.25rem 0.75rem;
@@ -57,27 +126,67 @@
.room {
background: white;
border-radius: 20px;
padding: 2rem;
margin-bottom: 2rem;
margin-bottom: 1rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
transition: box-shadow 0.2s;
}
.room:hover {
transform: translateY(-2px);
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 {
color: #333;
font-size: 1.75rem;
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 3px solid #667eea;
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 {
@@ -249,27 +358,6 @@
color: #999;
}
.mode-display {
background: #f8f9fa;
border-radius: 8px;
padding: 0.75rem;
text-align: center;
margin-bottom: 1rem;
}
.mode-label {
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
}
.mode-value {
font-size: 1rem;
font-weight: 600;
color: #667eea;
text-transform: uppercase;
}
.temp-controls {
display: flex;
gap: 0.5rem;
@@ -298,39 +386,96 @@
transform: scale(0.95);
}
.mode-controls {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.mode-button {
padding: 0.75rem;
border: 2px solid #ddd;
/* Contact Sensor Styles */
.contact-status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
font-size: 0.875rem;
margin: 1rem 0;
}
.contact-badge {
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
background: white;
color: #666;
min-height: 44px;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mode-button:hover {
border-color: #667eea;
color: #667eea;
}
.mode-button.active {
background: #667eea;
border-color: #667eea;
.contact-badge.open {
background: #dc3545;
color: white;
}
.mode-button:active {
transform: scale(0.95);
.contact-badge.closed {
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;
}
/* Temperature & Humidity Sensor Styles */
.temp-humidity-display {
padding: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
margin: 1rem 0;
}
.temp-humidity-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
}
.temp-humidity-row:not(:last-child) {
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.temp-humidity-label {
font-size: 0.875rem;
font-weight: 500;
opacity: 0.9;
}
.temp-humidity-value {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.temp-humidity-battery {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.8);
text-align: center;
padding: 0.5rem;
margin-top: 0.5rem;
background: rgba(0, 0, 0, 0.1);
border-radius: 6px;
}
.temp-humidity-info {
font-size: 0.75rem;
color: #999;
margin-top: 1rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
text-align: center;
}
.events {
@@ -394,16 +539,30 @@
<body>
<div class="container">
<header>
<h1>🏠 Home Automation</h1>
<p>Realtime Status: <span class="status disconnected" id="connection-status">Verbinde...</span></p>
<div class="header-content">
<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>
{% if rooms %}
{% for room in rooms %}
<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 %}
<div class="device-card" data-device-id="{{ device.device_id }}">
<div class="device-header">
@@ -414,6 +573,8 @@
{% if device.features.brightness %}• Dimmbar{% endif %}
{% elif device.type == "thermostat" %}
Thermostat
{% elif device.type == "contact" or device.type == "contact_sensor" %}
Contact Sensor • Read-Only
{% else %}
{{ device.type or "Unknown" }}
{% endif %}
@@ -478,43 +639,52 @@
</div>
</div>
<div class="mode-display">
<div class="mode-label">Modus</div>
<div class="mode-value" id="state-{{ device.device_id }}-mode">OFF</div>
</div>
<div class="temp-controls">
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', -0.5)">
-0.5
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', -1.0)">
-1.0
</button>
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', 0.5)">
+0.5
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', 1.0)">
+1.0
</button>
</div>
<div class="mode-controls">
<button
class="mode-button"
id="mode-{{ device.device_id }}-off"
onclick="setMode('{{ device.device_id }}', 'off')">
Off
</button>
<button
class="mode-button"
id="mode-{{ device.device_id }}-heat"
onclick="setMode('{{ device.device_id }}', 'heat')">
Heat
</button>
<button
class="mode-button"
id="mode-{{ device.device_id }}-auto"
onclick="setMode('{{ device.device_id }}', 'auto')">
Auto
</button>
{% elif device.type == "contact" or device.type == "contact_sensor" %}
<div class="contact-status">
<span class="contact-badge closed" id="state-{{ device.device_id }}">
Geschlossen
</span>
</div>
<div class="contact-info">
🔒 Nur-Lesen Gerät • Keine Steuerung möglich
</div>
{% elif device.type == "temp_humidity" or device.type == "temp_humidity_sensor" %}
<div class="temp-humidity-display">
<div class="temp-humidity-row">
<span class="temp-humidity-label">🌡️ Temperatur:</span>
<span class="temp-humidity-value" id="th-{{ device.device_id }}-t">--</span>
<span style="font-size: 1.25rem; font-weight: 600;">°C</span>
</div>
<div class="temp-humidity-row">
<span class="temp-humidity-label">💧 Luftfeuchte:</span>
<span class="temp-humidity-value" id="th-{{ device.device_id }}-h">--</span>
<span style="font-size: 1.25rem; font-weight: 600;">%</span>
</div>
</div>
<div class="temp-humidity-battery" id="th-{{ device.device_id }}-battery" style="display: none;">
🔋 <span id="th-{{ device.device_id }}-battery-value">--</span>%
</div>
<div class="temp-humidity-info">
🔒 Nur-Lesen Gerät • Keine Steuerung möglich
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{% endfor %}
@@ -534,6 +704,50 @@
</div>
<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');
@@ -558,18 +772,41 @@
});
});
// 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)
window.API_BASE = '{{ api_base }}';
window.RUNTIME_CONFIG = window.RUNTIME_CONFIG || {};
// Helper function to construct API URLs
function api(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 currentState = {};
let thermostatTargets = {};
let thermostatModes = {};
// Initialize device states
{% for room in rooms %}
@@ -578,7 +815,6 @@
currentState['{{ device.device_id }}'] = 'off';
{% elif device.type == "thermostat" %}
thermostatTargets['{{ device.device_id }}'] = 21.0;
thermostatModes['{{ device.device_id }}'] = 'off';
{% endif %}
{% endfor %}
{% endfor %}
@@ -654,7 +890,6 @@
// Adjust thermostat target temperature
async function adjustTarget(deviceId, delta) {
const currentTarget = thermostatTargets[deviceId] || 21.0;
const currentMode = thermostatModes[deviceId] || 'off';
const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta));
try {
@@ -666,7 +901,6 @@
body: JSON.stringify({
type: 'thermostat',
payload: {
mode: currentMode,
target: newTarget
}
})
@@ -685,38 +919,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
function updateDeviceUI(deviceId, power, brightness) {
currentState[deviceId] = power;
@@ -737,6 +939,8 @@
toggleButton.textContent = 'Einschalten';
toggleButton.className = 'toggle-button off';
}
// Force reflow for iOS Safari
void toggleButton.offsetHeight;
}
// Update brightness display and slider
@@ -761,7 +965,6 @@
function updateThermostatUI(deviceId, current, target, mode) {
const currentSpan = document.getElementById(`state-${deviceId}-current`);
const targetSpan = document.getElementById(`state-${deviceId}-target`);
const modeSpan = document.getElementById(`state-${deviceId}-mode`);
if (current !== undefined && currentSpan) {
currentSpan.textContent = current.toFixed(1);
@@ -773,24 +976,56 @@
}
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) {
if (modeSpan) {
modeSpan.textContent = mode.toUpperCase();
}
thermostatModes[deviceId] = mode;
// Update mode button states
['off', 'heat', 'auto'].forEach(m => {
const btn = document.getElementById(`mode-${deviceId}-${m}`);
if (btn) {
if (m === mode.toLowerCase()) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
}
});
// contactState is either "open" or "closed"
if (contactState === "open") {
badge.textContent = "Geöffnet";
badge.className = "contact-badge open";
} else if (contactState === "closed") {
badge.textContent = "Geschlossen";
badge.className = "contact-badge closed";
}
}
// Update temperature & humidity sensor UI
function updateTempHumidityUI(deviceId, payload) {
const tempSpan = document.getElementById(`th-${deviceId}-t`);
const humiditySpan = document.getElementById(`th-${deviceId}-h`);
const batteryDiv = document.getElementById(`th-${deviceId}-battery`);
const batteryValueSpan = document.getElementById(`th-${deviceId}-battery-value`);
if (!tempSpan || !humiditySpan) {
console.warn(`No temp/humidity elements found for device ${deviceId}`);
return;
}
// Update temperature (rounded to 1 decimal)
if (payload.temperature !== undefined && payload.temperature !== null) {
tempSpan.textContent = payload.temperature.toFixed(1);
}
// Update humidity (rounded to 0-1 decimals)
if (payload.humidity !== undefined && payload.humidity !== null) {
// Round to 1 decimal if has decimals, otherwise integer
const humidity = payload.humidity;
humiditySpan.textContent = (humidity % 1 === 0) ? humidity.toFixed(0) : humidity.toFixed(1);
}
// Update battery if present
if (payload.battery !== undefined && payload.battery !== null && batteryDiv && batteryValueSpan) {
batteryValueSpan.textContent = payload.battery;
batteryDiv.style.display = 'block';
} else if (batteryDiv) {
batteryDiv.style.display = 'none';
}
}
@@ -820,167 +1055,181 @@
}
}
// Connect to SSE
let reconnectAttempts = 0;
const maxReconnectDelay = 30000; // Max 30 seconds
// Safari/iOS-kompatibler SSE Client mit Auto-Reconnect
let reconnectDelay = 2500;
let reconnectTimer = null;
function connectSSE() {
// Close existing connection if any
// Global handleSSE function für SSE-Nachrichten
window.handleSSE = function(data) {
console.log('SSE message:', data);
addEvent(data);
// Update device state
if (data.type === 'state' && data.device_id && data.payload) {
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
if (!card) {
console.warn(`No card found for device ${data.device_id}`);
return;
}
// Check if it's a light
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);
}
// Check if it's a temp/humidity sensor
if (data.payload.temperature !== undefined || data.payload.humidity !== undefined) {
updateTempHumidityUI(data.device_id, data.payload);
}
}
};
function cleanupSSE() {
if (eventSource) {
try {
eventSource.close();
} catch (e) {
try {
eventSource.close();
} catch(e) {
console.error('Error closing EventSource:', e);
}
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
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();
console.log(`Connecting to SSE... (attempt ${reconnectAttempts + 1})`);
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 {
eventSource = new EventSource(api('/realtime'));
// Verwende Polyfill wenn verfügbar, sonst native EventSource
const EventSourceImpl = window.EventSourcePolyfill || window.EventSource;
eventSource = new EventSourceImpl(REALTIME_URL, {
withCredentials: false
});
eventSource.onopen = () => {
eventSource.onopen = function() {
console.log('SSE connected successfully');
reconnectAttempts = 0; // Reset counter on successful connection
reconnectDelay = 2500; // Reset backoff
document.getElementById('connection-status').textContent = 'Verbunden';
document.getElementById('connection-status').className = 'status connected';
};
eventSource.addEventListener('message', (e) => {
const data = JSON.parse(e.data);
console.log('SSE message:', data);
eventSource.onmessage = function(evt) {
if (!evt || !evt.data) return;
addEvent(data);
// Update device state
if (data.type === 'state' && data.device_id && data.payload) {
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
if (!card) {
console.warn(`No card found for device ${data.device_id}`);
return;
}
// Check if it's a light
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.mode !== undefined || data.payload.target !== undefined || data.payload.current !== undefined) {
if (data.payload.mode !== undefined) {
thermostatModes[data.device_id] = data.payload.mode;
}
if (data.payload.target !== undefined) {
thermostatTargets[data.device_id] = data.payload.target;
}
updateThermostatUI(
data.device_id,
data.payload.current,
data.payload.target,
data.payload.mode
);
}
// 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.addEventListener('ping', (e) => {
console.log('Heartbeat received');
});
eventSource.onerror = (error) => {
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';
if (eventSource) {
try {
eventSource.close();
} catch (e) {
console.error('Error closing EventSource on error:', e);
}
eventSource = null;
}
// Exponential backoff with max delay
reconnectAttempts++;
const delay = Math.min(
1000 * Math.pow(2, reconnectAttempts - 1),
maxReconnectDelay
);
console.log(`Reconnecting in ${delay}ms... (attempt ${reconnectAttempts})`);
setTimeout(connectSSE, delay);
// 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';
reconnectAttempts++;
const delay = Math.min(
1000 * Math.pow(2, reconnectAttempts - 1),
maxReconnectDelay
);
setTimeout(connectSSE, delay);
scheduleReconnect();
}
}
// Safari/iOS specific: Reconnect when page becomes visible
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
console.log('Page visible, checking SSE connection...');
// Only reconnect if connection is actually dead (CLOSED = 2)
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
console.log('SSE connection dead, forcing reconnect...');
reconnectAttempts = 0; // Reset for immediate reconnect
// 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();
} else {
console.log('SSE connection OK, readyState:', eventSource.readyState);
}
}
});
// Safari/iOS specific: Reconnect on page focus
window.addEventListener('focus', () => {
console.log('Window focused, checking SSE connection...');
// Only reconnect if connection is actually dead (CLOSED = 2)
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
console.log('SSE connection dead, forcing reconnect...');
reconnectAttempts = 0; // Reset for immediate reconnect
connectSSE();
} else {
console.log('SSE connection OK, readyState:', eventSource.readyState);
}
});
// Initialize
// Start SSE connection
connectSSE();
// Load initial device states
async function loadDevices() {
try {
const response = await fetch(api('/devices'));
const devices = await response.json();
console.log('Loaded initial device states:', devices);
const response = await fetch(api('/devices/states'));
const states = await response.json();
console.log('Loaded initial device states:', states);
// Update UI with initial states
devices.forEach(device => {
if (device.type === 'light' && device.state) {
currentState[device.id] = device.state.power;
updateDeviceUI(device.id, device.state.power, device.state.brightness);
} else if (device.type === 'thermostat' && device.state) {
if (device.state.mode) thermostatModes[device.id] = device.state.mode;
if (device.state.target) thermostatTargets[device.id] = device.state.target;
updateThermostatUI(device.id, device.state.current, device.state.target, device.state.mode);
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);
} else if (state.temperature !== undefined || state.humidity !== undefined) {
// It's a temp/humidity sensor
updateTempHumidityUI(deviceId, state);
}
});
}
} catch (error) {
console.error('Failed to load initial device states:', error);
}

View File

@@ -1,5 +1,4 @@
version: 1
mqtt:
broker: "172.16.2.16"
port: 1883
@@ -7,50 +6,718 @@ mqtt:
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: zigbee2mqtt
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: zigbee2mqtt
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: zigbee2mqtt
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: zigbee2mqtt
features:
mode: true
target: true
current: true
battery: true
topics:
set: "vendor/test_thermo_1/set"
state: "vendor/test_thermo_1/state"
- device_id: lampe_semeniere_wohnzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b8000015480b"
set: "zigbee2mqtt/0xf0d1b8000015480b/set"
metadata:
friendly_name: "Lampe Semeniere Wohnzimmer"
ieee_address: "0xf0d1b8000015480b"
model: "AC10691"
vendor: "OSRAM"
- device_id: grosse_lampe_wohnzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000151aca"
set: "zigbee2mqtt/0xf0d1b80000151aca/set"
metadata:
friendly_name: "grosse Lampe Wohnzimmer"
ieee_address: "0xf0d1b80000151aca"
model: "AC10691"
vendor: "OSRAM"
- device_id: lampe_naehtischchen_wohnzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0x842e14fffee560ee"
set: "zigbee2mqtt/0x842e14fffee560ee/set"
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: {}
- device_id: sensor_schlafzimmer
type: temp_humidity_sensor
name: Temperatur & Luftfeuchte
cap_version: temp_humidity_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d00043292dc
features: {}
- device_id: sensor_wohnzimmer
type: temp_humidity_sensor
name: Temperatur & Luftfeuchte
cap_version: temp_humidity_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d0008975707
features: {}
- device_id: sensor_kueche
type: temp_humidity_sensor
name: Temperatur & Luftfeuchte
cap_version: temp_humidity_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d00083299bb
features: {}
- device_id: sensor_arbeitszimmer_patty
type: temp_humidity_sensor
name: Temperatur & Luftfeuchte
cap_version: temp_humidity_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d0003f052b7
features: {}
- device_id: sensor_arbeitszimmer_wolfgang
type: temp_humidity_sensor
name: Temperatur & Luftfeuchte
cap_version: temp_humidity_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d000543fb99
features: {}
- device_id: sensor_bad_oben
type: temp_humidity_sensor
name: Temperatur & Luftfeuchte
cap_version: temp_humidity_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d00093e8987
features: {}
- device_id: sensor_bad_unten
type: temp_humidity_sensor
name: Temperatur & Luftfeuchte
cap_version: temp_humidity_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d00093e662a
features: {}
- device_id: sensor_flur
type: temp_humidity_sensor
name: Temperatur & Luftfeuchte
cap_version: temp_humidity_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d000836ccc6
features: {}
- device_id: sensor_waschkueche
type: temp_humidity_sensor
name: Temperatur & Luftfeuchte
cap_version: temp_humidity_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d000449f3bc
features: {}
- device_id: sensor_sportzimmer
type: temp_humidity_sensor
name: Temperatur & Luftfeuchte
cap_version: temp_humidity_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x00158d0009421422
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,252 @@
# 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: Schlafzimmer
devices:
- device_id: bettlicht_patty
title: Bettlicht Patty
icon: 🛏️
rank: 10
- device_id: bettlicht_wolfgang
title: Bettlicht Wolfgang
icon: 🛏️
rank: 20
- device_id: deckenlampe_schlafzimmer
title: Deckenlampe Schlafzimmer
icon: 💡
rank: 30
- device_id: medusalampe_schlafzimmer
title: Medusa-Lampe Schlafzimmer
icon: 💡
rank: 40
- device_id: thermostat_schlafzimmer
title: Thermostat Schlafzimmer
icon: 🌡️
rank: 45
- device_id: kontakt_schlafzimmer_strasse
title: Kontakt Straße
icon: 🪟
rank: 46
- device_id: sensor_schlafzimmer
title: Temperatur & Luftfeuchte
icon: 🌡️
rank: 47
- 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
- device_id: sensor_wohnzimmer
title: Temperatur & Luftfeuchte
icon: 🌡️
rank: 138
- 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
- device_id: sensor_kueche
title: Temperatur & Luftfeuchte
icon: 🌡️
rank: 155
- 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
- device_id: sensor_arbeitszimmer_patty
title: Temperatur & Luftfeuchte
icon: 🌡️
rank: 189
- 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
- device_id: sensor_arbeitszimmer_wolfgang
title: Temperatur & Luftfeuchte
icon: 🌡️
rank: 202
- 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
- device_id: sensor_flur
title: Temperatur & Luftfeuchte
icon: 🌡️
rank: 235
- 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
- device_id: sensor_sportzimmer
title: Temperatur & Luftfeuchte
icon: 🌡️
rank: 265
- 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
- device_id: sensor_bad_oben
title: Temperatur & Luftfeuchte
icon: 🌡️
rank: 272
- 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
- device_id: sensor_bad_unten
title: Temperatur & Luftfeuchte
icon: 🌡️
rank: 282
- name: Waschküche
devices:
- device_id: sensor_waschkueche
title: Temperatur & Luftfeuchte
icon: 🌡️
rank: 290

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

31
config/raeume.txt Normal file
View File

@@ -0,0 +1,31 @@
Schlafzimmer
0x00158d00043292dc
Esszimmer
Wohnzimmer
0x00158d0008975707
Küche
0x00158d00083299bb
Arbeitszimmer Patty
0x00158d0003f052b7
Arbeitszimmer Wolfgang
0x00158d000543fb99
Bad Oben
0x00158d00093e8987
Bad Unten
0x00158d00093e662a
Flur
0x00158d000836ccc6
Waschküche
0x00158d000449f3bc
Sportzimmer
0x00158d0009421422

View File

@@ -4,6 +4,10 @@ from packages.home_capabilities.light import CAP_VERSION as LIGHT_VERSION
from packages.home_capabilities.light import LightState
from packages.home_capabilities.thermostat import CAP_VERSION as THERMOSTAT_VERSION
from packages.home_capabilities.thermostat import ThermostatState
from packages.home_capabilities.contact_sensor import CAP_VERSION as CONTACT_SENSOR_VERSION
from packages.home_capabilities.contact_sensor import ContactState
from packages.home_capabilities.temp_humidity_sensor import CAP_VERSION as TEMP_HUMIDITY_SENSOR_VERSION
from packages.home_capabilities.temp_humidity_sensor import TempHumidityState
from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout
__all__ = [
@@ -11,6 +15,10 @@ __all__ = [
"LIGHT_VERSION",
"ThermostatState",
"THERMOSTAT_VERSION",
"ContactState",
"CONTACT_SENSOR_VERSION",
"TempHumidityState",
"TEMP_HUMIDITY_SENSOR_VERSION",
"DeviceTile",
"Room",
"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

@@ -0,0 +1,37 @@
"""
Temperature & Humidity Sensor Capability - temp_humidity_sensor@1.0.0
Read-only sensor for temperature and humidity measurements.
"""
from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, Field
class TempHumidityState(BaseModel):
"""
State model for temperature & humidity sensors.
Required fields:
- temperature: Temperature in degrees Celsius
- humidity: Relative humidity in percent
Optional fields:
- battery: Battery level 0-100%
- linkquality: Signal quality indicator
- voltage: Battery voltage in mV
- ts: Timestamp of measurement
"""
temperature: float = Field(..., description="Temperature in degrees Celsius")
humidity: float = Field(..., description="Relative humidity in percent (0-100)")
battery: Annotated[int, Field(ge=0, le=100)] | None = None
linkquality: int | None = None
voltage: int | None = None
ts: datetime | None = None
# Capability metadata
CAP_VERSION = "temp_humidity_sensor@1.0.0"
DISPLAY_NAME = "Temperature & Humidity Sensor"

View File

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