Compare commits

...

12 Commits

5 changed files with 478 additions and 29 deletions

View File

@@ -16,10 +16,14 @@ from aiomqtt import Client
from pydantic import ValidationError
from packages.home_capabilities import LightState, ThermostatState
from apps.abstraction.transformation import (
transform_abstract_to_vendor,
transform_vendor_to_abstract
)
# Configure logging
logging.basicConfig(
level=logging.INFO,
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
@@ -127,6 +131,7 @@ async def handle_abstract_set(
mqtt_client: Client,
device_id: str,
device_type: str,
device_technology: str,
vendor_topic: str,
payload: dict[str, Any]
) -> None:
@@ -136,21 +141,22 @@ async def handle_abstract_set(
mqtt_client: MQTT client instance
device_id: Device identifier
device_type: Device type (e.g., 'light', 'thermostat')
device_technology: Technology identifier (e.g., 'zigbee2mqtt')
vendor_topic: Vendor-specific SET topic
payload: Message payload
"""
# Extract actual payload (remove type wrapper if present)
vendor_payload = payload.get("payload", payload)
abstract_payload = payload.get("payload", payload)
# Validate payload based on device type
try:
if device_type == "light":
# Validate light SET payload (power and/or brightness)
LightState.model_validate(vendor_payload)
LightState.model_validate(abstract_payload)
elif device_type == "thermostat":
# For thermostat SET: only allow mode and target fields
allowed_set_fields = {"mode", "target"}
invalid_fields = set(vendor_payload.keys()) - allowed_set_fields
invalid_fields = set(abstract_payload.keys()) - allowed_set_fields
if invalid_fields:
logger.warning(
f"Thermostat SET {device_id} contains invalid fields {invalid_fields}, "
@@ -159,11 +165,14 @@ async def handle_abstract_set(
return
# Validate against ThermostatState (current/battery/window_open are optional)
ThermostatState.model_validate(vendor_payload)
ThermostatState.model_validate(abstract_payload)
except ValidationError as e:
logger.error(f"Validation failed for {device_type} SET {device_id}: {e}")
return
# Transform abstract payload to vendor-specific format
vendor_payload = transform_abstract_to_vendor(device_type, device_technology, abstract_payload)
vendor_message = json.dumps(vendor_payload)
logger.info(f"→ vendor SET {device_id}: {vendor_topic}{vendor_message}")
@@ -175,6 +184,7 @@ async def handle_vendor_state(
redis_client: aioredis.Redis,
device_id: str,
device_type: str,
device_technology: str,
payload: dict[str, Any],
redis_channel: str = "ui:updates"
) -> None:
@@ -185,23 +195,27 @@ async def handle_vendor_state(
redis_client: Redis client instance
device_id: Device identifier
device_type: Device type (e.g., 'light', 'thermostat')
payload: State payload
device_technology: Technology identifier (e.g., 'zigbee2mqtt')
payload: State payload (vendor-specific format)
redis_channel: Redis channel for UI updates
"""
# Transform vendor-specific payload to abstract format
abstract_payload = transform_vendor_to_abstract(device_type, device_technology, payload)
# Validate state payload based on device type
try:
if device_type == "light":
LightState.model_validate(payload)
LightState.model_validate(abstract_payload)
elif device_type == "thermostat":
# Validate thermostat state: mode, target, current (required), battery, window_open
ThermostatState.model_validate(payload)
ThermostatState.model_validate(abstract_payload)
except ValidationError as e:
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
return
# Publish to abstract state topic (retained)
abstract_topic = f"home/{device_type}/{device_id}/state"
abstract_message = json.dumps(payload)
abstract_message = json.dumps(abstract_payload)
logger.info(f"← abstract STATE {device_id}: {abstract_topic}{abstract_message}")
await mqtt_client.publish(abstract_topic, abstract_message, qos=1, retain=True)
@@ -210,7 +224,7 @@ async def handle_vendor_state(
ui_update = {
"type": "state",
"device_id": device_id,
"payload": payload,
"payload": abstract_payload,
"ts": datetime.now(timezone.utc).isoformat()
}
redis_message = json.dumps(ui_update)
@@ -297,8 +311,9 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
if device_id in devices:
device = devices[device_id]
vendor_topic = device["topics"]["set"]
device_technology = device.get("technology", "unknown")
await handle_abstract_set(
client, device_id, device_type, vendor_topic, payload
client, device_id, device_type, device_technology, vendor_topic, payload
)
# Check if this is a vendor STATE message
@@ -306,8 +321,10 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
# Find device by vendor state topic
for device_id, device in devices.items():
if topic == device["topics"]["state"]:
device_technology = device.get("technology", "unknown")
await handle_vendor_state(
client, redis_client, device_id, device["type"], payload, redis_channel
client, redis_client, device_id, device["type"],
device_technology, payload, redis_channel
)
break

View File

@@ -0,0 +1,237 @@
"""Payload transformation functions for vendor-specific device communication.
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)
"""
import logging
from typing import Any, Callable
logger = logging.getLogger(__name__)
# ============================================================================
# HANDLER FUNCTIONS: simulator technology
# ============================================================================
def _transform_light_simulator_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract light payload to simulator format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
def _transform_light_simulator_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform simulator light payload to abstract format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
def _transform_thermostat_simulator_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract thermostat payload to simulator format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
def _transform_thermostat_simulator_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform simulator thermostat payload to abstract format.
Simulator uses same format as abstract protocol (no transformation needed).
"""
return payload
# ============================================================================
# HANDLER FUNCTIONS: zigbee2mqtt technology
# ============================================================================
def _transform_light_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""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 vendor_payload
def _transform_light_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> 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 = payload.copy()
# 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]) -> dict[str, Any]:
"""Transform abstract thermostat payload to zigbee2mqtt format.
zigbee2mqtt uses same format as abstract protocol (no transformation needed).
"""
return payload
def _transform_thermostat_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform zigbee2mqtt thermostat payload to abstract format.
zigbee2mqtt uses same format as abstract protocol (no transformation needed).
"""
return payload
# ============================================================================
# REGISTRY: Maps (device_type, technology, direction) -> handler function
# ============================================================================
TransformHandler = Callable[[dict[str, Any]], dict[str, 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,
}
def _get_transform_handler(
device_type: str,
device_technology: str,
direction: str
) -> TransformHandler:
"""Get transformation handler for given device type, technology and direction.
Args:
device_type: Type of device (e.g., "light", "thermostat")
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
direction: Transformation direction ("to_vendor" or "to_abstract")
Returns:
Handler function for transformation, or pass-through if not found
"""
key = (device_type, device_technology, direction)
handler = TRANSFORM_HANDLERS.get(key)
if handler is None:
logger.warning(
f"No transformation handler for {key}, using pass-through. "
f"Available: {list(TRANSFORM_HANDLERS.keys())}"
)
return lambda payload: payload # Pass-through fallback
return handler
# ============================================================================
# PUBLIC API: Main transformation functions
# ============================================================================
def transform_abstract_to_vendor(
device_type: str,
device_technology: str,
abstract_payload: dict[str, Any]
) -> dict[str, Any]:
"""Transform abstract payload to vendor-specific format.
Args:
device_type: Type of device (e.g., "light", "thermostat")
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
abstract_payload: Payload in abstract home protocol format
Returns:
Payload in vendor-specific format
"""
logger.debug(
f"transform_abstract_to_vendor IN: type={device_type}, tech={device_technology}, "
f"payload={abstract_payload}"
)
handler = _get_transform_handler(device_type, device_technology, "to_vendor")
vendor_payload = handler(abstract_payload)
logger.debug(
f"transform_abstract_to_vendor OUT: type={device_type}, tech={device_technology}, "
f"payload={vendor_payload}"
)
return vendor_payload
def transform_vendor_to_abstract(
device_type: str,
device_technology: str,
vendor_payload: dict[str, Any]
) -> dict[str, Any]:
"""Transform vendor-specific payload to abstract format.
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
Returns:
Payload in abstract home protocol format
"""
logger.debug(
f"transform_vendor_to_abstract IN: type={device_type}, tech={device_technology}, "
f"payload={vendor_payload}"
)
handler = _get_transform_handler(device_type, device_technology, "to_abstract")
abstract_payload = handler(vendor_payload)
logger.debug(
f"transform_vendor_to_abstract OUT: type={device_type}, tech={device_technology}, "
f"payload={abstract_payload}"
)
return abstract_payload

View File

@@ -29,6 +29,16 @@
padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.header-content {
flex: 1;
min-width: 200px;
}
h1 {
@@ -36,6 +46,65 @@
margin-bottom: 0.5rem;
}
.header-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.refresh-btn,
.collapse-all-btn {
padding: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
min-height: 44px;
min-width: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.refresh-btn:hover,
.collapse-all-btn:hover {
background: #5568d3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.refresh-btn:active,
.collapse-all-btn:active {
transform: translateY(0);
}
.refresh-icon {
font-size: 1.5rem;
line-height: 1;
transition: transform 0.3s;
}
.refresh-icon.spinning {
animation: spin 0.6s linear;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.collapse-all-icon {
font-size: 1.25rem;
transition: transform 0.3s;
line-height: 1;
}
.collapse-all-icon.collapsed {
transform: rotate(-90deg);
}
.status {
display: inline-block;
padding: 0.25rem 0.75rem;
@@ -57,27 +126,67 @@
.room {
background: white;
border-radius: 20px;
padding: 2rem;
margin-bottom: 2rem;
margin-bottom: 1rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
transition: box-shadow 0.2s;
}
.room:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
.room-header {
padding: 1.5rem 2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
background: white;
transition: background-color 0.2s;
user-select: none;
}
.room-header:hover {
background: #f8f9fa;
}
.room-header:active {
background: #e9ecef;
}
.room-title {
color: #333;
font-size: 1.75rem;
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 3px solid #667eea;
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
}
.room-toggle {
font-size: 1.5rem;
color: #667eea;
transition: transform 0.3s;
line-height: 1;
}
.room-toggle.collapsed {
transform: rotate(-90deg);
}
.room-content {
padding: 0 2rem 2rem 2rem;
max-height: 5000px;
overflow: hidden;
transition: max-height 0.3s ease-out, padding 0.3s ease-out;
}
.room-content.collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
.devices {
@@ -394,16 +503,30 @@
<body>
<div class="container">
<header>
<h1>🏠 Home Automation</h1>
<p>Realtime Status: <span class="status disconnected" id="connection-status">Verbinde...</span></p>
<div class="header-content">
<h1>🏠 Home Automation</h1>
<p>Realtime Status: <span class="status disconnected" id="connection-status">Verbinde...</span></p>
</div>
<div class="header-buttons">
<button class="refresh-btn" onclick="refreshPage()" title="Seite aktualisieren">
<span class="refresh-icon" id="refresh-icon"></span>
</button>
<button class="collapse-all-btn" onclick="toggleAllRooms()" title="Alle Räume ein-/ausklappen">
<span class="collapse-all-icon" id="collapse-all-icon"></span>
</button>
</div>
</header>
{% if rooms %}
{% for room in rooms %}
<section class="room">
<h2 class="room-title">{{ room.name }}</h2>
<div class="room-header" onclick="toggleRoom('room-{{ loop.index }}')">
<h2 class="room-title">{{ room.name }}</h2>
<span class="room-toggle" id="toggle-room-{{ loop.index }}"></span>
</div>
<div class="devices">
<div class="room-content" id="room-{{ loop.index }}">
<div class="devices">
{% for device in room.devices %}
<div class="device-card" data-device-id="{{ device.device_id }}">
<div class="device-header">
@@ -515,6 +638,7 @@
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{% endfor %}
@@ -534,6 +658,50 @@
</div>
<script>
// Toggle room visibility
function toggleRoom(roomId) {
const content = document.getElementById(roomId);
const toggle = document.getElementById(`toggle-${roomId}`);
if (content && toggle) {
content.classList.toggle('collapsed');
toggle.classList.toggle('collapsed');
}
}
// Refresh page with animation
function refreshPage() {
const icon = document.getElementById('refresh-icon');
icon.classList.add('spinning');
// Reload page after brief animation
setTimeout(() => {
window.location.reload();
}, 300);
}
// Toggle all rooms
function toggleAllRooms() {
const allContents = document.querySelectorAll('.room-content');
const allToggles = document.querySelectorAll('.room-toggle');
const buttonIcon = document.getElementById('collapse-all-icon');
// Check if any room is expanded
const anyExpanded = Array.from(allContents).some(content => !content.classList.contains('collapsed'));
if (anyExpanded) {
// Collapse all
allContents.forEach(content => content.classList.add('collapsed'));
allToggles.forEach(toggle => toggle.classList.add('collapsed'));
buttonIcon.classList.add('collapsed');
} else {
// Expand all
allContents.forEach(content => content.classList.remove('collapsed'));
allToggles.forEach(toggle => toggle.classList.remove('collapsed'));
buttonIcon.classList.remove('collapsed');
}
}
// Set room icons based on room name
document.addEventListener('DOMContentLoaded', () => {
const roomTitles = document.querySelectorAll('.room-title');
@@ -558,6 +726,15 @@
});
});
// Clean up SSE connection before page unload
window.addEventListener('beforeunload', () => {
if (eventSource) {
console.log('Closing SSE connection before unload');
eventSource.close();
eventSource = null;
}
});
// API_BASE injected from backend (supports Docker/K8s environments)
window.API_BASE = '{{ api_base }}';
@@ -737,6 +914,8 @@
toggleButton.textContent = 'Einschalten';
toggleButton.className = 'toggle-button off';
}
// Force reflow for iOS Safari
void toggleButton.offsetHeight;
}
// Update brightness display and slider

View File

@@ -16,7 +16,7 @@ devices:
- device_id: test_lampe_1
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
technology: simulator
features:
power: true
brightness: true
@@ -26,7 +26,7 @@ devices:
- device_id: test_lampe_2
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
technology: simulator
features:
power: true
topics:
@@ -35,7 +35,7 @@ devices:
- device_id: test_lampe_3
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
technology: simulator
features:
power: true
brightness: true
@@ -45,12 +45,22 @@ devices:
- device_id: test_thermo_1
type: thermostat
cap_version: "thermostat@2.0.0"
technology: zigbee2mqtt
technology: simulator
features:
mode: true
mode: false
target: true
current: true
battery: true
topics:
set: "vendor/test_thermo_1/set"
state: "vendor/test_thermo_1/state"
- device_id: experiment_light_1
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
set: "zigbee2mqtt/0xf0d1b80000195038/set"
state: "zigbee2mqtt/0xf0d1b80000195038"

View File

@@ -24,6 +24,12 @@ rooms:
icon: "🛏️"
rank: 10
- name: Lab
devices:
- device_id: experiment_light_1
title: Experimentierlampe
icon: "💡"
rank: 10