add type relay

This commit is contained in:
2025-11-11 10:10:22 +01:00
parent 86d1933c1f
commit 97ea853483
8 changed files with 120 additions and 5 deletions

View File

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

View File

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

View File

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

View File

@@ -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 @@
</div>
{% endif %}
{% elif device.type == "relay" %}
<div class="device-state">
<span class="state-label">Status:</span>
<span class="state-value off" id="state-{{ device.device_id }}">off</span>
</div>
<div class="controls">
<button
class="toggle-button off"
id="toggle-{{ device.device_id }}"
onclick="toggleDevice('{{ device.device_id }}')">
Einschalten
</button>
</div>
{% elif device.type == "thermostat" %}
<div class="thermostat-display">
<div class="temp-reading">
@@ -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
}

View File

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

View File

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

View File

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

View File

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