diff --git a/MAX_INTEGRATION.md b/MAX_INTEGRATION.md new file mode 100644 index 0000000..63b4868 --- /dev/null +++ b/MAX_INTEGRATION.md @@ -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///SET_TEMPERATURE +Payload: "22" (plain integer as string) +``` + +**STATE Update:** +``` +homegear/instance1/plain///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) diff --git a/apps/abstraction/main.py b/apps/abstraction/main.py index a05272a..f2e02ff 100644 --- a/apps/abstraction/main.py +++ b/apps/abstraction/main.py @@ -173,7 +173,12 @@ async def handle_abstract_set( # Transform abstract payload to vendor-specific format 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}") await mqtt_client.publish(vendor_topic, vendor_message, qos=1) @@ -294,11 +299,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"): @@ -318,15 +342,24 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N # 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"]: - device_technology = device.get("technology", "unknown") - await handle_vendor_state( - client, redis_client, device_id, device["type"], - device_technology, 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") diff --git a/apps/abstraction/transformation.py b/apps/abstraction/transformation.py index 37516f9..964f8ff 100644 --- a/apps/abstraction/transformation.py +++ b/apps/abstraction/transformation.py @@ -124,6 +124,79 @@ def _transform_thermostat_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> di return payload +# ============================================================================ +# HANDLER FUNCTIONS: max technology (Homegear MAX!) +# ============================================================================ + +def _transform_thermostat_max_to_vendor(payload: dict[str, Any]) -> str: + """Transform abstract thermostat payload to MAX! (Homegear) format. + + MAX! expects only the integer temperature value (no JSON). + + Transformations: + - Extract 'target' temperature from payload + - Convert float to integer (MAX! only accepts integers) + - Return as plain string value + + Example: + - Abstract: {'mode': 'heat', 'target': 22.5} + - MAX!: "22" + + Note: MAX! ignores mode - it's always in heating mode + """ + if "target" not in payload: + logger.warning(f"MAX! thermostat payload missing 'target': {payload}") + return "21" # Default fallback + + target_temp = payload["target"] + + # Convert to integer (MAX! protocol requirement) + if isinstance(target_temp, (int, float)): + int_temp = int(round(target_temp)) + return str(int_temp) + + logger.warning(f"MAX! invalid target temperature type: {type(target_temp)}, value: {target_temp}") + return "21" + + +def _transform_thermostat_max_to_abstract(payload: str | int | float) -> dict[str, Any]: + """Transform MAX! (Homegear) thermostat payload to abstract format. + + MAX! sends only the integer temperature value (no JSON). + + Transformations: + - Parse plain string/int value + - Convert to float for abstract protocol + - Wrap in abstract payload structure with mode='heat' + + Example: + - MAX!: "22" or 22 + - Abstract: {'target': 22.0, 'mode': 'heat'} + + Note: MAX! doesn't send current temperature via SET_TEMPERATURE topic + """ + try: + # Handle both string and numeric input + if isinstance(payload, str): + target_temp = float(payload.strip()) + elif isinstance(payload, (int, float)): + target_temp = float(payload) + else: + logger.warning(f"MAX! unexpected payload type: {type(payload)}, value: {payload}") + target_temp = 21.0 + + return { + "target": target_temp, + "mode": "heat" # MAX! is always in heating mode + } + except (ValueError, TypeError) as e: + logger.error(f"MAX! failed to parse temperature: {payload}, error: {e}") + return { + "target": 21.0, + "mode": "heat" + } + + # ============================================================================ # REGISTRY: Maps (device_type, technology, direction) -> handler function # ============================================================================ @@ -142,6 +215,8 @@ TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = { ("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, }