Compare commits
16 Commits
0.7.0
...
room_comma
| Author | SHA1 | Date | |
|---|---|---|---|
|
d39bcfce26
|
|||
|
1fd275186a
|
|||
|
da370c9050
|
|||
|
08294ca294
|
|||
|
e5eb368dca
|
|||
|
169d0505cb
|
|||
|
02a2be92d5
|
|||
|
bcfc967460
|
|||
|
bd1f3bc8c9
|
|||
|
f9df70cf68
|
|||
|
5364b855aa
|
|||
|
3a1841a8a9
|
|||
|
9629850ebb
|
|||
|
000d32b78f
|
|||
|
24b2f70caf
|
|||
|
d3c1ec404a
|
@@ -4,620 +4,48 @@ This module implements a registry-pattern for vendor-specific transformations:
|
||||
- Each (device_type, technology, direction) tuple maps to a specific handler function
|
||||
- Handlers transform payloads between abstract and vendor-specific formats
|
||||
- Unknown combinations fall back to pass-through (no transformation)
|
||||
|
||||
Vendor-specific implementations are in the vendors/ subdirectory.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
from apps.abstraction.vendors import (
|
||||
simulator,
|
||||
zigbee2mqtt,
|
||||
max,
|
||||
shelly,
|
||||
tasmota,
|
||||
hottis_pv_modbus,
|
||||
hottis_wago_modbus,
|
||||
)
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
TransformHandler = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
TransformHandler = Callable[[Any], Any]
|
||||
|
||||
TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = {
|
||||
# Light transformations
|
||||
("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,
|
||||
# Build registry from vendor modules
|
||||
TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = {}
|
||||
|
||||
# Three-Phase Powermeter transformations
|
||||
("three_phase_powermeter", "hottis_pv_modbus", "to_vendor"): _transform_three_phase_powermeter_hottis_pv_modbus_to_vendor,
|
||||
("three_phase_powermeter", "hottis_pv_modbus", "to_abstract"): _transform_three_phase_powermeter_hottis_pv_modbus_to_abstract,
|
||||
}
|
||||
# Register handlers from each vendor module
|
||||
for vendor_name, vendor_module in [
|
||||
("simulator", simulator),
|
||||
("zigbee2mqtt", zigbee2mqtt),
|
||||
("max", max),
|
||||
("shelly", shelly),
|
||||
("tasmota", tasmota),
|
||||
("hottis_pv_modbus", hottis_pv_modbus),
|
||||
("hottis_wago_modbus", hottis_wago_modbus),
|
||||
]:
|
||||
for (device_type, direction), handler in vendor_module.HANDLERS.items():
|
||||
key = (device_type, vendor_name, direction)
|
||||
TRANSFORM_HANDLERS[key] = handler
|
||||
|
||||
|
||||
def _get_transform_handler(
|
||||
@@ -656,7 +84,7 @@ def transform_abstract_to_vendor(
|
||||
device_type: str,
|
||||
device_technology: str,
|
||||
abstract_payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
) -> str:
|
||||
"""Transform abstract payload to vendor-specific format.
|
||||
|
||||
Args:
|
||||
@@ -665,7 +93,7 @@ def transform_abstract_to_vendor(
|
||||
abstract_payload: Payload in abstract home protocol format
|
||||
|
||||
Returns:
|
||||
Payload in vendor-specific format
|
||||
Payload in vendor-specific format (as string)
|
||||
"""
|
||||
logger.debug(
|
||||
f"transform_abstract_to_vendor IN: type={device_type}, tech={device_technology}, "
|
||||
@@ -692,7 +120,7 @@ def transform_vendor_to_abstract(
|
||||
Args:
|
||||
device_type: Type of device (e.g., "light", "thermostat")
|
||||
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:
|
||||
Payload in abstract home protocol format
|
||||
|
||||
1
apps/abstraction/vendors/__init__.py
vendored
Normal file
1
apps/abstraction/vendors/__init__.py
vendored
Normal file
@@ -0,0 +1 @@
|
||||
"""Vendor-specific transformation modules."""
|
||||
134
apps/abstraction/vendors/hottis_pv_modbus.py
vendored
Normal file
134
apps/abstraction/vendors/hottis_pv_modbus.py
vendored
Normal 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,
|
||||
}
|
||||
58
apps/abstraction/vendors/hottis_wago_modbus.py
vendored
Normal file
58
apps/abstraction/vendors/hottis_wago_modbus.py
vendored
Normal 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,
|
||||
}
|
||||
95
apps/abstraction/vendors/max.py
vendored
Normal file
95
apps/abstraction/vendors/max.py
vendored
Normal 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
38
apps/abstraction/vendors/shelly.py
vendored
Normal 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
50
apps/abstraction/vendors/simulator.py
vendored
Normal 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
38
apps/abstraction/vendors/tasmota.py
vendored
Normal 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
209
apps/abstraction/vendors/zigbee2mqtt.py
vendored
Normal 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,
|
||||
}
|
||||
@@ -8,9 +8,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
MQTT_BROKER=172.16.2.16 \
|
||||
MQTT_PORT=1883 \
|
||||
REDIS_HOST=localhost \
|
||||
REDIS_HOST=172.23.1.116 \
|
||||
REDIS_PORT=6379 \
|
||||
REDIS_DB=0 \
|
||||
REDIS_DB=8 \
|
||||
REDIS_CHANNEL=ui:updates
|
||||
|
||||
# Create non-root user
|
||||
|
||||
@@ -121,7 +121,10 @@ async def get_device_layout(device_id: str):
|
||||
async def startup_event():
|
||||
"""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.rooms import router as rooms_router
|
||||
|
||||
app.include_router(groups_scenes_router, prefix="")
|
||||
app.include_router(rooms_router, prefix="")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
219
apps/api/routes/rooms.py
Normal file
219
apps/api/routes/rooms.py
Normal 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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from aiomqtt import Client, Message
|
||||
@@ -163,7 +164,7 @@ async def mqtt_worker(shutdown_event: asyncio.Event) -> None:
|
||||
async with Client(
|
||||
hostname=broker,
|
||||
port=port,
|
||||
identifier="pulsegen"
|
||||
identifier=f"pulsegen-{uuid.uuid4()}",
|
||||
) as client:
|
||||
logger.info("Connected to MQTT broker")
|
||||
|
||||
@@ -175,14 +176,19 @@ async def mqtt_worker(shutdown_event: asyncio.Event) -> None:
|
||||
# Publish startup message
|
||||
await publish_example(client)
|
||||
|
||||
# Message loop
|
||||
# Message loop with timeout to allow shutdown check
|
||||
async for message in client.messages:
|
||||
if shutdown_event.is_set():
|
||||
logger.info("Shutdown event detected, breaking message loop")
|
||||
break
|
||||
try:
|
||||
await handle_message(message, client)
|
||||
except Exception as e:
|
||||
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:
|
||||
logger.info("MQTT worker cancelled")
|
||||
@@ -216,8 +222,17 @@ async def main() -> None:
|
||||
# Wait for shutdown signal
|
||||
await shutdown_event.wait()
|
||||
|
||||
# Wait for worker to finish
|
||||
await worker_task
|
||||
# Give worker a moment to finish gracefully
|
||||
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")
|
||||
|
||||
|
||||
@@ -312,7 +312,8 @@
|
||||
// Device IDs for garage devices
|
||||
const GARAGE_DEVICES = [
|
||||
'power_relay_caroutlet',
|
||||
'powermeter_caroutlet'
|
||||
'powermeter_caroutlet',
|
||||
'sensor_caroutlet'
|
||||
];
|
||||
|
||||
// Device states
|
||||
@@ -410,7 +411,17 @@
|
||||
renderOutletControls(controlSection, device);
|
||||
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');
|
||||
if (powermeterDevice) {
|
||||
const powermeterSection = document.createElement('div');
|
||||
@@ -424,7 +435,6 @@
|
||||
function renderOutletControls(container, device) {
|
||||
const controlGroup = document.createElement('div');
|
||||
controlGroup.style.textAlign = 'center';
|
||||
// controlGroup.style.marginBottom = '8px';
|
||||
|
||||
const state = deviceStates[device.device_id];
|
||||
const currentPower = state?.power === 'on';
|
||||
@@ -440,36 +450,36 @@
|
||||
label.className = 'toggle-label';
|
||||
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(label);
|
||||
// controlGroup.appendChild(stateDisplay);
|
||||
|
||||
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) {
|
||||
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');
|
||||
overviewGrid.className = 'state-grid';
|
||||
overviewGrid.innerHTML = `
|
||||
@@ -484,16 +494,13 @@
|
||||
`;
|
||||
container.appendChild(overviewGrid);
|
||||
|
||||
// Phasen Title
|
||||
const phaseTitle = document.createElement('h4');
|
||||
phaseTitle.style.margin = '20px 0 8px 0';
|
||||
phaseTitle.style.fontSize = '16px';
|
||||
phaseTitle.style.fontWeight = '600';
|
||||
phaseTitle.style.color = '#333';
|
||||
// phaseTitle.textContent = 'Phasen';
|
||||
container.appendChild(phaseTitle);
|
||||
|
||||
// Phasen Details
|
||||
const phaseGrid = document.createElement('div');
|
||||
phaseGrid.className = 'phase-grid';
|
||||
phaseGrid.innerHTML = `
|
||||
@@ -601,12 +608,14 @@
|
||||
const state = deviceStates[deviceId];
|
||||
console.log(`Updating UI for ${deviceId}:`, state);
|
||||
|
||||
switch (device.type) {
|
||||
case 'relay':
|
||||
case 'outlet':
|
||||
switch (deviceId) {
|
||||
case 'power_relay_caroutlet':
|
||||
updateOutletUI(deviceId, state);
|
||||
break;
|
||||
case 'three_phase_powermeter':
|
||||
case 'sensor_caroutlet':
|
||||
updateFeedbackDisplay(deviceId, state);
|
||||
break;
|
||||
case 'powermeter_caroutlet':
|
||||
updateThreePhasePowerUI(deviceId, state);
|
||||
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) {
|
||||
// Update overview
|
||||
const totalPower = document.getElementById(`total-power-${deviceId}`);
|
||||
|
||||
@@ -860,7 +860,6 @@ devices:
|
||||
topics:
|
||||
set: "IoT/Car/Control"
|
||||
state: "IoT/Car/Control/State"
|
||||
|
||||
- device_id: powermeter_caroutlet
|
||||
name: Car Outlet
|
||||
type: three_phase_powermeter
|
||||
@@ -868,6 +867,13 @@ devices:
|
||||
technology: hottis_pv_modbus
|
||||
topics:
|
||||
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
|
||||
name: Schranklicht Flur vor Küche
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
rooms:
|
||||
- name: Schlafzimmer
|
||||
- id: schlafzimmer
|
||||
name: Schlafzimmer
|
||||
devices:
|
||||
- device_id: bettlicht_patty
|
||||
title: Bettlicht Patty
|
||||
@@ -33,7 +34,8 @@ rooms:
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 47
|
||||
- name: Esszimmer
|
||||
- id: esszimmer
|
||||
name: Esszimmer
|
||||
devices:
|
||||
- device_id: deckenlampe_esszimmer
|
||||
title: Deckenlampe Esszimmer
|
||||
@@ -79,7 +81,8 @@ rooms:
|
||||
title: Kontakt Straße links
|
||||
icon: 🪟
|
||||
rank: 97
|
||||
- name: Wohnzimmer
|
||||
- id: wohnzimmer
|
||||
name: Wohnzimmer
|
||||
devices:
|
||||
- device_id: lampe_naehtischchen_wohnzimmer
|
||||
title: Lampe Naehtischchen Wohnzimmer
|
||||
@@ -121,7 +124,8 @@ rooms:
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 138
|
||||
- name: Küche
|
||||
- id: kueche
|
||||
name: Küche
|
||||
devices:
|
||||
- device_id: kueche_deckenlampe
|
||||
title: Küche Deckenlampe
|
||||
@@ -135,6 +139,7 @@ rooms:
|
||||
title: Küche Putzlicht
|
||||
icon: 💡
|
||||
rank: 143
|
||||
excluded: true
|
||||
- device_id: kueche_fensterbank_licht
|
||||
title: Küche Fensterbank
|
||||
icon: 💡
|
||||
@@ -163,7 +168,8 @@ rooms:
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 155
|
||||
- name: Arbeitszimmer Patty
|
||||
- id: arbeitszimmer_patty
|
||||
name: Arbeitszimmer Patty
|
||||
devices:
|
||||
- device_id: leselampe_patty
|
||||
title: Leselampe Patty
|
||||
@@ -205,7 +211,8 @@ rooms:
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 189
|
||||
- name: Arbeitszimmer Wolfgang
|
||||
- id: arbeitszimmer_wolfgang
|
||||
name: Arbeitszimmer Wolfgang
|
||||
devices:
|
||||
- device_id: thermostat_wolfgang
|
||||
title: Wolfgang
|
||||
@@ -223,7 +230,8 @@ rooms:
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 202
|
||||
- name: Flur
|
||||
- id: flur
|
||||
name: Flur
|
||||
devices:
|
||||
- device_id: deckenlampe_flur_oben
|
||||
title: Deckenlampe Flur oben
|
||||
@@ -249,7 +257,8 @@ rooms:
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 235
|
||||
- name: Sportzimmer
|
||||
- id: sportzimmer
|
||||
name: Sportzimmer
|
||||
devices:
|
||||
- device_id: sportlicht_regal
|
||||
title: Sportlicht Regal
|
||||
@@ -267,7 +276,8 @@ rooms:
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 265
|
||||
- name: Bad Oben
|
||||
- id: bad_oben
|
||||
name: Bad Oben
|
||||
devices:
|
||||
- device_id: thermostat_bad_oben
|
||||
title: Thermostat Bad Oben
|
||||
@@ -281,7 +291,8 @@ rooms:
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 272
|
||||
- name: Bad Unten
|
||||
- id: bad_unten
|
||||
name: Bad Unten
|
||||
devices:
|
||||
- device_id: thermostat_bad_unten
|
||||
title: Thermostat Bad Unten
|
||||
@@ -295,13 +306,15 @@ rooms:
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 282
|
||||
- name: Waschküche
|
||||
- id: waschkueche
|
||||
name: Waschküche
|
||||
devices:
|
||||
- device_id: sensor_waschkueche
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 290
|
||||
- name: Outdoor
|
||||
- id: outdoor
|
||||
name: Outdoor
|
||||
devices:
|
||||
- device_id: licht_terasse
|
||||
title: Licht Terasse
|
||||
@@ -311,14 +324,19 @@ rooms:
|
||||
title: Gartenlicht vorne
|
||||
icon: 💡
|
||||
rank: 291
|
||||
- name: Garage
|
||||
- id: garage
|
||||
name: Garage
|
||||
devices:
|
||||
- device_id: power_relay_caroutlet
|
||||
title: Ladestrom
|
||||
icon: ⚡
|
||||
rank: 310
|
||||
- device_id: sensor_caroutlet
|
||||
title: Schützzustand
|
||||
icon: 🔌
|
||||
rank: 315
|
||||
- device_id: powermeter_caroutlet
|
||||
title: Ladestrom
|
||||
title: Messwerte
|
||||
icon: 📊
|
||||
rank: 320
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class DeviceTile(BaseModel):
|
||||
title: Display title for the device
|
||||
icon: Icon name or emoji for the device
|
||||
rank: Sort order within the room (lower = first)
|
||||
excluded: Optional flag to exclude device from certain operations
|
||||
"""
|
||||
|
||||
device_id: str = Field(
|
||||
@@ -40,16 +41,27 @@ class DeviceTile(BaseModel):
|
||||
ge=0,
|
||||
description="Sort order (lower values appear first)"
|
||||
)
|
||||
|
||||
excluded: bool = Field(
|
||||
default=False,
|
||||
description="Exclude device from bulk operations"
|
||||
)
|
||||
|
||||
|
||||
class Room(BaseModel):
|
||||
"""Represents a room containing devices.
|
||||
|
||||
Attributes:
|
||||
id: Unique room identifier (used for API endpoints)
|
||||
name: Room name (e.g., "Wohnzimmer", "Küche")
|
||||
devices: List of device tiles in this room
|
||||
"""
|
||||
|
||||
id: str = Field(
|
||||
...,
|
||||
description="Unique room identifier"
|
||||
)
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Room name"
|
||||
|
||||
Reference in New Issue
Block a user