Compare commits

...

12 Commits

12 changed files with 1108 additions and 175 deletions

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

@@ -15,7 +15,7 @@ import uuid
from aiomqtt import Client from aiomqtt import Client
from pydantic import ValidationError from pydantic import ValidationError
from packages.home_capabilities import LightState, ThermostatState from packages.home_capabilities import LightState, ThermostatState, ContactState
from apps.abstraction.transformation import ( from apps.abstraction.transformation import (
transform_abstract_to_vendor, transform_abstract_to_vendor,
transform_vendor_to_abstract transform_vendor_to_abstract
@@ -89,11 +89,12 @@ def validate_devices(devices: list[dict[str, Any]]) -> None:
if "topics" not in device: if "topics" not in device:
raise ValueError(f"Device {device_id} missing 'topics'") raise ValueError(f"Device {device_id} missing 'topics'")
if "set" not in device["topics"]: # 'state' topic is required for all devices
raise ValueError(f"Device {device_id} missing 'topics.set'")
if "state" not in device["topics"]: if "state" not in device["topics"]:
raise ValueError(f"Device {device_id} missing 'topics.state'") raise ValueError(f"Device {device_id} missing 'topics.state'")
# 'set' topic is optional (read-only devices like contact sensors don't have it)
# No validation needed for topics.set
# Log loaded devices # Log loaded devices
device_ids = [d["device_id"] for d in devices] device_ids = [d["device_id"] for d in devices]
@@ -166,6 +167,10 @@ async def handle_abstract_set(
# Validate against ThermostatState (current/battery/window_open are optional) # Validate against ThermostatState (current/battery/window_open are optional)
ThermostatState.model_validate(abstract_payload) ThermostatState.model_validate(abstract_payload)
elif device_type in {"contact", "contact_sensor"}:
# Contact sensors are read-only - SET commands should not occur
logger.warning(f"Contact sensor {device_id} received SET command - ignoring (read-only device)")
return
except ValidationError as e: except ValidationError as e:
logger.error(f"Validation failed for {device_type} SET {device_id}: {e}") logger.error(f"Validation failed for {device_type} SET {device_id}: {e}")
return return
@@ -173,7 +178,12 @@ async def handle_abstract_set(
# Transform abstract payload to vendor-specific format # Transform abstract payload to vendor-specific format
vendor_payload = transform_abstract_to_vendor(device_type, device_technology, abstract_payload) vendor_payload = transform_abstract_to_vendor(device_type, device_technology, abstract_payload)
vendor_message = json.dumps(vendor_payload) # For MAX! thermostats, vendor_payload is a plain string (integer temperature)
# For other devices, it's a dict that needs JSON encoding
if device_technology == "max" and device_type == "thermostat":
vendor_message = vendor_payload # Already a string
else:
vendor_message = json.dumps(vendor_payload)
logger.info(f"→ vendor SET {device_id}: {vendor_topic}{vendor_message}") logger.info(f"→ vendor SET {device_id}: {vendor_topic}{vendor_message}")
await mqtt_client.publish(vendor_topic, vendor_message, qos=1) await mqtt_client.publish(vendor_topic, vendor_message, qos=1)
@@ -209,12 +219,18 @@ async def handle_vendor_state(
elif device_type == "thermostat": elif device_type == "thermostat":
# Validate thermostat state: mode, target, current (required), battery, window_open # Validate thermostat state: mode, target, current (required), battery, window_open
ThermostatState.model_validate(abstract_payload) ThermostatState.model_validate(abstract_payload)
elif device_type in {"contact", "contact_sensor"}:
# Validate contact sensor state
ContactState.model_validate(abstract_payload)
except ValidationError as e: except ValidationError as e:
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}") logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
return return
# Normalize device type for topic (use 'contact' for both 'contact' and 'contact_sensor')
topic_type = "contact" if device_type in {"contact", "contact_sensor"} else device_type
# Publish to abstract state topic (retained) # Publish to abstract state topic (retained)
abstract_topic = f"home/{device_type}/{device_id}/state" abstract_topic = f"home/{topic_type}/{device_id}/state"
abstract_message = json.dumps(abstract_payload) abstract_message = json.dumps(abstract_payload)
logger.info(f"← abstract STATE {device_id}: {abstract_topic}{abstract_message}") logger.info(f"← abstract STATE {device_id}: {abstract_topic}{abstract_message}")
@@ -268,15 +284,22 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
keepalive=keepalive, keepalive=keepalive,
timeout=10.0 # Add explicit timeout for operations timeout=10.0 # Add explicit timeout for operations
) as client: ) as client:
logger.info(f"Connected to MQTT broker as {client_id}") logger.info(f"Connected to MQTT broker as {unique_client_id}")
# Subscribe to abstract SET topics for all devices # Subscribe to topics for all devices
for device in devices.values(): for device in devices.values():
abstract_set_topic = f"home/{device['type']}/{device['device_id']}/set" device_id = device['device_id']
await client.subscribe(abstract_set_topic) device_type = device['type']
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
# Subscribe to vendor STATE topics # Subscribe to abstract SET topic only if device has a SET topic (not read-only)
if "set" in device["topics"]:
abstract_set_topic = f"home/{device_type}/{device_id}/set"
await client.subscribe(abstract_set_topic)
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
else:
logger.info(f"Skipping SET subscription for read-only device: {device_id}")
# Subscribe to vendor STATE topics (all devices have state)
vendor_state_topic = device["topics"]["state"] vendor_state_topic = device["topics"]["state"]
await client.subscribe(vendor_state_topic) await client.subscribe(vendor_state_topic)
logger.info(f"Subscribed to vendor STATE: {vendor_state_topic}") logger.info(f"Subscribed to vendor STATE: {vendor_state_topic}")
@@ -294,11 +317,30 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
topic = str(message.topic) topic = str(message.topic)
payload_str = message.payload.decode() payload_str = message.payload.decode()
try: # Determine if message is from a MAX! device (requires plain text handling)
payload = json.loads(payload_str) is_max_device = False
except json.JSONDecodeError: max_device_id = None
logger.warning(f"Invalid JSON on {topic}: {payload_str}") max_device_type = None
continue
# Check if topic matches any MAX! device state topic
for device_id, device in devices.items():
if device.get("technology") == "max" and topic == device["topics"]["state"]:
is_max_device = True
max_device_id = device_id
max_device_type = device["type"]
break
# Parse payload based on device technology
if is_max_device:
# MAX! sends plain integer/string, not JSON
payload = payload_str.strip()
else:
# All other technologies use JSON
try:
payload = json.loads(payload_str)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON on {topic}: {payload_str}")
continue
# Check if this is an abstract SET message # Check if this is an abstract SET message
if topic.startswith("home/") and topic.endswith("/set"): if topic.startswith("home/") and topic.endswith("/set"):
@@ -318,15 +360,24 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
# Check if this is a vendor STATE message # Check if this is a vendor STATE message
else: else:
# Find device by vendor state topic # For MAX! devices, we already identified them above
for device_id, device in devices.items(): if is_max_device:
if topic == device["topics"]["state"]: device = devices[max_device_id]
device_technology = device.get("technology", "unknown") device_technology = device.get("technology", "unknown")
await handle_vendor_state( await handle_vendor_state(
client, redis_client, device_id, device["type"], client, redis_client, max_device_id, max_device_type,
device_technology, payload, redis_channel device_technology, payload, redis_channel
) )
break else:
# Find device by vendor state topic for other technologies
for device_id, device in devices.items():
if topic == device["topics"]["state"]:
device_technology = device.get("technology", "unknown")
await handle_vendor_state(
client, redis_client, device_id, device["type"],
device_technology, payload, redis_channel
)
break
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("MQTT worker cancelled") logger.info("MQTT worker cancelled")

View File

@@ -111,19 +111,233 @@ def _transform_light_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[st
def _transform_thermostat_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]: def _transform_thermostat_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract thermostat payload to zigbee2mqtt format. """Transform abstract thermostat payload to zigbee2mqtt format.
zigbee2mqtt uses same format as abstract protocol (no transformation needed). 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'}
""" """
return payload 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]: def _transform_thermostat_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform zigbee2mqtt thermostat payload to abstract format. """Transform zigbee2mqtt thermostat payload to abstract format.
zigbee2mqtt uses same format as abstract protocol (no transformation needed). 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 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 # REGISTRY: Maps (device_type, technology, direction) -> handler function
# ============================================================================ # ============================================================================
@@ -142,6 +356,18 @@ TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = {
("thermostat", "simulator", "to_abstract"): _transform_thermostat_simulator_to_abstract, ("thermostat", "simulator", "to_abstract"): _transform_thermostat_simulator_to_abstract,
("thermostat", "zigbee2mqtt", "to_vendor"): _transform_thermostat_zigbee2mqtt_to_vendor, ("thermostat", "zigbee2mqtt", "to_vendor"): _transform_thermostat_zigbee2mqtt_to_vendor,
("thermostat", "zigbee2mqtt", "to_abstract"): _transform_thermostat_zigbee2mqtt_to_abstract, ("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,
} }

View File

@@ -15,7 +15,14 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from packages.home_capabilities import LIGHT_VERSION, THERMOSTAT_VERSION, LightState, ThermostatState from packages.home_capabilities import (
LIGHT_VERSION,
THERMOSTAT_VERSION,
CONTACT_SENSOR_VERSION,
LightState,
ThermostatState,
ContactState
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -137,7 +144,8 @@ async def spec() -> dict[str, dict[str, str]]:
return { return {
"capabilities": { "capabilities": {
"light": LIGHT_VERSION, "light": LIGHT_VERSION,
"thermostat": THERMOSTAT_VERSION "thermostat": THERMOSTAT_VERSION,
"contact": CONTACT_SENSOR_VERSION
} }
} }
@@ -331,6 +339,13 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
detail=f"Device {device_id} not found" detail=f"Device {device_id} not found"
) )
# Check if device is read-only (contact sensors, etc.)
if "topics" in device and "set" not in device["topics"]:
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Device is read-only"
)
# Validate payload based on device type # Validate payload based on device type
if request.type == "light": if request.type == "light":
try: try:
@@ -356,6 +371,12 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid payload for thermostat: {e}" detail=f"Invalid payload for thermostat: {e}"
) )
elif request.type in {"contact", "contact_sensor"}:
# Contact sensors are read-only
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Contact sensors are read-only devices"
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,

