diff --git a/apps/abstraction/main.py b/apps/abstraction/main.py index 711b902..a05272a 100644 --- a/apps/abstraction/main.py +++ b/apps/abstraction/main.py @@ -16,10 +16,14 @@ from aiomqtt import Client from pydantic import ValidationError from packages.home_capabilities import LightState, ThermostatState +from apps.abstraction.transformation import ( + transform_abstract_to_vendor, + transform_vendor_to_abstract +) # Configure logging logging.basicConfig( - level=logging.INFO, + level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) @@ -127,6 +131,7 @@ async def handle_abstract_set( mqtt_client: Client, device_id: str, device_type: str, + device_technology: str, vendor_topic: str, payload: dict[str, Any] ) -> None: @@ -136,21 +141,22 @@ async def handle_abstract_set( mqtt_client: MQTT client instance device_id: Device identifier device_type: Device type (e.g., 'light', 'thermostat') + device_technology: Technology identifier (e.g., 'zigbee2mqtt') vendor_topic: Vendor-specific SET topic payload: Message payload """ # Extract actual payload (remove type wrapper if present) - vendor_payload = payload.get("payload", payload) + abstract_payload = payload.get("payload", payload) # Validate payload based on device type try: if device_type == "light": # Validate light SET payload (power and/or brightness) - LightState.model_validate(vendor_payload) + LightState.model_validate(abstract_payload) elif device_type == "thermostat": # For thermostat SET: only allow mode and target fields allowed_set_fields = {"mode", "target"} - invalid_fields = set(vendor_payload.keys()) - allowed_set_fields + invalid_fields = set(abstract_payload.keys()) - allowed_set_fields if invalid_fields: logger.warning( f"Thermostat SET {device_id} contains invalid fields {invalid_fields}, " @@ -159,11 +165,14 @@ async def handle_abstract_set( return # Validate against ThermostatState (current/battery/window_open are optional) - ThermostatState.model_validate(vendor_payload) + ThermostatState.model_validate(abstract_payload) except ValidationError as e: logger.error(f"Validation failed for {device_type} SET {device_id}: {e}") return + # 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) logger.info(f"→ vendor SET {device_id}: {vendor_topic} ← {vendor_message}") @@ -175,6 +184,7 @@ async def handle_vendor_state( redis_client: aioredis.Redis, device_id: str, device_type: str, + device_technology: str, payload: dict[str, Any], redis_channel: str = "ui:updates" ) -> None: @@ -185,23 +195,27 @@ async def handle_vendor_state( redis_client: Redis client instance device_id: Device identifier device_type: Device type (e.g., 'light', 'thermostat') - payload: State payload + device_technology: Technology identifier (e.g., 'zigbee2mqtt') + payload: State payload (vendor-specific format) redis_channel: Redis channel for UI updates """ + # Transform vendor-specific payload to abstract format + abstract_payload = transform_vendor_to_abstract(device_type, device_technology, payload) + # Validate state payload based on device type try: if device_type == "light": - LightState.model_validate(payload) + LightState.model_validate(abstract_payload) elif device_type == "thermostat": # Validate thermostat state: mode, target, current (required), battery, window_open - ThermostatState.model_validate(payload) + ThermostatState.model_validate(abstract_payload) except ValidationError as e: logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}") return # Publish to abstract state topic (retained) abstract_topic = f"home/{device_type}/{device_id}/state" - abstract_message = json.dumps(payload) + abstract_message = json.dumps(abstract_payload) logger.info(f"← abstract STATE {device_id}: {abstract_topic} → {abstract_message}") await mqtt_client.publish(abstract_topic, abstract_message, qos=1, retain=True) @@ -210,7 +224,7 @@ async def handle_vendor_state( ui_update = { "type": "state", "device_id": device_id, - "payload": payload, + "payload": abstract_payload, "ts": datetime.now(timezone.utc).isoformat() } redis_message = json.dumps(ui_update) @@ -297,8 +311,9 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N if device_id in devices: device = devices[device_id] vendor_topic = device["topics"]["set"] + device_technology = device.get("technology", "unknown") await handle_abstract_set( - client, device_id, device_type, vendor_topic, payload + client, device_id, device_type, device_technology, vendor_topic, payload ) # Check if this is a vendor STATE message @@ -306,8 +321,10 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N # 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"], payload, redis_channel + client, redis_client, device_id, device["type"], + device_technology, payload, redis_channel ) break diff --git a/apps/abstraction/transformation.py b/apps/abstraction/transformation.py new file mode 100644 index 0000000..5a0ac48 --- /dev/null +++ b/apps/abstraction/transformation.py @@ -0,0 +1,88 @@ +"""Payload transformation functions for vendor-specific device communication. + +This module provides transformation functions to translate between abstract +home protocol payloads and vendor-specific device payloads. +""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def transform_abstract_to_vendor( + device_type: str, + device_technology: str, + abstract_payload: dict[str, Any] +) -> dict[str, Any]: + """Transform abstract payload to vendor-specific payload for SET commands. + + This function allows technology-specific transformations when sending commands + to devices. For example, different vendors might use different field names or + value formats for the same abstract concept. + + Args: + device_type: Type of device (e.g., 'light', 'thermostat') + device_technology: Technology identifier (e.g., 'zigbee2mqtt', 'tasmota') + abstract_payload: Abstract payload following home protocol + + Returns: + Vendor-specific payload for the device + + Example: + Input: {'power': 'on', 'brightness': 75} + Output: {'state': 'ON', 'brightness': 75} # hypothetical vendor format + """ + logger.debug( + f"transform_abstract_to_vendor IN: type={device_type}, tech={device_technology}, " + f"payload={abstract_payload}" + ) + + # TODO: Implement technology-specific transformations here + # Currently pass-through: return payload unchanged + vendor_payload = abstract_payload + + logger.debug( + f"transform_abstract_to_vendor OUT: type={device_type}, tech={device_technology}, " + f"payload={vendor_payload}" + ) + return vendor_payload + + +def transform_vendor_to_abstract( + device_type: str, + device_technology: str, + vendor_payload: dict[str, Any] +) -> dict[str, Any]: + """Transform vendor-specific payload to abstract payload for STATE messages. + + This function allows technology-specific transformations when receiving state + updates from devices. For example, different vendors might report state using + different field names or value formats. + + Args: + device_type: Type of device (e.g., 'light', 'thermostat') + device_technology: Technology identifier (e.g., 'zigbee2mqtt', 'tasmota') + vendor_payload: Vendor-specific payload from the device + + Returns: + Abstract payload following home protocol + + Example: + Input: {'state': 'ON', 'brightness': 75} # hypothetical vendor format + Output: {'power': 'on', 'brightness': 75} + """ + logger.debug( + f"transform_vendor_to_abstract IN: type={device_type}, tech={device_technology}, " + f"payload={vendor_payload}" + ) + + # TODO: Implement technology-specific transformations here + # Currently pass-through: return payload unchanged + abstract_payload = vendor_payload + + logger.debug( + f"transform_vendor_to_abstract OUT: type={device_type}, tech={device_technology}, " + f"payload={abstract_payload}" + ) + return abstract_payload diff --git a/config/devices.yaml b/config/devices.yaml index bacb9df..8fcb3de 100644 --- a/config/devices.yaml +++ b/config/devices.yaml @@ -16,7 +16,7 @@ devices: - device_id: test_lampe_1 type: light cap_version: "light@1.2.0" - technology: zigbee2mqtt + technology: simulator features: power: true brightness: true @@ -26,7 +26,7 @@ devices: - device_id: test_lampe_2 type: light cap_version: "light@1.2.0" - technology: zigbee2mqtt + technology: simulator features: power: true topics: @@ -35,7 +35,7 @@ devices: - device_id: test_lampe_3 type: light cap_version: "light@1.2.0" - technology: zigbee2mqtt + technology: simulator features: power: true brightness: true @@ -45,7 +45,7 @@ devices: - device_id: test_thermo_1 type: thermostat cap_version: "thermostat@2.0.0" - technology: zigbee2mqtt + technology: simulator features: mode: false target: true