diff --git a/apps/abstraction/main.py b/apps/abstraction/main.py index 8d86b5b..4e86f7d 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, ContactState, TempHumidityState, RelayState, ThreePhasePowerState +from packages.home_capabilities import LightState, ThermostatState, ContactState, TempHumidityState, RelayState, ThreePhasePowerState, SwitchState from apps.abstraction.transformation import ( transform_abstract_to_vendor, transform_vendor_to_abstract @@ -174,6 +174,10 @@ async def handle_abstract_set( # 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 + elif device_type == "switch": + # Switches are read-only - SET commands should not occur + logger.warning(f"Switch {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 @@ -227,6 +231,9 @@ async def handle_vendor_state( elif device_type == "three_phase_powermeter": # Validate three-phase powermeter state ThreePhasePowerState.model_validate(abstract_payload) + elif device_type == "switch": + # Validate switch state + SwitchState.model_validate(abstract_payload) except ValidationError as e: logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}") return diff --git a/apps/abstraction/vendors/zigbee2mqtt.py b/apps/abstraction/vendors/zigbee2mqtt.py index 5ebc7ed..b86d92b 100644 --- a/apps/abstraction/vendors/zigbee2mqtt.py +++ b/apps/abstraction/vendors/zigbee2mqtt.py @@ -161,6 +161,24 @@ def transform_temp_humidity_sensor_to_abstract(payload: str) -> dict[str, Any]: return payload +def transform_switch_to_vendor(payload: dict[str, Any]) -> str: + """Transform abstract switch payload to zigbee2mqtt format. + + Switches are read-only, so this should not be called for SET commands. + """ + logger.warning("Switches are read-only - SET commands should not be used") + return json.dumps(payload) + + +def transform_switch_to_abstract(payload: str) -> dict[str, Any]: + """Transform zigbee2mqtt switch payload to abstract format. + + Passthrough - zigbee2mqtt provides action, battery, linkquality directly. + """ + payload = json.loads(payload) + return payload + + def transform_relay_to_vendor(payload: dict[str, Any]) -> str: """Transform abstract relay payload to zigbee2mqtt format. @@ -204,6 +222,8 @@ HANDLERS = { ("temp_humidity_sensor", "to_abstract"): transform_temp_humidity_sensor_to_abstract, ("temp_humidity", "to_vendor"): transform_temp_humidity_sensor_to_vendor, ("temp_humidity", "to_abstract"): transform_temp_humidity_sensor_to_abstract, + ("switch", "to_vendor"): transform_switch_to_vendor, + ("switch", "to_abstract"): transform_switch_to_abstract, ("relay", "to_vendor"): transform_relay_to_vendor, ("relay", "to_abstract"): transform_relay_to_abstract, } diff --git a/packages/home_capabilities/__init__.py b/packages/home_capabilities/__init__.py index ede6c72..cf3707e 100644 --- a/packages/home_capabilities/__init__.py +++ b/packages/home_capabilities/__init__.py @@ -8,6 +8,8 @@ from packages.home_capabilities.contact_sensor import CAP_VERSION as CONTACT_SEN from packages.home_capabilities.contact_sensor import ContactState from packages.home_capabilities.temp_humidity_sensor import CAP_VERSION as TEMP_HUMIDITY_SENSOR_VERSION from packages.home_capabilities.temp_humidity_sensor import TempHumidityState +from packages.home_capabilities.switch import CAP_VERSION as SWITCH_VERSION +from packages.home_capabilities.switch import SwitchState from packages.home_capabilities.relay import CAP_VERSION as RELAY_VERSION from packages.home_capabilities.relay import RelayState from packages.home_capabilities.three_phase_powermeter import CAP_VERSION as THREE_PHASE_POWERMETER_VERSION @@ -42,6 +44,8 @@ __all__ = [ "CONTACT_SENSOR_VERSION", "TempHumidityState", "TEMP_HUMIDITY_SENSOR_VERSION", + "SwitchState", + "SWITCH_VERSION", "RelayState", "RELAY_VERSION", "DeviceTile", diff --git a/packages/home_capabilities/switch.py b/packages/home_capabilities/switch.py new file mode 100644 index 0000000..2f51f8d --- /dev/null +++ b/packages/home_capabilities/switch.py @@ -0,0 +1,69 @@ +"""Switch Capability - Wireless Button/Switch (read-only). + +This module defines the SwitchState model for wireless switches/buttons. +These devices report action events (e.g., button presses) and are read-only devices. + +Capability Version: switch@1.0.0 +""" + +from datetime import datetime +from typing import Annotated + +from pydantic import BaseModel, Field + + +# Capability metadata +CAP_VERSION = "switch@1.0.0" +DISPLAY_NAME = "Switch" + + +class SwitchState(BaseModel): + """State model for wireless switches/buttons. + + Wireless switches are read-only devices that report button actions such as + single press, double press, long press, etc. They typically also report + battery level and signal quality. + + Attributes: + action: Action type (e.g., "single", "double", "long", "hold", etc.) + battery: Battery level percentage (0-100), optional + linkquality: MQTT link quality indicator, optional + voltage: Battery voltage in mV, optional + ts: Timestamp of the action event, optional + + Examples: + >>> SwitchState(action="single") + SwitchState(action='single', battery=None, ...) + + >>> SwitchState(action="double", battery=95, linkquality=87) + SwitchState(action='double', battery=95, linkquality=87, ...) + """ + + action: str = Field( + ..., + description="Action type: 'single', 'double', 'long', 'hold', etc." + ) + + 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)" + ) + + voltage: int | None = Field( + None, + description="Battery voltage in millivolts" + ) + + ts: datetime | None = Field( + None, + description="Timestamp of the action event" + ) + + +# Public API +__all__ = ["SwitchState", "CAP_VERSION", "DISPLAY_NAME"]