Compare commits

...

22 Commits

Author SHA1 Message Date
3e0a1b49ab hottis wifi relay 3
All checks were successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
2025-12-10 21:21:29 +01:00
befdc8a46c hottis wifi relay 2
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-10 21:15:49 +01:00
da16c59238 hottis wifi relay
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-10 21:13:00 +01:00
5f3185894d licht keller flur 3
All checks were successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
2025-12-10 20:58:45 +01:00
fb828c9a2c licht keller flur 2
All checks were successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
2025-12-10 20:50:34 +01:00
064ee6bbed licht keller flur
All checks were successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
2025-12-10 20:47:40 +01:00
d39bcfce26 excluded 2
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-09 17:38:46 +01:00
1fd275186a excluded
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-09 17:28:32 +01:00
da370c9050 room id
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-09 17:13:51 +01:00
08294ca294 started 4
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-09 13:34:41 +01:00
e5eb368dca started 3 2025-12-09 13:00:47 +01:00
169d0505cb started 2
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-09 12:53:53 +01:00
02a2be92d5 started
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-09 12:34:05 +01:00
bcfc967460 Hottis PV Modbus sensor 2
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-09 12:01:47 +01:00
bd1f3bc8c9 Hottis PV Modbus sensor
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-09 11:57:49 +01:00
f9df70cf68 Hottis PV Modbus transformation
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-09 11:17:02 +01:00
5364b855aa add vendor hottis wago modbus 3
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-08 16:57:57 +01:00
3a1841a8a9 add vendor hottis wago modbus
Some checks failed
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline failed
ci/woodpecker/tag/namespace Pipeline failed
ci/woodpecker/tag/build/3 Pipeline failed
ci/woodpecker/tag/build/2 Pipeline failed
ci/woodpecker/tag/build/1 Pipeline failed
ci/woodpecker/tag/build/7 Pipeline failed
ci/woodpecker/tag/config unknown status
ci/woodpecker/tag/deploy/5 unknown status
ci/woodpecker/tag/deploy/1 unknown status
ci/woodpecker/tag/deploy/4 unknown status
ci/woodpecker/tag/deploy/3 unknown status
ci/woodpecker/tag/deploy/2 unknown status
ci/woodpecker/tag/deploy/6 unknown status
ci/woodpecker/tag/ingress unknown status
2025-12-08 16:57:18 +01:00
9629850ebb vendor transformations separated 2
All checks were successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-08 16:48:23 +01:00
000d32b78f vendor transformations separated
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-08 16:43:17 +01:00
24b2f70caf better stopping
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-08 16:20:25 +01:00
d3c1ec404a seems to work, client_id with uuid
All checks were successful
ci/woodpecker/tag/build/5 Pipeline was successful
ci/woodpecker/tag/build/6 Pipeline was successful
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/build/4 Pipeline was successful
ci/woodpecker/tag/build/1 Pipeline was successful
ci/woodpecker/tag/config Pipeline was successful
ci/woodpecker/tag/build/3 Pipeline was successful
ci/woodpecker/tag/build/7 Pipeline was successful
ci/woodpecker/tag/build/2 Pipeline was successful
ci/woodpecker/tag/deploy/3 Pipeline was successful
ci/woodpecker/tag/deploy/1 Pipeline was successful
ci/woodpecker/tag/deploy/4 Pipeline was successful
ci/woodpecker/tag/deploy/5 Pipeline was successful
ci/woodpecker/tag/deploy/2 Pipeline was successful
ci/woodpecker/tag/deploy/6 Pipeline was successful
ci/woodpecker/tag/ingress Pipeline was successful
2025-12-08 15:42:53 +01:00
18 changed files with 1144 additions and 662 deletions

View File

