transformation added
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
88
apps/abstraction/transformation.py
Normal file
88
apps/abstraction/transformation.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user