diff --git a/apps/abstraction/main.py b/apps/abstraction/main.py index f2e02ff..3da42f4 100644 --- a/apps/abstraction/main.py +++ b/apps/abstraction/main.py @@ -15,7 +15,7 @@ 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 @@ -89,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] @@ -166,6 +167,10 @@ async def handle_abstract_set( # Validate against ThermostatState (current/battery/window_open are optional) 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 @@ -214,12 +219,18 @@ async def handle_vendor_state( elif device_type == "thermostat": # Validate thermostat state: mode, target, current (required), battery, window_open 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_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}") @@ -273,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}") diff --git a/apps/abstraction/transformation.py b/apps/abstraction/transformation.py index fbc3976..f84f1af 100644 --- a/apps/abstraction/transformation.py +++ b/apps/abstraction/transformation.py @@ -159,6 +159,112 @@ def _transform_thermostat_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> di 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!) # ============================================================================ @@ -252,6 +358,16 @@ TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = { ("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, } diff --git a/apps/api/main.py b/apps/api/main.py index 2c695a1..c083099 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -15,7 +15,14 @@ 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__) @@ -137,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 } } @@ -331,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: @@ -356,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, diff --git a/apps/ui/templates/dashboard.html b/apps/ui/templates/dashboard.html index f671489..2be5834 100644 --- a/apps/ui/templates/dashboard.html +++ b/apps/ui/templates/dashboard.html @@ -386,7 +386,45 @@ transform: scale(0.95); } - + /* Contact Sensor Styles */ + .contact-status { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: #f8f9fa; + border-radius: 8px; + margin: 1rem 0; + } + + .contact-badge { + padding: 0.5rem 1rem; + border-radius: 6px; + font-weight: 600; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .contact-badge.open { + background: #dc3545; + color: white; + } + + .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 { margin-top: 2rem; @@ -483,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 %} @@ -555,6 +595,18 @@ +1.0 + + {% elif device.type == "contact" or device.type == "contact_sensor" %} +