@@ -4,620 +4,50 @@ This module implements a registry-pattern for vendor-specific transformations:
- Each (device_type, technology, direction) tuple maps to a specific handler function - Each (device_type, technology, direction) tuple maps to a specific handler function
- Handlers transform payloads between abstract and vendor-specific formats - Handlers transform payloads between abstract and vendor-specific formats
- Unknown combinations fall back to pass-through (no transformation) - Unknown combinations fall back to pass-through (no transformation)
Vendor-specific implementations are in the vendors/ subdirectory.
""" """
import logging import logging
import json
from typing import Any, Callable from typing import Any, Callable
from apps.abstraction.vendors import (
simulator,
zigbee2mqtt,
max,
shelly,
tasmota,
hottis_pv_modbus,
hottis_wago_modbus,
hottis_wifi_relay,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ============================================================================
# HANDLER FUNCTIONS: simulator technology
# ============================================================================
def _transform_light_simulator_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract light payload to simulator format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return json.dumps(payload)
def _transform_light_simulator_to_abstract(payload: str) -> dict[str, Any]:
"""Transform simulator light payload to abstract format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
payload = json.loads(payload)
return payload
def _transform_thermostat_simulator_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract thermostat payload to simulator format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return json.dumps(payload)
def _transform_thermostat_simulator_to_abstract(payload: str) -> dict[str, Any]:
"""Transform simulator thermostat payload to abstract format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
payload = json.loads(payload)
return payload
# ============================================================================
# HANDLER FUNCTIONS: zigbee2mqtt technology
# ============================================================================
def _transform_light_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract light payload to zigbee2mqtt format.
Transformations:
- power: 'on'/'off' -> state: 'ON'/'OFF'
- brightness: 0-100 -> brightness: 0-254
Example:
- Abstract: {'power': 'on', 'brightness': 100}
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
"""
vendor_payload = payload.copy()
# Transform power -> state with uppercase values
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
# Transform brightness: 0-100 (%) -> 0-254 (zigbee2mqtt range)
if "brightness" in vendor_payload:
abstract_brightness = vendor_payload["brightness"]
if isinstance(abstract_brightness, (int, float)):
# Convert percentage (0-100) to zigbee2mqtt range (0-254)
vendor_payload["brightness"] = round(abstract_brightness * 254 / 100)
return json.dumps(vendor_payload)
def _transform_light_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
"""Transform zigbee2mqtt light payload to abstract format.
Transformations:
- state: 'ON'/'OFF' -> power: 'on'/'off'
- brightness: 0-254 -> brightness: 0-100
Example:
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
- Abstract: {'power': 'on', 'brightness': 100}
"""
abstract_payload = json.loads(payload)
# Transform state -> power with lowercase values
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
# Transform brightness: 0-254 (zigbee2mqtt range) -> 0-100 (%)
if "brightness" in abstract_payload:
vendor_brightness = abstract_payload["brightness"]
if isinstance(vendor_brightness, (int, float)):
# Convert zigbee2mqtt range (0-254) to percentage (0-100)
abstract_payload["brightness"] = round(vendor_brightness * 100 / 254)
return abstract_payload
def _transform_thermostat_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract thermostat payload to zigbee2mqtt format.
Transformations:
- target -> current_heating_setpoint (as string)
- mode is ignored (zigbee2mqtt thermostats use system_mode in state only)
Example:
- Abstract: {'target': 22.0}
- zigbee2mqtt: {'current_heating_setpoint': '22.0'}
"""
vendor_payload = {}
if "target" in payload:
# zigbee2mqtt expects current_heating_setpoint as string
vendor_payload["current_heating_setpoint"] = str(payload["target"])
return json.dumps(vendor_payload)
def _transform_thermostat_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
"""Transform zigbee2mqtt thermostat payload to abstract format.
Transformations:
- current_heating_setpoint -> target (as float)
- local_temperature -> current (as float)
- system_mode -> mode
Example:
- zigbee2mqtt: {'current_heating_setpoint': 15, 'local_temperature': 23, 'system_mode': 'heat'}
- Abstract: {'target': 15.0, 'current': 23.0, 'mode': 'heat'}
"""
payload = json.loads(payload)
abstract_payload = {}
# Extract target temperature
if "current_heating_setpoint" in payload:
setpoint = payload["current_heating_setpoint"]
abstract_payload["target"] = float(setpoint)
# Extract current temperature
if "local_temperature" in payload:
current = payload["local_temperature"]
abstract_payload["current"] = float(current)
# Extract mode
if "system_mode" in payload:
abstract_payload["mode"] = payload["system_mode"]
return abstract_payload
# ============================================================================
# HANDLER FUNCTIONS: contact_sensor - zigbee2mqtt technology
# ============================================================================
def _transform_contact_sensor_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract contact sensor payload to zigbee2mqtt format.
Contact sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return json.dumps(payload)
def _transform_contact_sensor_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
"""Transform zigbee2mqtt contact sensor payload to abstract format.
Transformations:
- contact: bool -> "open" | "closed"
- zigbee2mqtt semantics: False = OPEN, True = CLOSED (inverted!)
- battery: pass through (already 0-100)
- linkquality: pass through
- device_temperature: pass through (if present)
- voltage: pass through (if present)
Example:
- zigbee2mqtt: {"contact": false, "battery": 100, "linkquality": 87}
- Abstract: {"contact": "open", "battery": 100, "linkquality": 87}
"""
payload = json.loads(payload)
abstract_payload = {}
# Transform contact state (inverted logic!)
if "contact" in payload:
contact_bool = payload["contact"]
# zigbee2mqtt: False = OPEN, True = CLOSED
abstract_payload["contact"] = "closed" if contact_bool else "open"
# Pass through optional fields
if "battery" in payload:
abstract_payload["battery"] = payload["battery"]
if "linkquality" in payload:
abstract_payload["linkquality"] = payload["linkquality"]
if "device_temperature" in payload:
abstract_payload["device_temperature"] = payload["device_temperature"]
if "voltage" in payload:
abstract_payload["voltage"] = payload["voltage"]
return abstract_payload
# ============================================================================
# HANDLER FUNCTIONS: contact_sensor - max technology (Homegear MAX!)
# ============================================================================
def _transform_contact_sensor_max_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract contact sensor payload to MAX! format.
Contact sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return json.dumps(payload)
def _transform_contact_sensor_max_to_abstract(payload: str) -> dict[str, Any]:
"""Transform MAX! (Homegear) contact sensor payload to abstract format.
MAX! sends "true"/"false" (string or bool) on STATE topic.
Transformations:
- "true" or True -> "open" (window/door open)
- "false" or False -> "closed" (window/door closed)
Example:
- MAX!: "true" or True
- Abstract: {"contact": "open"}
"""
try:
contact_value = payload.strip().lower() == "true"
# MAX! semantics: True = OPEN, False = CLOSED
return {
"contact": "open" if contact_value else "closed"
}
except (ValueError, TypeError) as e:
logger.error(f"MAX! contact sensor failed to parse: {payload}, error: {e}")
return {
"contact": "closed" # Default to closed on error
}
# ============================================================================
# HANDLER FUNCTIONS: temp_humidity_sensor - zigbee2mqtt technology
# ============================================================================
def _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract temp/humidity sensor payload to zigbee2mqtt format.
Temp/humidity sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
return json.dumps(payload)
def _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
"""Transform zigbee2mqtt temp/humidity sensor payload to abstract format.
Passthrough - zigbee2mqtt provides temperature, humidity, battery, linkquality directly.
"""
payload = json.loads(payload)
return payload
# ============================================================================
# HANDLER FUNCTIONS: relay - zigbee2mqtt technology
# ============================================================================
def _transform_relay_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> str:
"""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 json.dumps(vendor_payload)
def _transform_relay_zigbee2mqtt_to_abstract(payload: str) -> 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'
"""
payload = json.loads(payload)
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: relay - shelly technology
# ============================================================================
def _transform_relay_shelly_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract relay payload to Shelly format.
Shelly expects plain text 'on' or 'off' (not JSON).
- power: 'on'/'off' -> 'on'/'off' (plain string)
Example:
- Abstract: {'power': 'on'}
- Shelly: 'on'
"""
power = payload.get("power", "off")
return power
def _transform_relay_shelly_to_abstract(payload: str) -> dict[str, Any]:
"""Transform Shelly relay payload to abstract format.
Shelly sends plain text 'on' or 'off' (not JSON).
- 'on'/'off' -> power: 'on'/'off'
Example:
- Shelly: 'on'
- Abstract: {'power': 'on'}
"""
return {"power": payload.strip()}
# ============================================================================
# HANDLER FUNCTIONS: relay - tasmota technology
# ============================================================================
def _transform_relay_tasmota_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract relay payload to Tasmota format.
Tasmota expects plain text 'on' or 'off' (not JSON).
- power: 'on'/'off' -> 'on'/'off' (plain string)
Example:
- Abstract: {'power': 'on'}
- Tasmota: 'on'
"""
power = payload.get("power", "off")
return power
def _transform_relay_tasmota_to_abstract(payload: str) -> dict[str, Any]:
"""Transform Tasmota relay payload to abstract format.
Tasmota sends plain text 'on' or 'off' (not JSON).
- 'on'/'off' -> power: 'on'/'off'
Example:
- Tasmota: 'ON'
- Abstract: {'power': 'on'}
"""
return {"power": payload.strip().lower()}
# ============================================================================
# HANDLER FUNCTIONS: relay - hottis_pv_modbus technology
# ============================================================================
def _transform_relay_hottis_pv_modbus_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract relay payload to Hottis Modbus format.
Hottis Modbus expects plain text 'on' or 'off' (not JSON).
- power: 'on'/'off' -> 'on'/'off' (plain string)
Example:
- Abstract: {'power': 'on'}
- Hottis Modbus: 'on'
"""
power = payload.get("power", "off")
return power
def _transform_relay_hottis_pv_modbus_to_abstract(payload: str) -> dict[str, Any]:
def _transform_relay_hottis_pv_modbus_to_abstract(payload: str) -> dict[str, Any]:
"""Transform Hottis Modbus relay payload to abstract format.
Hottis Modbus sends JSON like:
{"status": "Ok", "timestamp": "...", "state": false, "cnt": 528}
We only care about the 'state' field:
- state: true -> power: 'on'
- state: false -> power: 'off'
"""
data = json.loads(payload)
state = data.get("state", False)
power = "on" if bool(state) else "off"
return {"power": payload.strip()}
# ============================================================================
# HANDLER FUNCTIONS: three_phase_powermeter - hottis_pv_modbus technology
# ============================================================================
def _transform_three_phase_powermeter_hottis_pv_modbus_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract three_phase_powermeter payload to hottis_pv_modbus format.
energy: float = Field(..., description="Total energy in kWh")
total_power: float = Field(..., description="Total power in W")
phase1_power: float = Field(..., description="Power for phase 1 in W")
phase2_power: float = Field(..., description="Power for phase 2 in W")
phase3_power: float = Field(..., description="Power for phase 3 in W")
phase1_voltage: float = Field(..., description="Voltage for phase 1 in V")
phase2_voltage: float = Field(..., description="Voltage for phase 2 in V")
phase3_voltage: float = Field(..., description="Voltage for phase 3 in V")
phase1_current: float = Field(..., description="Current for phase 1 in A")
phase2_current: float = Field(..., description="Current for phase 2 in A")
phase3_current: float = Field(..., description="Current for phase 3 in A")
"""
vendor_payload = {
"energy": payload.get("energy", 0.0),
"total_power": payload.get("total_power", 0.0),
"phase1_power": payload.get("phase1_power", 0.0),
"phase2_power": payload.get("phase2_power", 0.0),
"phase3_power": payload.get("phase3_power", 0.0),
"phase1_voltage": payload.get("phase1_voltage", 0.0),
"phase2_voltage": payload.get("phase2_voltage", 0.0),
"phase3_voltage": payload.get("phase3_voltage", 0.0),
"phase1_current": payload.get("phase1_current", 0.0),
"phase2_current": payload.get("phase2_current", 0.0),
"phase3_current": payload.get("phase3_current", 0.0),
}
return json.dumps(vendor_payload)
def _transform_three_phase_powermeter_hottis_pv_modbus_to_abstract(payload: str) -> dict[str, Any]:
"""Transform hottis_pv_modbus three_phase_powermeter payload to abstract format.
Transformations:
- Map vendor field names to abstract field names
- totalImportEnergy -> energy
- powerL1/powerL2/powerL3 -> phase1_power/phase2_power/phase3_power
- voltageL1/voltageL2/voltageL3 -> phase1_voltage/phase2_voltage/phase3_voltage
- currentL1/currentL2/currentL3 -> phase1_current/phase2_current/phase3_current
- Sum of powerL1..3 -> total_power
"""
data = json.loads(payload)
# Helper to read numeric values uniformly as float
def _get_float(key: str, default: float = 0.0) -> float:
return float(data.get(key, default))
# Read all numeric values via helper for consistent error handling
phase1_power = _get_float("powerL1")
phase2_power = _get_float("powerL2")
phase3_power = _get_float("powerL3")
phase1_voltage = _get_float("voltageL1")
phase2_voltage = _get_float("voltageL2")
phase3_voltage = _get_float("voltageL3")
phase1_current = _get_float("currentL1")
phase2_current = _get_float("currentL2")
phase3_current = _get_float("currentL3")
energy = _get_float("totalImportEnergy")
abstract_payload = {
"energy": energy,
"total_power": phase1_power + phase2_power + phase3_power,
"phase1_power": phase1_power,
"phase2_power": phase2_power,
"phase3_power": phase3_power,
"phase1_voltage": phase1_voltage,
"phase2_voltage": phase2_voltage,
"phase3_voltage": phase3_voltage,
"phase1_current": phase1_current,
"phase2_current": phase2_current,
"phase3_current": phase3_current,
}
return abstract_payload
# ============================================================================
# HANDLER FUNCTIONS: max technology (Homegear MAX!)
# ============================================================================
def _transform_thermostat_max_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract thermostat payload to MAX! (Homegear) format.
MAX! expects only the integer temperature value (no JSON).
Transformations:
- Extract 'target' temperature from payload
- Convert float to integer (MAX! only accepts integers)
- Return as plain string value
Example:
- Abstract: {'mode': 'heat', 'target': 22.5}
- MAX!: "22"
Note: MAX! ignores mode - it's always in heating mode
"""
if "target" not in payload:
logger.warning(f"MAX! thermostat payload missing 'target': {payload}")
return "21" # Default fallback
target_temp = payload["target"]
# Convert to integer (MAX! protocol requirement)
if isinstance(target_temp, (int, float)):
int_temp = int(round(target_temp))
return str(int_temp)
logger.warning(f"MAX! invalid target temperature type: {type(target_temp)}, value: {target_temp}")
return "21"
def _transform_thermostat_max_to_abstract(payload: str) -> dict[str, Any]:
"""Transform MAX! (Homegear) thermostat payload to abstract format.
MAX! sends only the integer temperature value (no JSON).
Transformations:
- Parse plain string/int value
- Convert to float for abstract protocol
- Wrap in abstract payload structure with mode='heat'
Example:
- MAX!: "22" or 22
- Abstract: {'target': 22.0, 'mode': 'heat'}
Note: MAX! doesn't send current temperature via SET_TEMPERATURE topic
"""
# Handle both string and numeric input
target_temp = float(payload.strip())
return {
"target": target_temp,
"mode": "heat" # MAX! is always in heating mode
}
# ============================================================================ # ============================================================================
# REGISTRY: Maps (device_type, technology, direction) -> handler function # REGISTRY: Maps (device_type, technology, direction) -> handler function
# ============================================================================ # ============================================================================
TransformHandler = Callable[[dict[str, Any]], dict[str, Any]] TransformHandler = Callable[[Any], Any]
TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = { # Build registry from vendor modules
# Light transformations TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = {}
("light", "simulator", "to_vendor"): _transform_light_simulator_to_vendor,
("light", "simulator", "to_abstract"): _transform_light_simulator_to_abstract,
("light", "zigbee2mqtt", "to_vendor"): _transform_light_zigbee2mqtt_to_vendor,
("light", "zigbee2mqtt", "to_abstract"): _transform_light_zigbee2mqtt_to_abstract,
# Thermostat transformations
("thermostat", "simulator", "to_vendor"): _transform_thermostat_simulator_to_vendor,
("thermostat", "simulator", "to_abstract"): _transform_thermostat_simulator_to_abstract,
("thermostat", "zigbee2mqtt", "to_vendor"): _transform_thermostat_zigbee2mqtt_to_vendor,
("thermostat", "zigbee2mqtt", "to_abstract"): _transform_thermostat_zigbee2mqtt_to_abstract,
("thermostat", "max", "to_vendor"): _transform_thermostat_max_to_vendor,
("thermostat", "max", "to_abstract"): _transform_thermostat_max_to_abstract,
# Contact sensor transformations (support both 'contact' and 'contact_sensor' types)
("contact_sensor", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
("contact_sensor", "zigbee2mqtt", "to_abstract"): _transform_contact_sensor_zigbee2mqtt_to_abstract,
("contact_sensor", "max", "to_vendor"): _transform_contact_sensor_max_to_vendor,
("contact_sensor", "max", "to_abstract"): _transform_contact_sensor_max_to_abstract,
("contact", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
("contact", "zigbee2mqtt", "to_abstract"): _transform_contact_sensor_zigbee2mqtt_to_abstract,
("contact", "max", "to_vendor"): _transform_contact_sensor_max_to_vendor,
("contact", "max", "to_abstract"): _transform_contact_sensor_max_to_abstract,
# Temperature & humidity sensor transformations (support both type aliases)
("temp_humidity_sensor", "zigbee2mqtt", "to_vendor"): _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor,
("temp_humidity_sensor", "zigbee2mqtt", "to_abstract"): _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract,
("temp_humidity", "zigbee2mqtt", "to_vendor"): _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor,
("temp_humidity", "zigbee2mqtt", "to_abstract"): _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract,
# Relay transformations
("relay", "zigbee2mqtt", "to_vendor"): _transform_relay_zigbee2mqtt_to_vendor,
("relay", "zigbee2mqtt", "to_abstract"): _transform_relay_zigbee2mqtt_to_abstract,
("relay", "shelly", "to_vendor"): _transform_relay_shelly_to_vendor,
("relay", "shelly", "to_abstract"): _transform_relay_shelly_to_abstract,
("relay", "hottis_pv_modbus", "to_vendor"): _transform_relay_hottis_pv_modbus_to_vendor,
("relay", "hottis_pv_modbus", "to_abstract"): _transform_relay_hottis_pv_modbus_to_abstract,
("relay", "tasmota", "to_vendor"): _transform_relay_tasmota_to_vendor,
("relay", "tasmota", "to_abstract"): _transform_relay_tasmota_to_abstract,
# Three-Phase Powermeter transformations # Register handlers from each vendor module
("three_phase_powermeter", "hottis_pv_modbus", "to_vendor"): _transform_three_phase_powermeter_hottis_pv_modbus_to_vendor, for vendor_name, vendor_module in [
("three_phase_powermeter", "hottis_pv_modbus", "to_abstract"): _transform_three_phase_powermeter_hottis_pv_modbus_to_abstract, ("simulator", simulator),
} ("zigbee2mqtt", zigbee2mqtt),
("max", max),
("shelly", shelly),
("tasmota", tasmota),
("hottis_pv_modbus", hottis_pv_modbus),
("hottis_wago_modbus", hottis_wago_modbus),
("hottis_wifi_relay", hottis_wifi_relay),
]:
for (device_type, direction), handler in vendor_module.HANDLERS.items():
key = (device_type, vendor_name, direction)
TRANSFORM_HANDLERS[key] = handler
def _get_transform_handler( def _get_transform_handler(
@@ -656,7 +86,7 @@ def transform_abstract_to_vendor(
device_type: str, device_type: str,
device_technology: str, device_technology: str,
abstract_payload: dict[str, Any] abstract_payload: dict[str, Any]
) -> dict[str, Any]: ) -> str:
"""Transform abstract payload to vendor-specific format. """Transform abstract payload to vendor-specific format.
Args: Args:
@@ -665,7 +95,7 @@ def transform_abstract_to_vendor(
abstract_payload: Payload in abstract home protocol format abstract_payload: Payload in abstract home protocol format
Returns: Returns:
Payload in vendor-specific format Payload in vendor-specific format (as string)
""" """
logger.debug( logger.debug(
f"transform_abstract_to_vendor IN: type={device_type}, tech={device_technology}, " f"transform_abstract_to_vendor IN: type={device_type}, tech={device_technology}, "
@@ -692,7 +122,7 @@ def transform_vendor_to_abstract(
Args: Args:
device_type: Type of device (e.g., "light", "thermostat") device_type: Type of device (e.g., "light", "thermostat")
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt") device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
vendor_payload: Payload in vendor-specific format vendor_payload: Payload in vendor-specific format (as string)
Returns: Returns:
Payload in abstract home protocol format Payload in abstract home protocol format

1
apps/abstraction/vendors/__init__.py vendored Normal file
View File

@@ -0,0 +1 @@
"""Vendor-specific transformation modules."""

View File

@@ -0,0 +1,134 @@
"""Hottis PV Modbus vendor transformations."""
import json
import logging
from typing import Any
logger = logging.getLogger(__name__)
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract relay payload to Hottis Modbus format.
Hottis Modbus expects plain text 'on' or 'off'.
Example:
- Abstract: {'power': 'on'}
- Hottis Modbus: 'on'
"""
power = payload.get("power", "off")
return power
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
"""Transform Hottis Modbus relay payload to abstract format.
Hottis Modbus sends plain text 'on' or 'off'.
Example:
- Hottis PV Modbus: 'on'
- Abstract: {'power': 'on'}
"""
return {"power": payload.strip()}
def transform_contact_sensor_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract contact sensor payload to format.
Contact sensors are read-only.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return json.dumps(payload)
def transform_contact_sensor_to_abstract(payload: str) -> dict[str, Any]:
"""Transform contact sensor payload to abstract format.
MAX! sends "true"/"false" (string or bool) on STATE topic.
Transformations:
- "true" or True -> "open" (window/door open)
- "false" or False -> "closed" (window/door closed)
Example:
- contact sensor: "off"
- Abstract: {"contact": "open"}
"""
contact_value = payload.strip().lower() == "off"
return {
"contact": "open" if contact_value else "closed"
}
def transform_three_phase_powermeter_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract three_phase_powermeter payload to hottis_pv_modbus format."""
vendor_payload = {
"energy": payload.get("energy", 0.0),
"total_power": payload.get("total_power", 0.0),
"phase1_power": payload.get("phase1_power", 0.0),
"phase2_power": payload.get("phase2_power", 0.0),
"phase3_power": payload.get("phase3_power", 0.0),
"phase1_voltage": payload.get("phase1_voltage", 0.0),
"phase2_voltage": payload.get("phase2_voltage", 0.0),
"phase3_voltage": payload.get("phase3_voltage", 0.0),
"phase1_current": payload.get("phase1_current", 0.0),
"phase2_current": payload.get("phase2_current", 0.0),
"phase3_current": payload.get("phase3_current", 0.0),
}
return json.dumps(vendor_payload)
def transform_three_phase_powermeter_to_abstract(payload: str) -> dict[str, Any]:
"""Transform hottis_pv_modbus three_phase_powermeter payload to abstract format.
Transformations:
- totalImportEnergy -> energy
- powerL1/powerL2/powerL3 -> phase1_power/phase2_power/phase3_power
- voltageL1/voltageL2/voltageL3 -> phase1_voltage/phase2_voltage/phase3_voltage
- currentL1/currentL2/currentL3 -> phase1_current/phase2_current/phase3_current
- Sum of powerL1..3 -> total_power
"""
data = json.loads(payload)
def _get_float(key: str, default: float = 0.0) -> float:
return float(data.get(key, default))
phase1_power = _get_float("powerL1")
phase2_power = _get_float("powerL2")
phase3_power = _get_float("powerL3")
phase1_voltage = _get_float("voltageL1")
phase2_voltage = _get_float("voltageL2")
phase3_voltage = _get_float("voltageL3")
phase1_current = _get_float("currentL1")
phase2_current = _get_float("currentL2")
phase3_current = _get_float("currentL3")
energy = _get_float("totalImportEnergy")
return {
"energy": energy,
"total_power": phase1_power + phase2_power + phase3_power,
"phase1_power": phase1_power,
"phase2_power": phase2_power,
"phase3_power": phase3_power,
"phase1_voltage": phase1_voltage,
"phase2_voltage": phase2_voltage,
"phase3_voltage": phase3_voltage,
"phase1_current": phase1_current,
"phase2_current": phase2_current,
"phase3_current": phase3_current,
}
# Registry of handlers for this vendor
HANDLERS = {
("relay", "to_vendor"): transform_relay_to_vendor,
("relay", "to_abstract"): transform_relay_to_abstract,
("three_phase_powermeter", "to_vendor"): transform_three_phase_powermeter_to_vendor,
("three_phase_powermeter", "to_abstract"): transform_three_phase_powermeter_to_abstract,
("contact_sensor", "to_vendor"): transform_contact_sensor_to_vendor,
("contact_sensor", "to_abstract"): transform_contact_sensor_to_abstract,
("contact", "to_vendor"): transform_contact_sensor_to_vendor,
("contact", "to_abstract"): transform_contact_sensor_to_abstract,
}

View File

@@ -0,0 +1,58 @@
"""Hottis Wago Modbus vendor transformations."""
import logging
from typing import Any
logger = logging.getLogger(__name__)
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract relay payload to Hottis Wago Modbus format.
Hottis Wago Modbus expects plain text 'true' or 'false' (not JSON).
Example:
- Abstract: {'power': 'on'}
- Hottis Wago Modbus: 'true' or 'false'
"""
power = payload.get("power", "off")
# Map abstract "on"/"off" to vendor "true"/"false"
if isinstance(power, str):
power_lower = power.lower()
if power_lower in {"on", "true", "1"}:
return "true"
if power_lower in {"off", "false", "0"}:
return "false"
# Fallback: any truthy value -> "true", else "false"
return "true" if power else "false"
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
"""Transform Hottis Wago Modbus relay payload to abstract format.
Hottis Wago Modbus sends plain text 'true' or 'false'.
Example:
- Hottis Wago Modbus: 'true'
- Abstract: {'power': 'on'}
"""
value = payload.strip().lower()
if value == "true":
power = "on"
elif value == "false":
power = "off"
else:
# Fallback for unexpected values: keep as-is
logger.warning("Unexpected relay payload from Hottis Wago Modbus: %r", payload)
power = value
return {"power": power}
# Registry of handlers for this vendor
HANDLERS = {
("relay", "to_vendor"): transform_relay_to_vendor,
("relay", "to_abstract"): transform_relay_to_abstract,
}

View File

@@ -0,0 +1,38 @@
"""Shelly vendor transformations."""
import logging
from typing import Any
logger = logging.getLogger(__name__)
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract relay payload to Shelly format.
Shelly expects plain text 'on' or 'off' (not JSON).
Example:
- Abstract: {'power': 'on'}
- Shelly: 'on'
"""
power = payload.get("power", "off")
return power
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
"""Transform Shelly relay payload to abstract format.
Shelly sends plain text 'on' or 'off'.
Example:
- Shelly: 'on'
- Abstract: {'power': 'on'}
"""
return {"power": payload.strip()}
# Registry of handlers for this vendor
HANDLERS = {
("relay", "to_vendor"): transform_relay_to_vendor,
("relay", "to_abstract"): transform_relay_to_abstract,
}

95
apps/abstraction/vendors/max.py vendored Normal file
View File

@@ -0,0 +1,95 @@
"""MAX! (Homegear) vendor transformations."""
import json
import logging
from typing import Any
logger = logging.getLogger(__name__)
def transform_contact_sensor_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract contact sensor payload to MAX! format.
Contact sensors are read-only.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return json.dumps(payload)
def transform_contact_sensor_to_abstract(payload: str) -> dict[str, Any]:
"""Transform MAX! contact sensor payload to abstract format.
MAX! sends "true"/"false" (string or bool) on STATE topic.
Transformations:
- "true" or True -> "open" (window/door open)
- "false" or False -> "closed" (window/door closed)
Example:
- MAX!: "true"
- Abstract: {"contact": "open"}
"""
try:
contact_value = payload.strip().lower() == "true"
return {
"contact": "open" if contact_value else "closed"
}
except (ValueError, TypeError) as e:
logger.error(f"MAX! contact sensor failed to parse: {payload}, error: {e}")
return {"contact": "closed"}
def transform_thermostat_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract thermostat payload to MAX! format.
MAX! expects only the integer temperature value (no JSON).
Transformations:
- Extract 'target' temperature from payload
- Convert float to integer
- Return as plain string value
Example:
- Abstract: {'target': 22.5}
- MAX!: "22"
"""
if "target" not in payload:
logger.warning(f"MAX! thermostat payload missing 'target': {payload}")
return "21"
target_temp = payload["target"]
if isinstance(target_temp, (int, float)):
int_temp = int(round(target_temp))
return str(int_temp)
logger.warning(f"MAX! invalid target temperature type: {type(target_temp)}")
return "21"
def transform_thermostat_to_abstract(payload: str) -> dict[str, Any]:
"""Transform MAX! thermostat payload to abstract format.
MAX! sends only the integer temperature value (no JSON).
Example:
- MAX!: "22"
- Abstract: {'target': 22.0, 'mode': 'heat'}
"""
target_temp = float(payload.strip())
return {
"target": target_temp,
"mode": "heat"
}
# Registry of handlers for this vendor
HANDLERS = {
("contact_sensor", "to_vendor"): transform_contact_sensor_to_vendor,
("contact_sensor", "to_abstract"): transform_contact_sensor_to_abstract,
("contact", "to_vendor"): transform_contact_sensor_to_vendor,
("contact", "to_abstract"): transform_contact_sensor_to_abstract,
("thermostat", "to_vendor"): transform_thermostat_to_vendor,
("thermostat", "to_abstract"): transform_thermostat_to_abstract,
}

38
apps/abstraction/vendors/shelly.py vendored Normal file
View File

@@ -0,0 +1,38 @@
"""Shelly vendor transformations."""
import logging
from typing import Any
logger = logging.getLogger(__name__)
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract relay payload to Shelly format.
Shelly expects plain text 'on' or 'off' (not JSON).
Example:
- Abstract: {'power': 'on'}
- Shelly: 'on'
"""
power = payload.get("power", "off")
return power
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
"""Transform Shelly relay payload to abstract format.
Shelly sends plain text 'on' or 'off'.
Example:
- Shelly: 'on'
- Abstract: {'power': 'on'}
"""
return {"power": payload.strip()}
# Registry of handlers for this vendor
HANDLERS = {
("relay", "to_vendor"): transform_relay_to_vendor,
("relay", "to_abstract"): transform_relay_to_abstract,
}

50
apps/abstraction/vendors/simulator.py vendored Normal file
View File

@@ -0,0 +1,50 @@
"""Simulator vendor transformations."""
import json
import logging
from typing import Any
logger = logging.getLogger(__name__)
def transform_light_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract light payload to simulator format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return json.dumps(payload)
def transform_light_to_abstract(payload: str) -> dict[str, Any]:
"""Transform simulator light payload to abstract format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
payload = json.loads(payload)
return payload
def transform_thermostat_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract thermostat payload to simulator format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return json.dumps(payload)
def transform_thermostat_to_abstract(payload: str) -> dict[str, Any]:
"""Transform simulator thermostat payload to abstract format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
payload = json.loads(payload)
return payload
# Registry of handlers for this vendor
HANDLERS = {
("light", "to_vendor"): transform_light_to_vendor,
("light", "to_abstract"): transform_light_to_abstract,
("thermostat", "to_vendor"): transform_thermostat_to_vendor,
("thermostat", "to_abstract"): transform_thermostat_to_abstract,
}

38
apps/abstraction/vendors/tasmota.py vendored Normal file
View File

@@ -0,0 +1,38 @@
"""Tasmota vendor transformations."""
import logging
from typing import Any
logger = logging.getLogger(__name__)
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract relay payload to Tasmota format.
Tasmota expects plain text 'on' or 'off' (not JSON).
Example:
- Abstract: {'power': 'on'}
- Tasmota: 'on'
"""
power = payload.get("power", "off")
return power
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
"""Transform Tasmota relay payload to abstract format.
Tasmota sends plain text 'ON' or 'OFF'.
Example:
- Tasmota: 'ON'
- Abstract: {'power': 'on'}
"""
return {"power": payload.strip().lower()}
# Registry of handlers for this vendor
HANDLERS = {
("relay", "to_vendor"): transform_relay_to_vendor,
("relay", "to_abstract"): transform_relay_to_abstract,
}

209
apps/abstraction/vendors/zigbee2mqtt.py vendored Normal file
View File

@@ -0,0 +1,209 @@
"""Zigbee2MQTT vendor transformations."""
import json
import logging
from typing import Any
logger = logging.getLogger(__name__)
def transform_light_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract light payload to zigbee2mqtt format.
Transformations:
- power: 'on'/'off' -> state: 'ON'/'OFF'
- brightness: 0-100 -> brightness: 0-254
Example:
- Abstract: {'power': 'on', 'brightness': 100}
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
"""
vendor_payload = payload.copy()
# Transform power -> state with uppercase values
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
# Transform brightness: 0-100 (%) -> 0-254 (zigbee2mqtt range)
if "brightness" in vendor_payload:
abstract_brightness = vendor_payload["brightness"]
if isinstance(abstract_brightness, (int, float)):
vendor_payload["brightness"] = round(abstract_brightness * 254 / 100)
return json.dumps(vendor_payload)
def transform_light_to_abstract(payload: str) -> dict[str, Any]:
"""Transform zigbee2mqtt light payload to abstract format.
Transformations:
- state: 'ON'/'OFF' -> power: 'on'/'off'
- brightness: 0-254 -> brightness: 0-100
Example:
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
- Abstract: {'power': 'on', 'brightness': 100}
"""
abstract_payload = json.loads(payload)
# Transform state -> power with lowercase values
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
# Transform brightness: 0-254 (zigbee2mqtt range) -> 0-100 (%)
if "brightness" in abstract_payload:
vendor_brightness = abstract_payload["brightness"]
if isinstance(vendor_brightness, (int, float)):
abstract_payload["brightness"] = round(vendor_brightness * 100 / 254)
return abstract_payload
def transform_thermostat_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract thermostat payload to zigbee2mqtt format.
Transformations:
- target -> current_heating_setpoint (as string)
- mode is ignored (zigbee2mqtt thermostats use system_mode in state only)
Example:
- Abstract: {'target': 22.0}
- zigbee2mqtt: {'current_heating_setpoint': '22.0'}
"""
vendor_payload = {}
if "target" in payload:
vendor_payload["current_heating_setpoint"] = str(payload["target"])
return json.dumps(vendor_payload)
def transform_thermostat_to_abstract(payload: str) -> dict[str, Any]:
"""Transform zigbee2mqtt thermostat payload to abstract format.
Transformations:
- current_heating_setpoint -> target (as float)
- local_temperature -> current (as float)
- system_mode -> mode
Example:
- zigbee2mqtt: {'current_heating_setpoint': 15, 'local_temperature': 23, 'system_mode': 'heat'}
- Abstract: {'target': 15.0, 'current': 23.0, 'mode': 'heat'}
"""
payload = json.loads(payload)
abstract_payload = {}
if "current_heating_setpoint" in payload:
setpoint = payload["current_heating_setpoint"]
abstract_payload["target"] = float(setpoint)
if "local_temperature" in payload:
current = payload["local_temperature"]
abstract_payload["current"] = float(current)
if "system_mode" in payload:
abstract_payload["mode"] = payload["system_mode"]
return abstract_payload
def transform_contact_sensor_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract contact sensor payload to zigbee2mqtt format.
Contact sensors are read-only, so this should not be called for SET commands.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return json.dumps(payload)
def transform_contact_sensor_to_abstract(payload: str) -> dict[str, Any]:
"""Transform zigbee2mqtt contact sensor payload to abstract format.
Transformations:
- contact: bool -> "open" | "closed"
- zigbee2mqtt semantics: False = OPEN, True = CLOSED (inverted!)
Example:
- zigbee2mqtt: {"contact": false, "battery": 100}
- Abstract: {"contact": "open", "battery": 100}
"""
payload = json.loads(payload)
abstract_payload = {}
if "contact" in payload:
contact_bool = payload["contact"]
abstract_payload["contact"] = "closed" if contact_bool else "open"
# Pass through optional fields
for field in ["battery", "linkquality", "device_temperature", "voltage"]:
if field in payload:
abstract_payload[field] = payload[field]
return abstract_payload
def transform_temp_humidity_sensor_to_vendor(payload: dict[str, Any]) -> str:
"""Transform abstract temp/humidity sensor payload to zigbee2mqtt format.
Temp/humidity sensors are read-only.
"""
return json.dumps(payload)
def transform_temp_humidity_sensor_to_abstract(payload: str) -> dict[str, Any]:
"""Transform zigbee2mqtt temp/humidity sensor payload to abstract format.
Passthrough - zigbee2mqtt provides temperature, humidity, 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.
- 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 json.dumps(vendor_payload)
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
"""Transform zigbee2mqtt relay payload to abstract format.
- state: 'ON'/'OFF' -> power: 'on'/'off'
"""
payload = json.loads(payload)
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
# Registry of handlers for this vendor
HANDLERS = {
("light", "to_vendor"): transform_light_to_vendor,
("light", "to_abstract"): transform_light_to_abstract,
("thermostat", "to_vendor"): transform_thermostat_to_vendor,
("thermostat", "to_abstract"): transform_thermostat_to_abstract,
("contact_sensor", "to_vendor"): transform_contact_sensor_to_vendor,
("contact_sensor", "to_abstract"): transform_contact_sensor_to_abstract,
("contact", "to_vendor"): transform_contact_sensor_to_vendor,
("contact", "to_abstract"): transform_contact_sensor_to_abstract,
("temp_humidity_sensor", "to_vendor"): transform_temp_humidity_sensor_to_vendor,
("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,
("relay", "to_vendor"): transform_relay_to_vendor,
("relay", "to_abstract"): transform_relay_to_abstract,
}

View File

@@ -8,9 +8,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
MQTT_BROKER=172.16.2.16 \ MQTT_BROKER=172.16.2.16 \
MQTT_PORT=1883 \ MQTT_PORT=1883 \
REDIS_HOST=localhost \ REDIS_HOST=172.23.1.116 \
REDIS_PORT=6379 \ REDIS_PORT=6379 \
REDIS_DB=0 \ REDIS_DB=8 \
REDIS_CHANNEL=ui:updates REDIS_CHANNEL=ui:updates
# Create non-root user # Create non-root user

View File

@@ -121,7 +121,10 @@ async def get_device_layout(device_id: str):
async def startup_event(): async def startup_event():
"""Include routers after app is initialized to avoid circular imports.""" """Include routers after app is initialized to avoid circular imports."""
from apps.api.routes.groups_scenes import router as groups_scenes_router from apps.api.routes.groups_scenes import router as groups_scenes_router
from apps.api.routes.rooms import router as rooms_router
app.include_router(groups_scenes_router, prefix="") app.include_router(groups_scenes_router, prefix="")
app.include_router(rooms_router, prefix="")
@app.get("/health") @app.get("/health")

219
apps/api/routes/rooms.py Normal file
View File

@@ -0,0 +1,219 @@
"""
Room-based device control endpoints.
Provides bulk control operations for devices within rooms:
- /rooms/{room_name}/lights - Control all lights in a room
- /rooms/{room_name}/heating - Control all thermostats in a room
"""
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from packages.home_capabilities import load_layout
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Rooms"])
@router.get("/rooms")
async def get_rooms() -> list[dict[str, str]]:
"""Get list of all room IDs and names.
Returns:
List of dicts with room id and name
"""
layout = load_layout()
return [
{
"id": room.id,
"name": room.name
}
for room in layout.rooms
]
class LightsControlRequest(BaseModel):
"""Request model for controlling lights in a room."""
power: str # "on" or "off"
brightness: int | None = None # Optional brightness 0-100
class HeatingControlRequest(BaseModel):
"""Request model for controlling heating in a room."""
target: float # Target temperature
def get_room_devices(room_id: str) -> list[dict[str, Any]]:
"""Get all devices in a specific room from layout.
Args:
room_id: ID of the room
Returns:
List of device dicts with device_id, title, icon, rank, excluded
Raises:
HTTPException: If room not found
"""
layout = load_layout()
for room in layout.rooms:
if room.id == room_id:
return [
{
"device_id": device.device_id,
"title": device.title,
"icon": device.icon,
"rank": device.rank,
"excluded": device.excluded
}
for device in room.devices
]
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Room '{room_id}' not found"
)
@router.post("/rooms/{room_id}/lights", status_code=status.HTTP_202_ACCEPTED)
async def control_room_lights(room_id: str, request: LightsControlRequest) -> dict[str, Any]:
"""Control all lights (light and relay devices) in a room.
Args:
room_id: ID of the room
request: Light control parameters
Returns:
dict with affected device_ids and command summary
"""
from apps.api.main import load_devices, publish_abstract_set
# Get all devices in room
room_devices = get_room_devices(room_id)
# Filter out excluded devices
room_device_ids = {d["device_id"] for d in room_devices if not d.get("excluded", False)}
# Load all devices to filter by type
all_devices = load_devices()
# Filter for light/relay devices in this room
light_devices = [
d for d in all_devices
if d["device_id"] in room_device_ids and d["type"] in ("light", "relay")
]
if not light_devices:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No light devices found in room '{room_id}'"
)
# Build payload
payload = {"power": request.power}
if request.brightness is not None and request.power == "on":
payload["brightness"] = request.brightness
# Send commands to all light devices
affected_ids = []
errors = []
for device in light_devices:
try:
await publish_abstract_set(
device_type=device["type"],
device_id=device["device_id"],
payload=payload
)
affected_ids.append(device["device_id"])
logger.info(f"Sent command to {device['device_id']}: {payload}")
except Exception as e:
logger.error(f"Failed to control {device['device_id']}: {e}")
errors.append({
"device_id": device["device_id"],
"error": str(e)
})
return {
"room": room_id,
"command": "lights",
"payload": payload,
"affected_devices": affected_ids,
"success_count": len(affected_ids),
"error_count": len(errors),
"errors": errors if errors else None
}
@router.post("/rooms/{room_id}/heating", status_code=status.HTTP_202_ACCEPTED)
async def control_room_heating(room_id: str, request: HeatingControlRequest) -> dict[str, Any]:
"""Control all thermostats in a room.
Args:
room_id: ID of the room
request: Heating control parameters
Returns:
dict with affected device_ids and command summary
"""
from apps.api.main import load_devices, publish_abstract_set
# Get all devices in room
room_devices = get_room_devices(room_id)
# Filter out excluded devices
room_device_ids = {d["device_id"] for d in room_devices if not d.get("excluded", False)}
# Load all devices to filter by type
all_devices = load_devices()
# Filter for thermostat devices in this room
thermostat_devices = [
d for d in all_devices
if d["device_id"] in room_device_ids and d["type"] == "thermostat"
]
if not thermostat_devices:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No thermostat devices found in room '{room_name}'"
)
# Build payload
payload = {"target": request.target}
# Send commands to all thermostat devices
affected_ids = []
errors = []
for device in thermostat_devices:
try:
await publish_abstract_set(
device_type="thermostat",
device_id=device["device_id"],
payload=payload
)
affected_ids.append(device["device_id"])
logger.info(f"Sent heating command to {device['device_id']}: {payload}")
except Exception as e:
logger.error(f"Failed to control {device['device_id']}: {e}")
errors.append({
"device_id": device["device_id"],
"error": str(e)
})
return {
"room": room_id,
"command": "heating",
"payload": payload,
"affected_devices": affected_ids,
"success_count": len(affected_ids),
"error_count": len(errors),
"errors": errors if errors else None
}

View File

@@ -5,6 +5,7 @@ import json
import logging import logging
import os import os
import signal import signal
import uuid
from typing import Any from typing import Any
from aiomqtt import Client, Message from aiomqtt import Client, Message
@@ -163,7 +164,7 @@ async def mqtt_worker(shutdown_event: asyncio.Event) -> None:
async with Client( async with Client(
hostname=broker, hostname=broker,
port=port, port=port,
identifier="pulsegen" identifier=f"pulsegen-{uuid.uuid4()}",
) as client: ) as client:
logger.info("Connected to MQTT broker") logger.info("Connected to MQTT broker")
@@ -175,14 +176,19 @@ async def mqtt_worker(shutdown_event: asyncio.Event) -> None:
# Publish startup message # Publish startup message
await publish_example(client) await publish_example(client)
# Message loop # Message loop with timeout to allow shutdown check
async for message in client.messages: async for message in client.messages:
if shutdown_event.is_set(): if shutdown_event.is_set():
logger.info("Shutdown event detected, breaking message loop")
break break
try: try:
await handle_message(message, client) await handle_message(message, client)
except Exception as e: except Exception as e:
logger.error(f"Error in message handler: {e}", exc_info=True) logger.error(f"Error in message handler: {e}", exc_info=True)
# If we exit the loop due to shutdown, break the reconnect loop too
if shutdown_event.is_set():
break
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("MQTT worker cancelled") logger.info("MQTT worker cancelled")
@@ -216,8 +222,17 @@ async def main() -> None:
# Wait for shutdown signal # Wait for shutdown signal
await shutdown_event.wait() await shutdown_event.wait()
# Wait for worker to finish # Give worker a moment to finish gracefully
await worker_task logger.info("Waiting for MQTT worker to finish...")
try:
await asyncio.wait_for(worker_task, timeout=5.0)
except asyncio.TimeoutError:
logger.warning("MQTT worker did not finish in time, cancelling...")
worker_task.cancel()
try:
await worker_task
except asyncio.CancelledError:
pass
logger.info("Pulsegen application stopped") logger.info("Pulsegen application stopped")

View File

@@ -312,7 +312,8 @@
// Device IDs for garage devices // Device IDs for garage devices
const GARAGE_DEVICES = [ const GARAGE_DEVICES = [
'power_relay_caroutlet', 'power_relay_caroutlet',
'powermeter_caroutlet' 'powermeter_caroutlet',
'sensor_caroutlet'
]; ];
// Device states // Device states
@@ -410,7 +411,17 @@
renderOutletControls(controlSection, device); renderOutletControls(controlSection, device);
container.appendChild(controlSection); container.appendChild(controlSection);
// 3. Powermeter section // 3. Feedback section
const feedbackDevice = Object.values(devicesData).find(d => d.device_id === 'sensor_caroutlet');
if (feedbackDevice) {
const feedbackSection = document.createElement('div');
feedbackSection.className = 'device-section';
feedbackSection.dataset.deviceId = feedbackDevice.device_id;
renderFeedbackDisplay(feedbackSection, feedbackDevice);
container.appendChild(feedbackSection);
}
// 4. Powermeter section
const powermeterDevice = Object.values(devicesData).find(d => d.device_id === 'powermeter_caroutlet'); const powermeterDevice = Object.values(devicesData).find(d => d.device_id === 'powermeter_caroutlet');
if (powermeterDevice) { if (powermeterDevice) {
const powermeterSection = document.createElement('div'); const powermeterSection = document.createElement('div');
@@ -424,7 +435,6 @@
function renderOutletControls(container, device) { function renderOutletControls(container, device) {
const controlGroup = document.createElement('div'); const controlGroup = document.createElement('div');
controlGroup.style.textAlign = 'center'; controlGroup.style.textAlign = 'center';
// controlGroup.style.marginBottom = '8px';
const state = deviceStates[device.device_id]; const state = deviceStates[device.device_id];
const currentPower = state?.power === 'on'; const currentPower = state?.power === 'on';
@@ -440,36 +450,36 @@
label.className = 'toggle-label'; label.className = 'toggle-label';
label.textContent = currentPower ? 'Ein' : 'Aus'; label.textContent = currentPower ? 'Ein' : 'Aus';
// Status display
// const stateDisplay = document.createElement('div');
// stateDisplay.style.marginTop = '16px';
// stateDisplay.style.fontSize = '18px';
// stateDisplay.style.fontWeight = '600';
// stateDisplay.style.color = currentPower ? '#34c759' : '#666';
// stateDisplay.textContent = `Status: ${currentPower ? 'Eingeschaltet' : 'Ausgeschaltet'}`;
controlGroup.appendChild(toggleSwitch); controlGroup.appendChild(toggleSwitch);
controlGroup.appendChild(label); controlGroup.appendChild(label);
// controlGroup.appendChild(stateDisplay);
container.appendChild(controlGroup); container.appendChild(controlGroup);
} }
function renderFeedbackDisplay(container, device) {
const state = deviceStates[device.device_id] || {};
const controlGroup = document.createElement('div');
controlGroup.style.textAlign = 'center';
const label = document.createElement('div');
label.className = 'toggle-label';
console.log(`Rendering feedback for ${device.device_id}:`, state);
if (state.contact === 'closed') {
label.textContent = 'Schütz ✅ eingeschaltet';
} else {
label.textContent = 'Schütz 🅾️ ausgeschaltet';
}
controlGroup.appendChild(label);
container.appendChild(controlGroup);
}
function renderThreePhasePowerDisplay(container, device) { function renderThreePhasePowerDisplay(container, device) {
const state = deviceStates[device.device_id] || {}; const state = deviceStates[device.device_id] || {};
// Leistungsmessung Title
// const title = document.createElement('h3');
// title.style.margin = '0 0 20px 0';
// title.style.fontSize = '18px';
// title.style.fontWeight = '600';
// title.style.color = '#333';
// title.textContent = 'Leistungsmessung';
// container.appendChild(title);
// Übersicht
const overviewGrid = document.createElement('div'); const overviewGrid = document.createElement('div');
overviewGrid.className = 'state-grid'; overviewGrid.className = 'state-grid';
overviewGrid.innerHTML = ` overviewGrid.innerHTML = `
@@ -484,16 +494,13 @@
`; `;
container.appendChild(overviewGrid); container.appendChild(overviewGrid);
// Phasen Title
const phaseTitle = document.createElement('h4'); const phaseTitle = document.createElement('h4');
phaseTitle.style.margin = '20px 0 8px 0'; phaseTitle.style.margin = '20px 0 8px 0';
phaseTitle.style.fontSize = '16px'; phaseTitle.style.fontSize = '16px';
phaseTitle.style.fontWeight = '600'; phaseTitle.style.fontWeight = '600';
phaseTitle.style.color = '#333'; phaseTitle.style.color = '#333';
// phaseTitle.textContent = 'Phasen';
container.appendChild(phaseTitle); container.appendChild(phaseTitle);
// Phasen Details
const phaseGrid = document.createElement('div'); const phaseGrid = document.createElement('div');
phaseGrid.className = 'phase-grid'; phaseGrid.className = 'phase-grid';
phaseGrid.innerHTML = ` phaseGrid.innerHTML = `
@@ -601,12 +608,14 @@
const state = deviceStates[deviceId]; const state = deviceStates[deviceId];
console.log(`Updating UI for ${deviceId}:`, state); console.log(`Updating UI for ${deviceId}:`, state);
switch (device.type) { switch (deviceId) {
case 'relay': case 'power_relay_caroutlet':
case 'outlet':
updateOutletUI(deviceId, state); updateOutletUI(deviceId, state);
break; break;
case 'three_phase_powermeter': case 'sensor_caroutlet':
updateFeedbackDisplay(deviceId, state);
break;
case 'powermeter_caroutlet':
updateThreePhasePowerUI(deviceId, state); updateThreePhasePowerUI(deviceId, state);
break; break;
} }
@@ -637,6 +646,29 @@
} }
} }
function updateFeedbackDisplay(deviceId, state) {
const section = document.querySelector(`[data-device-id="${deviceId}"]`);
if (!section) return;
const label = section.querySelector('.toggle-label');
if (label) {
const isOn = state.contact === 'closed';
label.textContent = isOn ? 'Schütz ✅ eingeschaltet' : 'Schütz 🅾️ ausgeschaltet';
// Update state display in separate card
const cards = section.querySelectorAll('.card');
if (cards.length >= 3) { // Header, Control, State
const stateCard = cards[2];
stateCard.innerHTML = `
<div style="font-size: 18px; font-weight: 600; color: ${isOn ? '#34c759' : '#666'};">
Status: ${isOn ? 'Eingeschaltet' : 'Ausgeschaltet'}
</div>
`;
}
}
}
function updateThreePhasePowerUI(deviceId, state) { function updateThreePhasePowerUI(deviceId, state) {
// Update overview // Update overview
const totalPower = document.getElementById(`total-power-${deviceId}`); const totalPower = document.getElementById(`total-power-${deviceId}`);

View File

@@ -860,7 +860,6 @@ devices:
topics: topics:
set: "IoT/Car/Control" set: "IoT/Car/Control"
state: "IoT/Car/Control/State" state: "IoT/Car/Control/State"
- device_id: powermeter_caroutlet - device_id: powermeter_caroutlet
name: Car Outlet name: Car Outlet
type: three_phase_powermeter type: three_phase_powermeter
@@ -868,6 +867,13 @@ devices:
technology: hottis_pv_modbus technology: hottis_pv_modbus
topics: topics:
state: "IoT/Car/Values" state: "IoT/Car/Values"
- device_id: sensor_caroutlet
name: Car Outlet
type: contact
cap_version: contact_sensor@1.0.0
technology: hottis_pv_modbus
topics:
state: IoT/Car/Feedback/State
- device_id: schranklicht_flur_vor_kueche - device_id: schranklicht_flur_vor_kueche
name: Schranklicht Flur vor Küche name: Schranklicht Flur vor Küche
@@ -892,3 +898,63 @@ devices:
set: "zigbee2mqtt/0x842e14fffea72027/set" set: "zigbee2mqtt/0x842e14fffea72027/set"
- device_id: keller_flur_licht
name: Keller Flur Licht
type: relay
cap_version: "relay@1.0.0"
technology: hottis_wago_modbus
features:
power: true
topics:
set: "pulsegen/command/10/21"
state: "pulsegen/status/10"
- device_id: waschkueche_licht
name: Waschküche Licht
type: relay
cap_version: "relay@1.0.0"
technology: hottis_wago_modbus
features:
power: true
topics:
set: "pulsegen/command/8/22"
state: "pulsegen/status/8"
- device_id: werkstatt_licht
name: Werkstatt Licht
type: relay
cap_version: "relay@1.0.0"
technology: hottis_wago_modbus
features:
power: true
topics:
set: "pulsegen/command/7/19"
state: "pulsegen/status/7"
- device_id: sportzimmer_licht
name: Sportzimmer Licht
type: relay
cap_version: "relay@1.0.0"
technology: hottis_wago_modbus
features:
power: true
topics:
set: "pulsegen/command/9/20"
state: "pulsegen/status/9"
- device_id: deckenlampe_patty
name: Deckenlampe Patty
type: relay
cap_version: "relay@1.0.0"
technology: hottis_wago_modbus
features:
power: true
topics:
set: "pulsegen/command/4/16"
state: "pulsegen/status/4"
- device_id: regallampe_esszimmer
name: Regallampe Esszimmer
type: relay
cap_version: "relay@1.0.0"
technology: hottis_wifi_relay
features:
power: true
topics:
set: "IoT/WifiRelay1/State"
state: "IoT/WifiRelay1/State"

View File

@@ -1,5 +1,6 @@
rooms: rooms:
- name: Schlafzimmer - id: schlafzimmer
name: Schlafzimmer
devices: devices:
- device_id: bettlicht_patty - device_id: bettlicht_patty
title: Bettlicht Patty title: Bettlicht Patty
@@ -33,7 +34,8 @@ rooms:
title: Temperatur & Luftfeuchte title: Temperatur & Luftfeuchte
icon: 🌡️ icon: 🌡️
rank: 47 rank: 47
- name: Esszimmer - id: esszimmer
name: Esszimmer
devices: devices:
- device_id: deckenlampe_esszimmer - device_id: deckenlampe_esszimmer
title: Deckenlampe Esszimmer title: Deckenlampe Esszimmer
@@ -59,10 +61,10 @@ rooms:
title: Stehlampe Esszimmer Schrank title: Stehlampe Esszimmer Schrank
icon: 💡 icon: 💡
rank: 82 rank: 82
# - device_id: kleine_lampe_rechts_esszimmer - device_id: regallampe_esszimmer
# title: kleine Lampe rechts Esszimmer title: Regallampe Esszimmer
# icon: 💡 icon: 💡
# rank: 90 rank: 90
- device_id: licht_schrank_esszimmer - device_id: licht_schrank_esszimmer
title: Schranklicht Esszimmer title: Schranklicht Esszimmer
icon: 💡 icon: 💡
@@ -79,7 +81,8 @@ rooms:
title: Kontakt Straße links title: Kontakt Straße links
icon: 🪟 icon: 🪟
rank: 97 rank: 97
- name: Wohnzimmer - id: wohnzimmer
name: Wohnzimmer
devices: devices:
- device_id: lampe_naehtischchen_wohnzimmer - device_id: lampe_naehtischchen_wohnzimmer
title: Lampe Naehtischchen Wohnzimmer title: Lampe Naehtischchen Wohnzimmer
@@ -121,7 +124,8 @@ rooms:
title: Temperatur & Luftfeuchte title: Temperatur & Luftfeuchte
icon: 🌡️ icon: 🌡️
rank: 138 rank: 138
- name: che - id: kueche
name: Küche
devices: devices:
- device_id: kueche_deckenlampe - device_id: kueche_deckenlampe
title: Küche Deckenlampe title: Küche Deckenlampe
@@ -135,6 +139,7 @@ rooms:
title: Küche Putzlicht title: Küche Putzlicht
icon: 💡 icon: 💡
rank: 143 rank: 143
excluded: true
- device_id: kueche_fensterbank_licht - device_id: kueche_fensterbank_licht
title: Küche Fensterbank title: Küche Fensterbank
icon: 💡 icon: 💡
@@ -163,30 +168,35 @@ rooms:
title: Temperatur & Luftfeuchte title: Temperatur & Luftfeuchte
icon: 🌡️ icon: 🌡️
rank: 155 rank: 155
- name: Arbeitszimmer Patty - id: arbeitszimmer_patty
name: Arbeitszimmer Patty
devices: devices:
- device_id: leselampe_patty - device_id: leselampe_patty
title: Leselampe Patty title: Leselampe Patty
icon: 💡 icon: 💡
rank: 160 rank: 160
- device_id: schranklicht_hinten_patty - device_id: schranklicht_hinten_patty
title: Schranklicht hinten Patty title: Schranklicht hinten
icon: 💡 icon: 💡
rank: 170 rank: 170
- device_id: schranklicht_vorne_patty - device_id: schranklicht_vorne_patty
title: Schranklicht vorne Patty title: Schranklicht vorne
icon: 💡 icon: 💡
rank: 180 rank: 180
- device_id: kugellampe_patty - device_id: kugellampe_patty
title: Kugellampe Patty title: Kugellampe
icon: 💡 icon: 💡
rank: 181 rank: 181
- device_id: licht_schreibtisch_patty - device_id: licht_schreibtisch_patty
title: Licht Schreibtisch Patty title: Licht Schreibtisch
icon: 💡 icon: 💡
rank: 182 rank: 182
- device_id: deckenlampe_patty
title: Deckenlampe
icon: 💡
rank: 183
- device_id: thermostat_patty - device_id: thermostat_patty
title: Thermostat Patty title: Thermostat
icon: 🌡️ icon: 🌡️
rank: 185 rank: 185
- device_id: kontakt_patty_garten_rechts - device_id: kontakt_patty_garten_rechts
@@ -205,7 +215,8 @@ rooms:
title: Temperatur & Luftfeuchte title: Temperatur & Luftfeuchte
icon: 🌡️ icon: 🌡️
rank: 189 rank: 189
- name: Arbeitszimmer Wolfgang - id: arbeitszimmer_wolfgang
name: Arbeitszimmer Wolfgang
devices: devices:
- device_id: thermostat_wolfgang - device_id: thermostat_wolfgang
title: Wolfgang title: Wolfgang
@@ -223,7 +234,8 @@ rooms:
title: Temperatur & Luftfeuchte title: Temperatur & Luftfeuchte
icon: 🌡️ icon: 🌡️
rank: 202 rank: 202
- name: Flur - id: flur
name: Flur
devices: devices:
- device_id: deckenlampe_flur_oben - device_id: deckenlampe_flur_oben
title: Deckenlampe Flur oben title: Deckenlampe Flur oben
@@ -249,7 +261,8 @@ rooms:
title: Temperatur & Luftfeuchte title: Temperatur & Luftfeuchte
icon: 🌡️ icon: 🌡️
rank: 235 rank: 235
- name: Sportzimmer - id: sportzimmer
name: Sportzimmer
devices: devices:
- device_id: sportlicht_regal - device_id: sportlicht_regal
title: Sportlicht Regal title: Sportlicht Regal
@@ -263,11 +276,16 @@ rooms:
title: Sportlicht am Fernseher, Studierzimmer title: Sportlicht am Fernseher, Studierzimmer
icon: 🏃 icon: 🏃
rank: 260 rank: 260
- device_id: sportzimmer_licht
title: Deckenlampe
icon: 💡
rank: 262
- device_id: sensor_sportzimmer - device_id: sensor_sportzimmer
title: Temperatur & Luftfeuchte title: Temperatur & Luftfeuchte
icon: 🌡️ icon: 🌡️
rank: 265 rank: 265
- name: Bad Oben - id: bad_oben
name: Bad Oben
devices: devices:
- device_id: thermostat_bad_oben - device_id: thermostat_bad_oben
title: Thermostat Bad Oben title: Thermostat Bad Oben
@@ -281,7 +299,8 @@ rooms:
title: Temperatur & Luftfeuchte title: Temperatur & Luftfeuchte
icon: 🌡️ icon: 🌡️
rank: 272 rank: 272
- name: Bad Unten - id: bad_unten
name: Bad Unten
devices: devices:
- device_id: thermostat_bad_unten - device_id: thermostat_bad_unten
title: Thermostat Bad Unten title: Thermostat Bad Unten
@@ -295,13 +314,20 @@ rooms:
title: Temperatur & Luftfeuchte title: Temperatur & Luftfeuchte
icon: 🌡️ icon: 🌡️
rank: 282 rank: 282
- name: Waschküche - id: waschkueche
name: Waschküche
devices: devices:
- device_id: sensor_waschkueche - device_id: sensor_waschkueche
title: Temperatur & Luftfeuchte title: Temperatur & Luftfeuchte
icon: 🌡️ icon: 🌡️
rank: 290 rank: 290
- name: Outdoor - device_id: waschkueche_licht
title: Waschküche Licht
icon: 💡
rank: 340
- id: outdoor
name: Outdoor
devices: devices:
- device_id: licht_terasse - device_id: licht_terasse
title: Licht Terasse title: Licht Terasse
@@ -311,15 +337,33 @@ rooms:
title: Gartenlicht vorne title: Gartenlicht vorne
icon: 💡 icon: 💡
rank: 291 rank: 291
- name: Garage - id: garage
name: Garage
devices: devices:
- device_id: power_relay_caroutlet - device_id: power_relay_caroutlet
title: Ladestrom title: Ladestrom
icon: icon:
rank: 310 rank: 310
- device_id: sensor_caroutlet
title: Schützzustand
icon: 🔌
rank: 315
- device_id: powermeter_caroutlet - device_id: powermeter_caroutlet
title: Ladestrom title: Messwerte
icon: 📊 icon: 📊
rank: 320 rank: 320
- id: keller
name: Keller
devices:
- device_id: keller_flur_licht
title: Keller Flur Licht
icon: 💡
rank: 330
- device_id: werkstatt_licht
title: Werkstatt Licht
icon: 💡
rank: 350

View File

@@ -18,6 +18,7 @@ class DeviceTile(BaseModel):
title: Display title for the device title: Display title for the device
icon: Icon name or emoji for the device icon: Icon name or emoji for the device
rank: Sort order within the room (lower = first) rank: Sort order within the room (lower = first)
excluded: Optional flag to exclude device from certain operations
""" """
device_id: str = Field( device_id: str = Field(
@@ -40,16 +41,27 @@ class DeviceTile(BaseModel):
ge=0, ge=0,
description="Sort order (lower values appear first)" description="Sort order (lower values appear first)"
) )
excluded: bool = Field(
default=False,
description="Exclude device from bulk operations"
)
class Room(BaseModel): class Room(BaseModel):
"""Represents a room containing devices. """Represents a room containing devices.
Attributes: Attributes:
id: Unique room identifier (used for API endpoints)
name: Room name (e.g., "Wohnzimmer", "Küche") name: Room name (e.g., "Wohnzimmer", "Küche")
devices: List of device tiles in this room devices: List of device tiles in this room
""" """
id: str = Field(
...,
description="Unique room identifier"
)
name: str = Field( name: str = Field(
..., ...,
description="Room name" description="Room name"