Compare commits
16 Commits
0.6.3-conf
...
0.9.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
169d0505cb
|
|||
|
02a2be92d5
|
|||
|
bcfc967460
|
|||
|
bd1f3bc8c9
|
|||
|
f9df70cf68
|
|||
|
5364b855aa
|
|||
|
3a1841a8a9
|
|||
|
9629850ebb
|
|||
|
000d32b78f
|
|||
|
24b2f70caf
|
|||
|
d3c1ec404a
|
|||
|
9ba478c34d
|
|||
|
15e132b187
|
|||
|
f40887ec37
|
|||
|
507f6f3854
|
|||
|
f163bb09bf
|
@@ -11,6 +11,7 @@ matrix:
|
|||||||
- abstraction
|
- abstraction
|
||||||
- rules
|
- rules
|
||||||
- static
|
- static
|
||||||
|
- pulsegen
|
||||||
- homekit
|
- homekit
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ matrix:
|
|||||||
- abstraction
|
- abstraction
|
||||||
- rules
|
- rules
|
||||||
- static
|
- static
|
||||||
|
- pulsegen
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
deploy-${APP}:
|
deploy-${APP}:
|
||||||
|
|||||||
@@ -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
|
- 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,
|
||||||
|
)
|
||||||
|
|
||||||
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),
|
||||||
|
]:
|
||||||
|
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 +84,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 +93,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 +120,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
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,
|
||||||
|
}
|
||||||
35
apps/pulsegen/Dockerfile
Normal file
35
apps/pulsegen/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Pulsegen Dockerfile
|
||||||
|
# MQTT Pulse Generator Worker
|
||||||
|
|
||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
MQTT_BROKER=172.16.2.16 \
|
||||||
|
MQTT_PORT=1883
|
||||||
|
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY apps/pulsegen/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY apps/__init__.py /app/apps/__init__.py
|
||||||
|
COPY apps/pulsegen/ /app/apps/pulsegen/
|
||||||
|
|
||||||
|
# Change ownership to app user
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["python", "-m", "apps.pulsegen.main"]
|
||||||
53
apps/pulsegen/README.md
Normal file
53
apps/pulsegen/README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Pulsegen
|
||||||
|
|
||||||
|
MQTT-basierte Pulse-Generator Applikation für Home Automation.
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
- MQTT-Kommunikation über `aiomqtt`
|
||||||
|
- Automatische Reconnect-Logik
|
||||||
|
- Graceful shutdown (SIGTERM/SIGINT)
|
||||||
|
- JSON message parsing
|
||||||
|
- Konfigurierbar über Umgebungsvariablen
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
- `MQTT_BROKER`: MQTT Broker Hostname (default: `localhost`)
|
||||||
|
- `MQTT_PORT`: MQTT Broker Port (default: `1883`)
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
Lokal starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/pulsegen
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # oder venv\Scripts\activate auf Windows
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f apps/pulsegen/Dockerfile -t pulsegen .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -e MQTT_BROKER=172.16.2.16 -e MQTT_PORT=1883 pulsegen
|
||||||
|
```
|
||||||
|
|
||||||
|
## MQTT Topics
|
||||||
|
|
||||||
|
### Subscribed
|
||||||
|
|
||||||
|
- `pulsegen/command/#` - Kommandos für pulsegen
|
||||||
|
- `home/+/+/state` - Device state updates
|
||||||
|
|
||||||
|
### Published
|
||||||
|
|
||||||
|
- `pulsegen/status` - Status-Updates der Applikation
|
||||||
1
apps/pulsegen/__init__.py
Normal file
1
apps/pulsegen/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Pulsegen - MQTT pulse generator application."""
|
||||||
241
apps/pulsegen/main.py
Normal file
241
apps/pulsegen/main.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""Pulsegen - MQTT pulse generator application."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiomqtt import Client, Message
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
COIL_STATUS_PREFIX = "dt1/di"
|
||||||
|
COIL_STATUS_TOPIC = f"{COIL_STATUS_PREFIX}/+"
|
||||||
|
PULSEGEN_COMMAND_PREFIX = "pulsegen/command"
|
||||||
|
PULSEGEN_COMMAND_TOPIC = f"{PULSEGEN_COMMAND_PREFIX}/+/+"
|
||||||
|
COIL_COMMAND_PREFIX = "dt1/coil"
|
||||||
|
PULSEGEN_STATUS_PREFIX = "pulsegen/status"
|
||||||
|
|
||||||
|
COIL_STATUS_CACHE: dict[int, bool] = {}
|
||||||
|
|
||||||
|
def get_mqtt_settings() -> tuple[str, int]:
|
||||||
|
"""Get MQTT broker settings from environment variables.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (broker_host, broker_port)
|
||||||
|
"""
|
||||||
|
broker = os.getenv("MQTT_BROKER", "localhost")
|
||||||
|
port = int(os.getenv("MQTT_PORT", "1883"))
|
||||||
|
logger.info(f"MQTT settings: broker={broker}, port={port}")
|
||||||
|
return broker, port
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_message(message: Message, client: Client) -> None:
|
||||||
|
"""Handle incoming MQTT message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: MQTT message object
|
||||||
|
client: MQTT client instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = message.payload.decode()
|
||||||
|
logger.info(f"Received message on {message.topic}: {payload}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
topic = str(message.topic)
|
||||||
|
|
||||||
|
match topic.split("/"):
|
||||||
|
case [prefix, di, coil_id] if f"{prefix}/{di}" == COIL_STATUS_PREFIX:
|
||||||
|
try:
|
||||||
|
coil_num = int(coil_id)
|
||||||
|
except ValueError:
|
||||||
|
logger.debug(f"Invalid coil id in topic: {topic}")
|
||||||
|
return
|
||||||
|
|
||||||
|
state = payload.lower() in ("1", "true", "on")
|
||||||
|
COIL_STATUS_CACHE[coil_num] = state
|
||||||
|
logger.info(f"Updated coil {coil_num} status to {state}")
|
||||||
|
|
||||||
|
logger.info(f"Publishing pulsegen status for coil {coil_num}: {state}")
|
||||||
|
await client.publish(
|
||||||
|
topic=f"{PULSEGEN_STATUS_PREFIX}/{coil_num}",
|
||||||
|
payload="on" if state else "off",
|
||||||
|
qos=1,
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
case [prefix, command, coil_in_id, coil_out_id] if f"{prefix}/{command}" == PULSEGEN_COMMAND_PREFIX:
|
||||||
|
try:
|
||||||
|
coil_in_id = int(coil_in_id)
|
||||||
|
coil_out_id = int(coil_out_id)
|
||||||
|
except ValueError:
|
||||||
|
logger.debug(f"Invalid coil id in topic: {topic}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
coil_state = COIL_STATUS_CACHE[coil_in_id]
|
||||||
|
except KeyError:
|
||||||
|
logger.debug(f"Coil {coil_in_id} status unknown, cannot process command")
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = payload.lower() in ("1", "true", "on")
|
||||||
|
|
||||||
|
if cmd == coil_state:
|
||||||
|
logger.info(f"Coil {coil_in_id} already in desired state {cmd}, ignoring command")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Received pulsegen command on {topic}: {coil_in_id=}, {coil_out_id=}, {cmd=}")
|
||||||
|
|
||||||
|
|
||||||
|
coil_cmd_topic = f"{COIL_COMMAND_PREFIX}/{coil_out_id}"
|
||||||
|
|
||||||
|
logger.info(f"Sending raising edge command: topic={coil_cmd_topic}")
|
||||||
|
await client.publish(
|
||||||
|
topic=coil_cmd_topic,
|
||||||
|
payload="1",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
logger.info(f"Sending falling edge command: topic={coil_cmd_topic}")
|
||||||
|
await client.publish(
|
||||||
|
topic=coil_cmd_topic,
|
||||||
|
payload="0",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
case _:
|
||||||
|
logger.debug(f"Ignoring message on unrelated topic: {topic}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Exception when handling payload: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling message: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_example(client: Client) -> None:
|
||||||
|
"""Example function to publish MQTT messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: MQTT client instance
|
||||||
|
"""
|
||||||
|
topic = "pulsegen/status"
|
||||||
|
payload = {
|
||||||
|
"status": "running",
|
||||||
|
"timestamp": asyncio.get_event_loop().time()
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.publish(
|
||||||
|
topic=topic,
|
||||||
|
payload=json.dumps(payload),
|
||||||
|
qos=1
|
||||||
|
)
|
||||||
|
logger.info(f"Published to {topic}: {payload}")
|
||||||
|
|
||||||
|
|
||||||
|
async def mqtt_worker(shutdown_event: asyncio.Event) -> None:
|
||||||
|
"""Main MQTT worker loop.
|
||||||
|
|
||||||
|
Connects to MQTT broker, subscribes to topics, and processes messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shutdown_event: Event to signal shutdown
|
||||||
|
"""
|
||||||
|
broker, port = get_mqtt_settings()
|
||||||
|
|
||||||
|
|
||||||
|
reconnect_interval = 5 # seconds
|
||||||
|
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
logger.info(f"Connecting to MQTT broker {broker}:{port}...")
|
||||||
|
|
||||||
|
async with Client(
|
||||||
|
hostname=broker,
|
||||||
|
port=port,
|
||||||
|
identifier=f"pulsegen-{uuid.uuid4()}",
|
||||||
|
) as client:
|
||||||
|
logger.info("Connected to MQTT broker")
|
||||||
|
|
||||||
|
# Subscribe to topics
|
||||||
|
for topic in [PULSEGEN_COMMAND_TOPIC, COIL_STATUS_TOPIC]:
|
||||||
|
await client.subscribe(topic)
|
||||||
|
logger.info(f"Subscribed to {topic}")
|
||||||
|
|
||||||
|
# Publish startup message
|
||||||
|
await publish_example(client)
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MQTT error: {e}", exc_info=True)
|
||||||
|
if not shutdown_event.is_set():
|
||||||
|
logger.info(f"Reconnecting in {reconnect_interval} seconds...")
|
||||||
|
await asyncio.sleep(reconnect_interval)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Main application entry point."""
|
||||||
|
logger.info("Starting pulsegen application...")
|
||||||
|
|
||||||
|
# Shutdown event for graceful shutdown
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
# Setup signal handlers
|
||||||
|
def signal_handler(sig: int) -> None:
|
||||||
|
logger.info(f"Received signal {sig}, initiating shutdown...")
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
loop.add_signal_handler(sig, lambda s=sig: signal_handler(s))
|
||||||
|
|
||||||
|
# Start MQTT worker
|
||||||
|
worker_task = asyncio.create_task(mqtt_worker(shutdown_event))
|
||||||
|
|
||||||
|
# Wait for shutdown signal
|
||||||
|
await shutdown_event.wait()
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
1
apps/pulsegen/requirements.txt
Normal file
1
apps/pulsegen/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
aiomqtt==2.3.0
|
||||||
@@ -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,16 @@
|
|||||||
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';
|
||||||
|
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 +434,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 +449,45 @@
|
|||||||
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);
|
||||||
|
|
||||||
|
const values = Object.values(state || {});
|
||||||
|
const isActive = values.some(v =>
|
||||||
|
v === true ||
|
||||||
|
v === 'on' ||
|
||||||
|
v === 1
|
||||||
|
);
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
label.textContent = 'Kein Status verfügbar';
|
||||||
|
} else {
|
||||||
|
label.textContent = isActive
|
||||||
|
? 'Fahrzeug verbunden'
|
||||||
|
: 'Kein Fahrzeug verbunden';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +502,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 = `
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -317,8 +317,12 @@ rooms:
|
|||||||
title: Ladestrom
|
title: Ladestrom
|
||||||
icon: ⚡
|
icon: ⚡
|
||||||
rank: 310
|
rank: 310
|
||||||
|
- device_id: sensor_caroutlet
|
||||||
|
title: Kabelstatus
|
||||||
|
icon: 🔌
|
||||||
|
rank: 315
|
||||||
- device_id: powermeter_caroutlet
|
- device_id: powermeter_caroutlet
|
||||||
title: Ladestrom
|
title: Messwerte
|
||||||
icon: 📊
|
icon: 📊
|
||||||
rank: 320
|
rank: 320
|
||||||
|
|
||||||
|
|||||||
51
deployment/pulsegen-deployment.yaml
Normal file
51
deployment/pulsegen-deployment.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: pulsegen
|
||||||
|
namespace: homea2
|
||||||
|
labels:
|
||||||
|
app: pulsegen
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: pulsegen
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
reloader.stakater.com/auto: "true"
|
||||||
|
configmap.reloader.stakater.com/reload: "home-automation-environment"
|
||||||
|
labels:
|
||||||
|
app: pulsegen
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: pulsegen
|
||||||
|
image: %IMAGE%
|
||||||
|
env:
|
||||||
|
- name: MQTT_BROKER
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_MQTT_BROKER
|
||||||
|
- name: MQTT_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_MQTT_PORT
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
requests:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- "ps aux | grep -v grep | grep python"
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
Reference in New Issue
Block a user