29 Commits

Author SHA1 Message Date
f389115841 kontakte 2025-11-10 21:20:43 +01:00
19a6a603d5 window contact first try 2025-11-10 19:41:08 +01:00
e728dd58e4 all collapsed at load/refresh 2025-11-10 17:11:50 +01:00
6310fedeea zigbee2mqtt thermostat transformation 2025-11-10 17:07:41 +01:00
e113616abf fix 2025-11-10 16:58:46 +01:00
e8cd34f88f thermostat mode optional 2025-11-10 16:54:09 +01:00
1bd175c912 fix 2025-11-10 16:42:15 +01:00
cc566c9e73 drop mode from thermostat ui 2025-11-10 16:36:20 +01:00
2eb4f3c376 max thermostats added 2025-11-10 16:19:55 +01:00
b57ddb1589 MAX transformation added 2025-11-10 16:11:28 +01:00
a49d56df60 temperaturschrittweite 1.0 2025-11-10 16:04:44 +01:00
5a7b16f7aa mode buttons removed from thermostat 2025-11-10 16:02:23 +01:00
e69822719a fix 2025-11-10 12:35:06 +01:00
25a6b98d41 alle lampen 2025-11-10 12:28:23 +01:00
5f7af7574c sse iphone fix 4 2025-11-09 21:19:06 +01:00
0c73e36e82 sse iphone fix 2 2025-11-09 20:12:08 +01:00
01b60671db sse iphone fix 1 2025-11-09 20:05:35 +01:00
b60fdfced4 refresh 3 2025-11-09 18:52:52 +01:00
0cd0c6de41 refresh 2 2025-11-09 18:40:31 +01:00
ecf5aebc3c refresh 2025-11-09 18:19:20 +01:00
79d87aff6a transformation added 3 2025-11-09 13:31:07 +01:00
b1e9b201d1 transformation added 2 2025-11-09 13:26:55 +01:00
1eff8a2044 transformation added 2025-11-09 12:59:15 +01:00
8fd0921a08 experiment light 1 2025-11-09 12:21:25 +01:00
7304a017c2 disable mode of thermostat 2025-11-09 00:12:43 +01:00
db6da4815c klappbare Räume 4 2025-11-08 23:40:50 +01:00
54f53705c0 klappbare Räume 3 2025-11-08 21:04:51 +01:00
f8144496b3 klappbare Räume 2 2025-11-08 18:29:00 +01:00
50e7402152 klappbare Räume 2025-11-08 18:27:23 +01:00
17 changed files with 2640 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
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,33 @@ 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)
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
# 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 +240,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 +284,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 +317,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 +353,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,463 @@
"""Payload transformation functions for vendor-specific device communication.
This module implements a registry-pattern for vendor-specific transformations:
- Each (device_type, technology, direction) tuple maps to a specific handler function
- Handlers transform payloads between abstract and vendor-specific formats
- Unknown combinations fall back to pass-through (no transformation)
"""
import logging
from typing import Any, Callable
logger = logging.getLogger(__name__)
# ============================================================================
# HANDLER FUNCTIONS: simulator technology
# ============================================================================
def _transform_light_simulator_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract light payload to simulator format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
def _transform_light_simulator_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform simulator light payload to abstract format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
def _transform_thermostat_simulator_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract thermostat payload to simulator format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
def _transform_thermostat_simulator_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform simulator thermostat payload to abstract format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
# ============================================================================
# HANDLER FUNCTIONS: zigbee2mqtt technology
# ============================================================================
def _transform_light_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract light payload to zigbee2mqtt format.
Transformations:
- power: 'on'/'off' -> state: 'ON'/'OFF'
- brightness: 0-100 -> brightness: 0-254
Example:
- Abstract: {'power': 'on', 'brightness': 100}
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
"""
vendor_payload = payload.copy()
# Transform power -> state with uppercase values
if "power" in vendor_payload:
power_value = vendor_payload.pop("power")
vendor_payload["state"] = power_value.upper() if isinstance(power_value, str) else power_value
# Transform brightness: 0-100 (%) -> 0-254 (zigbee2mqtt range)
if "brightness" in vendor_payload:
abstract_brightness = vendor_payload["brightness"]
if isinstance(abstract_brightness, (int, float)):
# Convert percentage (0-100) to zigbee2mqtt range (0-254)
vendor_payload["brightness"] = round(abstract_brightness * 254 / 100)
return vendor_payload
def _transform_light_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform zigbee2mqtt light payload to abstract format.
Transformations:
- state: 'ON'/'OFF' -> power: 'on'/'off'
- brightness: 0-254 -> brightness: 0-100
Example:
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
- Abstract: {'power': 'on', 'brightness': 100}
"""
abstract_payload = payload.copy()
# Transform state -> power with lowercase values
if "state" in abstract_payload:
state_value = abstract_payload.pop("state")
abstract_payload["power"] = state_value.lower() if isinstance(state_value, str) else state_value
# Transform brightness: 0-254 (zigbee2mqtt range) -> 0-100 (%)
if "brightness" in abstract_payload:
vendor_brightness = abstract_payload["brightness"]
if isinstance(vendor_brightness, (int, float)):
# Convert zigbee2mqtt range (0-254) to percentage (0-100)
abstract_payload["brightness"] = round(vendor_brightness * 100 / 254)
return abstract_payload
def _transform_thermostat_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract thermostat payload to zigbee2mqtt format.
Transformations:
- target -> current_heating_setpoint (as string)
- mode is ignored (zigbee2mqtt thermostats use system_mode in state only)
Example:
- Abstract: {'target': 22.0}
- zigbee2mqtt: {'current_heating_setpoint': '22.0'}
"""
vendor_payload = {}
if "target" in payload:
# zigbee2mqtt expects current_heating_setpoint as string
vendor_payload["current_heating_setpoint"] = str(payload["target"])
return vendor_payload
def _transform_thermostat_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform zigbee2mqtt thermostat payload to abstract format.
Transformations:
- current_heating_setpoint -> target (as float)
- local_temperature -> current (as float)
- system_mode -> mode
Example:
- zigbee2mqtt: {'current_heating_setpoint': 15, 'local_temperature': 23, 'system_mode': 'heat'}
- Abstract: {'target': 15.0, 'current': 23.0, 'mode': 'heat'}
"""
abstract_payload = {}
# Extract target temperature
if "current_heating_setpoint" in payload:
setpoint = payload["current_heating_setpoint"]
abstract_payload["target"] = float(setpoint)
# Extract current temperature
if "local_temperature" in payload:
current = payload["local_temperature"]
abstract_payload["current"] = float(current)
# Extract mode
if "system_mode" in payload:
abstract_payload["mode"] = payload["system_mode"]
return abstract_payload
# ============================================================================
# HANDLER FUNCTIONS: contact_sensor - zigbee2mqtt technology
# ============================================================================
def _transform_contact_sensor_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract contact sensor payload to zigbee2mqtt format.
Contact sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return payload
def _transform_contact_sensor_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform zigbee2mqtt contact sensor payload to abstract format.
Transformations:
- contact: bool -> "open" | "closed"
- zigbee2mqtt semantics: False = OPEN, True = CLOSED (inverted!)
- battery: pass through (already 0-100)
- linkquality: pass through
- device_temperature: pass through (if present)
- voltage: pass through (if present)
Example:
- zigbee2mqtt: {"contact": false, "battery": 100, "linkquality": 87}
- Abstract: {"contact": "open", "battery": 100, "linkquality": 87}
"""
abstract_payload = {}
# Transform contact state (inverted logic!)
if "contact" in payload:
contact_bool = payload["contact"]
# zigbee2mqtt: False = OPEN, True = CLOSED
abstract_payload["contact"] = "closed" if contact_bool else "open"
# Pass through optional fields
if "battery" in payload:
abstract_payload["battery"] = payload["battery"]
if "linkquality" in payload:
abstract_payload["linkquality"] = payload["linkquality"]
if "device_temperature" in payload:
abstract_payload["device_temperature"] = payload["device_temperature"]
if "voltage" in payload:
abstract_payload["voltage"] = payload["voltage"]
return abstract_payload
# ============================================================================
# HANDLER FUNCTIONS: contact_sensor - max technology (Homegear MAX!)
# ============================================================================
def _transform_contact_sensor_max_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract contact sensor payload to MAX! format.
Contact sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return payload
def _transform_contact_sensor_max_to_abstract(payload: str | bool | dict[str, Any]) -> dict[str, Any]:
"""Transform MAX! (Homegear) contact sensor payload to abstract format.
MAX! sends "true"/"false" (string or bool) on STATE topic.
Transformations:
- "true" or True -> "open" (window/door open)
- "false" or False -> "closed" (window/door closed)
Example:
- MAX!: "true" or True
- Abstract: {"contact": "open"}
"""
try:
# Handle string, bool, or dict input
if isinstance(payload, dict):
# If already a dict, extract contact field
contact_value = payload.get("contact", False)
elif isinstance(payload, str):
# Parse string to bool
contact_value = payload.strip().lower() == "true"
elif isinstance(payload, bool):
# Use bool directly
contact_value = payload
else:
logger.warning(f"MAX! contact sensor unexpected payload type: {type(payload)}, value: {payload}")
contact_value = False
# MAX! semantics: True = OPEN, False = CLOSED
return {
"contact": "open" if contact_value else "closed"
}
except (ValueError, TypeError) as e:
logger.error(f"MAX! contact sensor failed to parse: {payload}, error: {e}")
return {
"contact": "closed" # Default to closed on error
}
# ============================================================================
# HANDLER FUNCTIONS: max technology (Homegear MAX!)
# ============================================================================
def _transform_thermostat_max_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract thermostat payload to MAX! (Homegear) format.
MAX! expects only the integer temperature value (no JSON).
Transformations:
- Extract 'target' temperature from payload
- Convert float to integer (MAX! only accepts integers)
- Return as plain string value
Example:
- Abstract: {'mode': 'heat', 'target': 22.5}
- MAX!: "22"
Note: MAX! ignores mode - it's always in heating mode
"""
if "target" not in payload:
logger.warning(f"MAX! thermostat payload missing 'target': {payload}")
return "21" # Default fallback
target_temp = payload["target"]
# Convert to integer (MAX! protocol requirement)
if isinstance(target_temp, (int, float)):
int_temp = int(round(target_temp))
return str(int_temp)
logger.warning(f"MAX! invalid target temperature type: {type(target_temp)}, value: {target_temp}")
return "21"
def _transform_thermostat_max_to_abstract(payload: str | int | float) -> dict[str, Any]:
"""Transform MAX! (Homegear) thermostat payload to abstract format.
MAX! sends only the integer temperature value (no JSON).
Transformations:
- Parse plain string/int value
- Convert to float for abstract protocol
- Wrap in abstract payload structure with mode='heat'
Example:
- MAX!: "22" or 22
- Abstract: {'target': 22.0, 'mode': 'heat'}
Note: MAX! doesn't send current temperature via SET_TEMPERATURE topic
"""
try:
# Handle both string and numeric input
if isinstance(payload, str):
target_temp = float(payload.strip())
elif isinstance(payload, (int, float)):
target_temp = float(payload)
else:
logger.warning(f"MAX! unexpected payload type: {type(payload)}, value: {payload}")
target_temp = 21.0
return {
"target": target_temp,
"mode": "heat" # MAX! is always in heating mode
}
except (ValueError, TypeError) as e:
logger.error(f"MAX! failed to parse temperature: {payload}, error: {e}")
return {
"target": 21.0,
"mode": "heat"
}
# ============================================================================
# REGISTRY: Maps (device_type, technology, direction) -> handler function
# ============================================================================
TransformHandler = Callable[[dict[str, Any]], dict[str, Any]]
TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = {
# Light transformations
("light", "simulator", "to_vendor"): _transform_light_simulator_to_vendor,
("light", "simulator", "to_abstract"): _transform_light_simulator_to_abstract,
("light", "zigbee2mqtt", "to_vendor"): _transform_light_zigbee2mqtt_to_vendor,
("light", "zigbee2mqtt", "to_abstract"): _transform_light_zigbee2mqtt_to_abstract,
# Thermostat transformations
("thermostat", "simulator", "to_vendor"): _transform_thermostat_simulator_to_vendor,
("thermostat", "simulator", "to_abstract"): _transform_thermostat_simulator_to_abstract,
("thermostat", "zigbee2mqtt", "to_vendor"): _transform_thermostat_zigbee2mqtt_to_vendor,
("thermostat", "zigbee2mqtt", "to_abstract"): _transform_thermostat_zigbee2mqtt_to_abstract,
("thermostat", "max", "to_vendor"): _transform_thermostat_max_to_vendor,
("thermostat", "max", "to_abstract"): _transform_thermostat_max_to_abstract,
# Contact sensor transformations (support both 'contact' and 'contact_sensor' types)
("contact_sensor", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
("contact_sensor", "zigbee2mqtt", "to_abstract"): _transform_contact_sensor_zigbee2mqtt_to_abstract,
("contact_sensor", "max", "to_vendor"): _transform_contact_sensor_max_to_vendor,
("contact_sensor", "max", "to_abstract"): _transform_contact_sensor_max_to_abstract,
("contact", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
("contact", "zigbee2mqtt", "to_abstract"): _transform_contact_sensor_zigbee2mqtt_to_abstract,
("contact", "max", "to_vendor"): _transform_contact_sensor_max_to_vendor,
("contact", "max", "to_abstract"): _transform_contact_sensor_max_to_abstract,
}
def _get_transform_handler(
device_type: str,
device_technology: str,
direction: str
) -> TransformHandler:
"""Get transformation handler for given device type, technology and direction.
Args:
device_type: Type of device (e.g., "light", "thermostat")
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
direction: Transformation direction ("to_vendor" or "to_abstract")
Returns:
Handler function for transformation, or pass-through if not found
"""
key = (device_type, device_technology, direction)
handler = TRANSFORM_HANDLERS.get(key)
if handler is None:
logger.warning(
f"No transformation handler for {key}, using pass-through. "
f"Available: {list(TRANSFORM_HANDLERS.keys())}"
)
return lambda payload: payload # Pass-through fallback
return handler
# ============================================================================
# PUBLIC API: Main transformation functions
# ============================================================================
def transform_abstract_to_vendor(
device_type: str,
device_technology: str,
abstract_payload: dict[str, Any]
) -> dict[str, Any]:
"""Transform abstract payload to vendor-specific format.
Args:
device_type: Type of device (e.g., "light", "thermostat")
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
abstract_payload: Payload in abstract home protocol format
Returns:
Payload in vendor-specific format
"""
logger.debug(
f"transform_abstract_to_vendor IN: type={device_type}, tech={device_technology}, "
f"payload={abstract_payload}"
)
handler = _get_transform_handler(device_type, device_technology, "to_vendor")
vendor_payload = handler(abstract_payload)
logger.debug(
f"transform_abstract_to_vendor OUT: type={device_type}, tech={device_technology}, "
f"payload={vendor_payload}"
)
return vendor_payload
def transform_vendor_to_abstract(
device_type: str,
device_technology: str,
vendor_payload: dict[str, Any]
) -> dict[str, Any]:
"""Transform vendor-specific payload to abstract format.
Args:
device_type: Type of device (e.g., "light", "thermostat")
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
vendor_payload: Payload in vendor-specific format
Returns:
Payload in abstract home protocol format
"""
logger.debug(
f"transform_vendor_to_abstract IN: type={device_type}, tech={device_technology}, "
f"payload={vendor_payload}"
)
handler = _get_transform_handler(device_type, device_technology, "to_abstract")
abstract_payload = handler(vendor_payload)
logger.debug(
f"transform_vendor_to_abstract OUT: type={device_type}, tech={device_technology}, "
f"payload={abstract_payload}"
)
return abstract_payload

View File

@@ -15,10 +15,24 @@ 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,
LightState,
ThermostatState,
ContactState
)
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 +63,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 +144,8 @@ async def spec() -> dict[str, dict[str, str]]:
return {
"capabilities": {
"light": LIGHT_VERSION,
"thermostat": THERMOSTAT_VERSION
"thermostat": THERMOSTAT_VERSION,
"contact": CONTACT_SENSOR_VERSION
}
}
@@ -182,6 +268,16 @@ async def get_devices() -> list[DeviceInfo]:
]
@app.get("/devices/states")
async def get_device_states() -> dict[str, dict[str, Any]]:
"""Get current states of all devices from in-memory cache.
Returns:
dict: Dictionary mapping device_id to state payload
"""
return device_states
@app.get("/layout")
async def get_layout() -> dict[str, Any]:
"""Get UI layout configuration.
@@ -243,6 +339,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 +371,12 @@ 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"
)
else:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -287,7 +396,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 +410,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 +439,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 +507,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,44 @@
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;
}
.events {
@@ -394,16 +487,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 +521,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 +587,30 @@
</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>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{% endfor %}
@@ -534,6 +630,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 +698,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 +741,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 +816,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 +827,6 @@
body: JSON.stringify({
type: 'thermostat',
payload: {
mode: currentMode,
target: newTarget
}
})
@@ -685,38 +845,6 @@
}
}
// Set thermostat mode
async function setMode(deviceId, mode) {
const currentTarget = thermostatTargets[deviceId] || 21.0;
try {
const response = await fetch(api(`/devices/${deviceId}/set`), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'thermostat',
payload: {
mode: mode,
target: currentTarget
}
})
});
if (response.ok) {
console.log(`Sent mode ${mode} to ${deviceId}`);
addEvent({
action: 'mode_set',
device_id: deviceId,
mode: mode
});
}
} catch (error) {
console.error('Failed to set mode:', error);
}
}
// Update device UI
function updateDeviceUI(deviceId, power, brightness) {
currentState[deviceId] = power;
@@ -737,6 +865,8 @@
toggleButton.textContent = 'Einschalten';
toggleButton.className = 'toggle-button off';
}
// Force reflow for iOS Safari
void toggleButton.offsetHeight;
}
// Update brightness display and slider
@@ -761,7 +891,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 +902,23 @@
}
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";
}
}
@@ -820,167 +948,173 @@
}
}
// 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);
}
}
};
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);
}
});
}
} 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,637 @@ 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: {}

View File

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

View File

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

View File

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

View File

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

View File

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

33
config/raeume.txt Normal file
View File

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

View File

@@ -4,6 +4,8 @@ from packages.home_capabilities.light import CAP_VERSION as LIGHT_VERSION
from packages.home_capabilities.light import LightState
from packages.home_capabilities.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.layout import DeviceTile, Room, UiLayout, load_layout
__all__ = [
@@ -11,6 +13,8 @@ __all__ = [
"LIGHT_VERSION",
"ThermostatState",
"THERMOSTAT_VERSION",
"ContactState",
"CONTACT_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

@@ -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(