transformation added

This commit is contained in:
2025-11-09 12:59:15 +01:00
parent 8fd0921a08
commit 1eff8a2044
3 changed files with 121 additions and 16 deletions

View File

@@ -16,10 +16,14 @@ 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
from apps.abstraction.transformation import (
transform_abstract_to_vendor,
transform_vendor_to_abstract
)
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -127,6 +131,7 @@ async def handle_abstract_set(
mqtt_client: Client, mqtt_client: Client,
device_id: str, device_id: str,
device_type: str, device_type: str,
device_technology: str,
vendor_topic: str, vendor_topic: str,
payload: dict[str, Any] payload: dict[str, Any]
) -> None: ) -> None:
@@ -136,21 +141,22 @@ async def handle_abstract_set(
mqtt_client: MQTT client instance mqtt_client: MQTT client instance
device_id: Device identifier device_id: Device identifier
device_type: Device type (e.g., 'light', 'thermostat') device_type: Device type (e.g., 'light', 'thermostat')
device_technology: Technology identifier (e.g., 'zigbee2mqtt')
vendor_topic: Vendor-specific SET topic vendor_topic: Vendor-specific SET topic
payload: Message payload payload: Message payload
""" """
# Extract actual payload (remove type wrapper if present) # 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 # Validate payload based on device type
try: try:
if device_type == "light": if device_type == "light":
# Validate light SET payload (power and/or brightness) # Validate light SET payload (power and/or brightness)
LightState.model_validate(vendor_payload) LightState.model_validate(abstract_payload)
elif device_type == "thermostat": elif device_type == "thermostat":
# For thermostat SET: only allow mode and target fields # For thermostat SET: only allow mode and target fields
allowed_set_fields = {"mode", "target"} 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: if invalid_fields:
logger.warning( logger.warning(
f"Thermostat SET {device_id} contains invalid fields {invalid_fields}, " f"Thermostat SET {device_id} contains invalid fields {invalid_fields}, "
@@ -159,11 +165,14 @@ async def handle_abstract_set(
return return
# Validate against ThermostatState (current/battery/window_open are optional) # Validate against ThermostatState (current/battery/window_open are optional)
ThermostatState.model_validate(vendor_payload) ThermostatState.model_validate(abstract_payload)
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
# 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) 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}")
@@ -175,6 +184,7 @@ async def handle_vendor_state(
redis_client: aioredis.Redis, redis_client: aioredis.Redis,
device_id: str, device_id: str,
device_type: str, device_type: str,
device_technology: str,
payload: dict[str, Any], payload: dict[str, Any],
redis_channel: str = "ui:updates" redis_channel: str = "ui:updates"
) -> None: ) -> None:
@@ -185,23 +195,27 @@ async def handle_vendor_state(
redis_client: Redis client instance redis_client: Redis client instance
device_id: Device identifier device_id: Device identifier
device_type: Device type (e.g., 'light', 'thermostat') 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 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 # Validate state payload based on device type
try: try:
if device_type == "light": if device_type == "light":
LightState.model_validate(payload) LightState.model_validate(abstract_payload)
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(payload) ThermostatState.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
# 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/{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}") logger.info(f"← abstract STATE {device_id}: {abstract_topic}{abstract_message}")
await mqtt_client.publish(abstract_topic, abstract_message, qos=1, retain=True) await mqtt_client.publish(abstract_topic, abstract_message, qos=1, retain=True)
@@ -210,7 +224,7 @@ async def handle_vendor_state(
ui_update = { ui_update = {
"type": "state", "type": "state",
"device_id": device_id, "device_id": device_id,
"payload": payload, "payload": abstract_payload,
"ts": datetime.now(timezone.utc).isoformat() "ts": datetime.now(timezone.utc).isoformat()
} }
redis_message = json.dumps(ui_update) 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: if device_id in devices:
device = devices[device_id] device = devices[device_id]
vendor_topic = device["topics"]["set"] vendor_topic = device["topics"]["set"]
device_technology = device.get("technology", "unknown")
await handle_abstract_set( 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 # 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 # Find device by vendor state topic
for device_id, device in devices.items(): for device_id, device in devices.items():
if topic == device["topics"]["state"]: if topic == device["topics"]["state"]:
device_technology = device.get("technology", "unknown")
await handle_vendor_state( 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 break

View File

@@ -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

View File

@@ -16,7 +16,7 @@ devices:
- device_id: test_lampe_1 - device_id: test_lampe_1
type: light type: light
cap_version: "light@1.2.0" cap_version: "light@1.2.0"
technology: zigbee2mqtt technology: simulator
features: features:
power: true power: true
brightness: true brightness: true
@@ -26,7 +26,7 @@ devices:
- device_id: test_lampe_2 - device_id: test_lampe_2
type: light type: light
cap_version: "light@1.2.0" cap_version: "light@1.2.0"
technology: zigbee2mqtt technology: simulator
features: features:
power: true power: true
topics: topics:
@@ -35,7 +35,7 @@ devices:
- device_id: test_lampe_3 - device_id: test_lampe_3
type: light type: light
cap_version: "light@1.2.0" cap_version: "light@1.2.0"
technology: zigbee2mqtt technology: simulator
features: features:
power: true power: true
brightness: true brightness: true
@@ -45,7 +45,7 @@ devices:
- device_id: test_thermo_1 - device_id: test_thermo_1
type: thermostat type: thermostat
cap_version: "thermostat@2.0.0" cap_version: "thermostat@2.0.0"
technology: zigbee2mqtt technology: simulator
features: features:
mode: false mode: false
target: true target: true