View File

@@ -358,27 +358,6 @@
color: #999; color: #999;
} }
.mode-display {
background: #f8f9fa;
border-radius: 8px;
padding: 0.75rem;
text-align: center;
margin-bottom: 1rem;
}
.mode-label {
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
}
.mode-value {
font-size: 1rem;
font-weight: 600;
color: #667eea;
text-transform: uppercase;
}
.temp-controls { .temp-controls {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -407,39 +386,44 @@
transform: scale(0.95); transform: scale(0.95);
} }
.mode-controls { /* Contact Sensor Styles */
display: grid; .contact-status {
grid-template-columns: repeat(3, 1fr); display: flex;
gap: 0.5rem; align-items: center;
} gap: 0.75rem;
padding: 1rem;
.mode-button { background: #f8f9fa;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 8px; border-radius: 8px;
font-size: 0.875rem; margin: 1rem 0;
}
.contact-badge {
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 600; font-weight: 600;
cursor: pointer; font-size: 0.875rem;
transition: all 0.2s;
background: white;
color: #666;
min-height: 44px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px;
} }
.mode-button:hover { .contact-badge.open {
border-color: #667eea; background: #dc3545;
color: #667eea;
}
.mode-button.active {
background: #667eea;
border-color: #667eea;
color: white; color: white;
} }
.mode-button:active { .contact-badge.closed {
transform: scale(0.95); background: #28a745;
color: white;
}
.contact-info {
font-size: 0.75rem;
color: #999;
margin-top: 1rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
text-align: center;
} }
.events { .events {
@@ -512,7 +496,7 @@
<span class="refresh-icon" id="refresh-icon"></span> <span class="refresh-icon" id="refresh-icon"></span>
</button> </button>
<button class="collapse-all-btn" onclick="toggleAllRooms()" title="Alle Räume ein-/ausklappen"> <button class="collapse-all-btn" onclick="toggleAllRooms()" title="Alle Räume ein-/ausklappen">
<span class="collapse-all-icon" id="collapse-all-icon"></span> <span class="collapse-all-icon collapsed" id="collapse-all-icon"></span>
</button> </button>
</div> </div>
</header> </header>
@@ -522,10 +506,10 @@
<section class="room"> <section class="room">
<div class="room-header" onclick="toggleRoom('room-{{ loop.index }}')"> <div class="room-header" onclick="toggleRoom('room-{{ loop.index }}')">
<h2 class="room-title">{{ room.name }}</h2> <h2 class="room-title">{{ room.name }}</h2>
<span class="room-toggle" id="toggle-room-{{ loop.index }}"></span> <span class="room-toggle collapsed" id="toggle-room-{{ loop.index }}"></span>
</div> </div>
<div class="room-content" id="room-{{ loop.index }}"> <div class="room-content collapsed" id="room-{{ loop.index }}">
<div class="devices"> <div class="devices">
{% for device in room.devices %} {% for device in room.devices %}
<div class="device-card" data-device-id="{{ device.device_id }}"> <div class="device-card" data-device-id="{{ device.device_id }}">
@@ -537,6 +521,8 @@
{% if device.features.brightness %}• Dimmbar{% endif %} {% if device.features.brightness %}• Dimmbar{% endif %}
{% elif device.type == "thermostat" %} {% elif device.type == "thermostat" %}
Thermostat Thermostat
{% elif device.type == "contact" or device.type == "contact_sensor" %}
Contact Sensor • Read-Only
{% else %} {% else %}
{{ device.type or "Unknown" }} {{ device.type or "Unknown" }}
{% endif %} {% endif %}
@@ -601,40 +587,26 @@
</div> </div>
</div> </div>
<div class="mode-display">
<div class="mode-label">Modus</div>
<div class="mode-value" id="state-{{ device.device_id }}-mode">OFF</div>
</div>
<div class="temp-controls"> <div class="temp-controls">
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', -0.5)"> <button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', -1.0)">
-0.5 -1.0
</button> </button>
<button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', 0.5)"> <button class="temp-button" onclick="adjustTarget('{{ device.device_id }}', 1.0)">
+0.5 +1.0
</button> </button>
</div> </div>
<div class="mode-controls"> {% elif device.type == "contact" or device.type == "contact_sensor" %}
<button <div class="contact-status">
class="mode-button" <span class="contact-badge closed" id="state-{{ device.device_id }}">
id="mode-{{ device.device_id }}-off" Geschlossen
onclick="setMode('{{ device.device_id }}', 'off')"> </span>
Off
</button>
<button
class="mode-button"
id="mode-{{ device.device_id }}-heat"
onclick="setMode('{{ device.device_id }}', 'heat')">
Heat
</button>
<button
class="mode-button"
id="mode-{{ device.device_id }}-auto"
onclick="setMode('{{ device.device_id }}', 'auto')">
Auto
</button>
</div> </div>
<div class="contact-info">
🔒 Nur-Lesen Gerät • Keine Steuerung möglich
</div>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
@@ -761,7 +733,6 @@
let eventSource = null; let eventSource = null;
let currentState = {}; let currentState = {};
let thermostatTargets = {}; let thermostatTargets = {};
let thermostatModes = {};
// Initialize device states // Initialize device states
{% for room in rooms %} {% for room in rooms %}
@@ -770,7 +741,6 @@
currentState['{{ device.device_id }}'] = 'off'; currentState['{{ device.device_id }}'] = 'off';
{% elif device.type == "thermostat" %} {% elif device.type == "thermostat" %}
thermostatTargets['{{ device.device_id }}'] = 21.0; thermostatTargets['{{ device.device_id }}'] = 21.0;
thermostatModes['{{ device.device_id }}'] = 'off';
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
@@ -846,7 +816,6 @@
// Adjust thermostat target temperature // Adjust thermostat target temperature
async function adjustTarget(deviceId, delta) { async function adjustTarget(deviceId, delta) {
const currentTarget = thermostatTargets[deviceId] || 21.0; const currentTarget = thermostatTargets[deviceId] || 21.0;
const currentMode = thermostatModes[deviceId] || 'off';
const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta)); const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta));
try { try {
@@ -858,7 +827,6 @@
body: JSON.stringify({ body: JSON.stringify({
type: 'thermostat', type: 'thermostat',
payload: { payload: {
mode: currentMode,
target: newTarget target: newTarget
} }
}) })
@@ -877,38 +845,6 @@
} }
} }
// Set thermostat mode
async function setMode(deviceId, mode) {
const currentTarget = thermostatTargets[deviceId] || 21.0;
try {
const response = await fetch(api(`/devices/${deviceId}/set`), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'thermostat',
payload: {
mode: mode,
target: currentTarget
}
})
});
if (response.ok) {
console.log(`Sent mode ${mode} to ${deviceId}`);
addEvent({
action: 'mode_set',
device_id: deviceId,
mode: mode
});
}
} catch (error) {
console.error('Failed to set mode:', error);
}
}
// Update device UI // Update device UI
function updateDeviceUI(deviceId, power, brightness) { function updateDeviceUI(deviceId, power, brightness) {
currentState[deviceId] = power; currentState[deviceId] = power;
@@ -955,7 +891,6 @@
function updateThermostatUI(deviceId, current, target, mode) { function updateThermostatUI(deviceId, current, target, mode) {
const currentSpan = document.getElementById(`state-${deviceId}-current`); const currentSpan = document.getElementById(`state-${deviceId}-current`);
const targetSpan = document.getElementById(`state-${deviceId}-target`); const targetSpan = document.getElementById(`state-${deviceId}-target`);
const modeSpan = document.getElementById(`state-${deviceId}-mode`);
if (current !== undefined && currentSpan) { if (current !== undefined && currentSpan) {
currentSpan.textContent = current.toFixed(1); currentSpan.textContent = current.toFixed(1);
@@ -967,24 +902,23 @@
} }
thermostatTargets[deviceId] = target; thermostatTargets[deviceId] = target;
} }
}
// Update contact sensor UI
function updateContactUI(deviceId, contactState) {
const badge = document.getElementById(`state-${deviceId}`);
if (!badge) {
console.warn(`No contact badge found for device ${deviceId}`);
return;
}
if (mode !== undefined) { // contactState is either "open" or "closed"
if (modeSpan) { if (contactState === "open") {
modeSpan.textContent = mode.toUpperCase(); badge.textContent = "Geöffnet";
} badge.className = "contact-badge open";
thermostatModes[deviceId] = mode; } else if (contactState === "closed") {
badge.textContent = "Geschlossen";
// Update mode button states badge.className = "contact-badge closed";
['off', 'heat', 'auto'].forEach(m => {
const btn = document.getElementById(`mode-${deviceId}-${m}`);
if (btn) {
if (m === mode.toLowerCase()) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
}
});
} }
} }
@@ -1043,20 +977,21 @@
} }
// Check if it's a thermostat // Check if it's a thermostat
if (data.payload.mode !== undefined || data.payload.target !== undefined || data.payload.current !== undefined) { if (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) { if (data.payload.target !== undefined) {
thermostatTargets[data.device_id] = data.payload.target; thermostatTargets[data.device_id] = data.payload.target;
} }
updateThermostatUI( updateThermostatUI(
data.device_id, data.device_id,
data.payload.current, data.payload.current,
data.payload.target, data.payload.target
data.payload.mode
); );
} }
// Check if it's a contact sensor
if (data.payload.contact !== undefined) {
updateContactUI(data.device_id, data.payload.contact);
}
} }
}; };
@@ -1171,11 +1106,13 @@
// It's a light // It's a light
currentState[deviceId] = state.power; currentState[deviceId] = state.power;
updateDeviceUI(deviceId, state.power, state.brightness); updateDeviceUI(deviceId, state.power, state.brightness);
} else if (state.mode !== undefined || state.target !== undefined) { } else if (state.target !== undefined) {
// It's a thermostat // It's a thermostat
if (state.mode) thermostatModes[deviceId] = state.mode;
if (state.target) thermostatTargets[deviceId] = state.target; if (state.target) thermostatTargets[deviceId] = state.target;
updateThermostatUI(deviceId, state.current, state.target, state.mode); updateThermostatUI(deviceId, state.current, state.target);
} else if (state.contact !== undefined) {
// It's a contact sensor
updateContactUI(deviceId, state.contact);
} }
} }
} catch (error) { } catch (error) {

View File

@@ -395,6 +395,114 @@ devices:
ieee_address: "0x94deb8fffe2e5c06" ieee_address: "0x94deb8fffe2e5c06"
model: "GS361A-H04" model: "GS361A-H04"
vendor: "Siterwell" 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 - device_id: sterne_wohnzimmer
type: light type: light
cap_version: "light@1.2.0" cap_version: "light@1.2.0"
@@ -410,3 +518,125 @@ devices:
ieee_address: "0xf0d1b80000155fc2" ieee_address: "0xf0d1b80000155fc2"
model: "AC10691" model: "AC10691"
vendor: "OSRAM" 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

@@ -17,6 +17,14 @@ rooms:
title: Medusa-Lampe Schlafzimmer title: Medusa-Lampe Schlafzimmer
icon: 💡 icon: 💡
rank: 40 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 - name: Esszimmer
devices: devices:
- device_id: deckenlampe_esszimmer - device_id: deckenlampe_esszimmer
@@ -39,6 +47,18 @@ rooms:
title: kleine Lampe rechts Esszimmer title: kleine Lampe rechts Esszimmer
icon: 💡 icon: 💡
rank: 90 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 - name: Wohnzimmer
devices: devices:
- device_id: lampe_naehtischchen_wohnzimmer - device_id: lampe_naehtischchen_wohnzimmer
@@ -57,6 +77,18 @@ rooms:
title: grosse Lampe Wohnzimmer title: grosse Lampe Wohnzimmer
icon: 💡 icon: 💡
rank: 130 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 - name: Küche
devices: devices:
- device_id: kueche_deckenlampe - device_id: kueche_deckenlampe
@@ -67,6 +99,22 @@ rooms:
title: Kueche title: Kueche
icon: 🌡️ icon: 🌡️
rank: 150 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 - name: Arbeitszimmer Patty
devices: devices:
- device_id: leselampe_patty - device_id: leselampe_patty
@@ -81,6 +129,22 @@ rooms:
title: Schranklicht vorne Patty title: Schranklicht vorne Patty
icon: 💡 icon: 💡
rank: 180 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 - name: Arbeitszimmer Wolfgang
devices: devices:
- device_id: thermostat_wolfgang - device_id: thermostat_wolfgang
@@ -91,6 +155,10 @@ rooms:
title: ExperimentLabTest title: ExperimentLabTest
icon: 💡 icon: 💡
rank: 200 rank: 200
- device_id: kontakt_wolfgang_garten
title: Kontakt Garten
icon: 🪟
rank: 201
- name: Flur - name: Flur
devices: devices:
- device_id: deckenlampe_flur_oben - device_id: deckenlampe_flur_oben
@@ -119,3 +187,24 @@ rooms:
title: Sportlicht am Fernseher, Studierzimmer title: Sportlicht am Fernseher, Studierzimmer
icon: 🏃 icon: 🏃
rank: 260 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,23 @@
# MAX! Thermostats - Room Assignment
#
# Extracted from layout.yaml
# Format: Room Name | Device ID (if thermostat exists)
#
Schlafzimmer
42
Esszimmer
45
Wohnzimmer
46
Arbeitszimmer Patty
39
Bad Oben
41
Bad Unten
48

33
config/raeume.txt Normal file
View File

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

View File

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

View File

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

View File

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