From 97ea85348347ad0cd1a0a7664e2109b3f7e88f2d Mon Sep 17 00:00:00 2001 From: Wolfgang Hottgenroth Date: Tue, 11 Nov 2025 10:10:22 +0100 Subject: [PATCH] add type relay --- apps/abstraction/main.py | 7 ++++- apps/abstraction/transformation.py | 38 ++++++++++++++++++++++++++ apps/api/main.py | 15 ++++++++-- apps/ui/templates/dashboard.html | 24 ++++++++++++++-- config/devices.yaml | 12 ++++++++ config/layout.yaml | 4 +++ packages/home_capabilities/__init__.py | 4 +++ packages/home_capabilities/relay.py | 21 ++++++++++++++ 8 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 packages/home_capabilities/relay.py diff --git a/apps/abstraction/main.py b/apps/abstraction/main.py index 54616df..b79aefc 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 +from packages.home_capabilities import LightState, ThermostatState, ContactState, TempHumidityState, RelayState from apps.abstraction.transformation import ( transform_abstract_to_vendor, transform_vendor_to_abstract @@ -154,6 +154,9 @@ async def handle_abstract_set( if device_type == "light": # Validate light SET payload (power and/or brightness) LightState.model_validate(abstract_payload) + elif device_type == "relay": + # Validate relay SET payload (power only) + RelayState.model_validate(abstract_payload) elif device_type == "thermostat": # For thermostat SET: only allow mode and target fields allowed_set_fields = {"mode", "target"} @@ -216,6 +219,8 @@ async def handle_vendor_state( try: if device_type == "light": LightState.model_validate(abstract_payload) + elif device_type == "relay": + RelayState.model_validate(abstract_payload) elif device_type == "thermostat": # Validate thermostat state: mode, target, current (required), battery, window_open ThermostatState.model_validate(abstract_payload) diff --git a/apps/abstraction/transformation.py b/apps/abstraction/transformation.py index 3c56f73..260d8ea 100644 --- a/apps/abstraction/transformation.py +++ b/apps/abstraction/transformation.py @@ -307,6 +307,40 @@ def _transform_temp_humidity_sensor_max_to_abstract(payload: dict[str, Any]) -> return payload +# ============================================================================ +# HANDLER FUNCTIONS: relay - zigbee2mqtt technology +# ============================================================================ + +def _transform_relay_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]: + """Transform abstract relay payload to zigbee2mqtt format. + + Relay only has power on/off, same transformation as light. + - power: 'on'/'off' -> state: 'ON'/'OFF' + """ + vendor_payload = payload.copy() + + if "power" in vendor_payload: + power_value = vendor_payload.pop("power") + vendor_payload["state"] = power_value.upper() if isinstance(power_value, str) else power_value + + return vendor_payload + + +def _transform_relay_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]: + """Transform zigbee2mqtt relay payload to abstract format. + + Relay only has power on/off, same transformation as light. + - state: 'ON'/'OFF' -> power: 'on'/'off' + """ + abstract_payload = payload.copy() + + if "state" in abstract_payload: + state_value = abstract_payload.pop("state") + abstract_payload["power"] = state_value.lower() if isinstance(state_value, str) else state_value + + return abstract_payload + + # ============================================================================ # HANDLER FUNCTIONS: max technology (Homegear MAX!) # ============================================================================ @@ -420,6 +454,10 @@ TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = { ("temp_humidity", "zigbee2mqtt", "to_abstract"): _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract, ("temp_humidity", "max", "to_vendor"): _transform_temp_humidity_sensor_max_to_vendor, ("temp_humidity", "max", "to_abstract"): _transform_temp_humidity_sensor_max_to_abstract, + + # Relay transformations + ("relay", "zigbee2mqtt", "to_vendor"): _transform_relay_zigbee2mqtt_to_vendor, + ("relay", "zigbee2mqtt", "to_abstract"): _transform_relay_zigbee2mqtt_to_abstract, } diff --git a/apps/api/main.py b/apps/api/main.py index 461c8a7..87c159a 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -20,10 +20,12 @@ from packages.home_capabilities import ( THERMOSTAT_VERSION, CONTACT_SENSOR_VERSION, TEMP_HUMIDITY_SENSOR_VERSION, + RELAY_VERSION, LightState, ThermostatState, ContactState, - TempHumidityState + TempHumidityState, + RelayState ) logger = logging.getLogger(__name__) @@ -148,7 +150,8 @@ async def spec() -> dict[str, dict[str, str]]: "light": LIGHT_VERSION, "thermostat": THERMOSTAT_VERSION, "contact": CONTACT_SENSOR_VERSION, - "temp_humidity": TEMP_HUMIDITY_SENSOR_VERSION + "temp_humidity": TEMP_HUMIDITY_SENSOR_VERSION, + "relay": RELAY_VERSION } } @@ -358,6 +361,14 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Invalid payload for light: {e}" ) + elif request.type == "relay": + try: + RelayState(**request.payload) + except ValidationError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid payload for relay: {e}" + ) elif request.type == "thermostat": try: # For thermostat SET: only allow mode and target diff --git a/apps/ui/templates/dashboard.html b/apps/ui/templates/dashboard.html index a9f4c1c..0a76065 100644 --- a/apps/ui/templates/dashboard.html +++ b/apps/ui/templates/dashboard.html @@ -571,6 +571,8 @@ {% if device.type == "light" %} Light {% if device.features.brightness %}• Dimmbar{% endif %} + {% elif device.type == "relay" %} + Relay {% elif device.type == "thermostat" %} Thermostat {% elif device.type == "contact" or device.type == "contact_sensor" %} @@ -621,6 +623,21 @@ {% endif %} + {% elif device.type == "relay" %} +
+ Status: + off +
+ +
+ +
+ {% elif device.type == "thermostat" %}
@@ -807,11 +824,13 @@ let eventSource = null; let currentState = {}; let thermostatTargets = {}; + let deviceTypes = {}; // Initialize device states {% for room in rooms %} {% for device in room.devices %} - {% if device.type == "light" %} + deviceTypes['{{ device.device_id }}'] = '{{ device.type }}'; + {% if device.type == "light" or device.type == "relay" %} currentState['{{ device.device_id }}'] = 'off'; {% elif device.type == "thermostat" %} thermostatTargets['{{ device.device_id }}'] = 21.0; @@ -822,6 +841,7 @@ // Toggle device state async function toggleDevice(deviceId) { const newState = currentState[deviceId] === 'on' ? 'off' : 'on'; + const deviceType = deviceTypes[deviceId] || 'light'; try { const response = await fetch(api(`/devices/${deviceId}/set`), { @@ -830,7 +850,7 @@ 'Content-Type': 'application/json' }, body: JSON.stringify({ - type: 'light', + type: deviceType, payload: { power: newState } diff --git a/config/devices.yaml b/config/devices.yaml index f0476d5..9bf9a99 100644 --- a/config/devices.yaml +++ b/config/devices.yaml @@ -719,5 +719,17 @@ devices: topics: state: zigbee2mqtt/0x00158d0009421422 features: {} +- device_id: test_relay_1 + type: relay + name: Test Relay 1 + cap_version: relay@1.0.0 + technology: zigbee2mqtt + topics: + state: zigbee2mqtt/0xtest_relay_1 + set: zigbee2mqtt/0xtest_relay_1/set + features: + power: true + + diff --git a/config/layout.yaml b/config/layout.yaml index 1599af1..90e91b2 100644 --- a/config/layout.yaml +++ b/config/layout.yaml @@ -63,6 +63,10 @@ rooms: title: Kontakt Straße links icon: 🪟 rank: 97 + - device_id: test_relay_1 + title: Test Relay 1 + icon: ⚡ + rank: 98 - name: Wohnzimmer devices: - device_id: lampe_naehtischchen_wohnzimmer diff --git a/packages/home_capabilities/__init__.py b/packages/home_capabilities/__init__.py index 069d54f..5a30bc3 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.relay import CAP_VERSION as RELAY_VERSION +from packages.home_capabilities.relay import RelayState from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout __all__ = [ @@ -19,6 +21,8 @@ __all__ = [ "CONTACT_SENSOR_VERSION", "TempHumidityState", "TEMP_HUMIDITY_SENSOR_VERSION", + "RelayState", + "RELAY_VERSION", "DeviceTile", "Room", "UiLayout", diff --git a/packages/home_capabilities/relay.py b/packages/home_capabilities/relay.py new file mode 100644 index 0000000..f140691 --- /dev/null +++ b/packages/home_capabilities/relay.py @@ -0,0 +1,21 @@ +""" +Relay capability model. +A relay is essentially a simple on/off switch, like a light with only power control. +""" + +from pydantic import BaseModel, Field +from typing import Literal + +# Capability version +CAP_VERSION = "relay@1.0.0" +DISPLAY_NAME = "Relay" + + +class RelayState(BaseModel): + """State model for relay devices (on/off only)""" + power: Literal["on", "off"] = Field(..., description="Power state: on or off") + + +class RelaySetPayload(BaseModel): + """Payload for setting relay state""" + power: Literal["on", "off"] = Field(..., description="Desired power state: on or off")