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" %} +
+ + Geschlossen + +
+ +
+ 🔒 Nur-Lesen Gerät • Keine Steuerung möglich +
+ {% endif %} {% endfor %} @@ -852,6 +904,24 @@ } } + // 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; + } + + // 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"; + } + } + // Add event to list function addEvent(event) { const eventList = document.getElementById('event-list'); @@ -917,6 +987,11 @@ data.payload.target ); } + + // Check if it's a contact sensor + if (data.payload.contact !== undefined) { + updateContactUI(data.device_id, data.payload.contact); + } } }; @@ -1035,6 +1110,9 @@ // 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) { diff --git a/config/devices.yaml b/config/devices.yaml index 224ba06..42691f7 100644 --- a/config/devices.yaml +++ b/config/devices.yaml @@ -518,3 +518,12 @@ devices: ieee_address: "0xf0d1b80000155fc2" model: "AC10691" vendor: "OSRAM" + +- device_id: fenster_wohnzimmer + type: contact + name: Fenster Wohnzimmer + cap_version: contact_sensor@1.0.0 + technology: zigbee2mqtt + topics: + state: zigbee2mqtt/0x540f57fffe123456 + features: {} diff --git a/config/layout.yaml b/config/layout.yaml index 4c7f360..5758d68 100644 --- a/config/layout.yaml +++ b/config/layout.yaml @@ -69,6 +69,10 @@ rooms: title: Thermostat Wohnzimmer icon: 🌡️ rank: 135 + - device_id: fenster_wohnzimmer + title: Fenster Wohnzimmer + icon: 🪟 + rank: 140 - name: Küche devices: - device_id: kueche_deckenlampe diff --git a/packages/home_capabilities/__init__.py b/packages/home_capabilities/__init__.py index 169e1d6..0b164e2 100644 --- a/packages/home_capabilities/__init__.py +++ b/packages/home_capabilities/__init__.py @@ -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", diff --git a/packages/home_capabilities/contact_sensor.py b/packages/home_capabilities/contact_sensor.py new file mode 100644 index 0000000..93af609 --- /dev/null +++ b/packages/home_capabilities/contact_sensor.py @@ -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"]