Compare commits

...

17 Commits

11 changed files with 1551 additions and 242 deletions

41
DEVICES_BY_ROOM.md Normal file
View File

@@ -0,0 +1,41 @@
Schlafzimmer:
- Bettlicht Patty | 0x0017880108158b32
- Bettlicht Wolfgang | 0x00178801081570bf
- Deckenlampe Schlafzimmer | 0x0017880108a406a7
- Medusa-Lampe Schlafzimmer | 0xf0d1b80000154c7c
Esszimmer:
- Deckenlampe Esszimmer | 0x0017880108a03e45
- Leselampe Esszimmer | 0xec1bbdfffe7b84f2
- Standlampe Esszimmer | 0xbc33acfffe21f547
- kleine Lampe links Esszimmer | 0xf0d1b80000153099
- kleine Lampe rechts Esszimmer | 0xf0d1b80000156645
Wohnzimmer:
- Lampe Naehtischchen Wohnzimmer | 0x842e14fffee560ee
- Lampe Semeniere Wohnzimmer | 0xf0d1b8000015480b
- Sterne Wohnzimmer | 0xf0d1b80000155fc2
- grosse Lampe Wohnzimmer | 0xf0d1b80000151aca
Küche:
- Küche Deckenlampe | 0x001788010d2c40c4
- Kueche | 0x94deb8fffe2e5c06
Arbeitszimmer Patty:
- Leselampe Patty | 0x001788010600ec9d
- Schranklicht hinten Patty | 0x0017880106e29571
- Schranklicht vorne Patty | 0xf0d1b80000154cf5
Arbeitszimmer Wolfgang:
- Wolfgang | 0x540f57fffe7e3cfe
- ExperimentLabTest | 0xf0d1b80000195038
Flur:
- Deckenlampe Flur oben | 0x001788010d2123a7
- Haustür | 0xec1bbdfffea6a3da
- Licht Flur oben am Spiegel | 0x842e14fffefe4ba4
Sportzimmer:
- Sportlicht Regal | 0xf0d1b8be2409f569
- Sportlicht Tisch | 0xf0d1b8be2409f31b
- Sportlicht am Fernseher, Studierzimmer | 0x842e14fffe76a23a

View File

@@ -0,0 +1,54 @@
# Nicht berücksichtigte Zigbee-Geräte
## Switches (0)
~~Gerät "Sterne Wohnzimmer" wurde als Light zu devices.yaml hinzugefügt~~
## Sensoren und andere Geräte (22)
### Tür-/Fenstersensoren (7)
- Wolfgang (MCCGQ11LM) - 0x00158d008b3328da
- Terassentür (MCCGQ11LM) - 0x00158d008b332788
- Garten Kueche (MCCGQ11LM) - 0x00158d008b332785
- Strasse rechts Kueche (MCCGQ11LM) - 0x00158d008b151803
- Strasse links Kueche (MCCGQ11LM) - 0x00158d008b331d0b
- Fenster Bad oben (MCCGQ11LM) - 0x00158d008b333aec
- Fenster Patty Strasse (MCCGQ11LM) - 0x00158d000af457cf
### Temperatur-/Feuchtigkeitssensoren (11)
- Kueche (WSDCGQ11LM) - 0x00158d00083299bb
- Wolfgang (WSDCGQ11LM) - 0x00158d000543fb99
- Patty (WSDCGQ11LM) - 0x00158d0003f052b7
- Schlafzimmer (WSDCGQ01LM) - 0x00158d00043292dc
- Bad oben (WSDCGQ11LM) - 0x00158d00093e8987
- Flur (WSDCGQ11LM) - 0x00158d000836ccc6
- Wohnzimmer (WSDCGQ11LM) - 0x00158d0008975707
- Bad unten (WSDCGQ11LM) - 0x00158d00093e662a
- Waschkueche (WSDCGQ11LM) - 0x00158d000449f3bc
- Studierzimmer (WSDCGQ11LM) - 0x00158d0009421422
- Wolfgang (SONOFF SNZB-02D) - 0x0ceff6fffe39a196
### Schalter (2)
- Schalter Schlafzimmer (Philips 929003017102) - 0x001788010cc490d4
- Schalter Bettlicht Patty (WXKG11LM) - 0x00158d000805d165
### Bewegungsmelder (1)
- Bewegungsmelder 8 (Philips 9290012607) - 0x001788010867d420
### Wasserleck-Sensor (1)
- unter Therme (SJCGQ11LM) - 0x00158d008b3a83a9
## Zusammenfassung
**Unterstützt in devices.yaml:**
- 24 Lampen (lights)
- 2 Thermostate
**Nicht unterstützt:**
- 0 Switches
- 7 Tür-/Fenstersensoren
- 11 Temperatur-/Feuchtigkeitssensoren
- 2 Schalter (Button-Devices)
- 1 Bewegungsmelder
- 1 Wasserleck-Sensor
Die nicht unterstützten Geräte könnten in Zukunft durch Erweiterung des Systems integriert werden.

View File

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

@@ -19,6 +19,13 @@ from packages.home_capabilities import LIGHT_VERSION, THERMOSTAT_VERSION, LightS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# In-memory cache for last known device states
# Will be populated from Redis pub/sub messages
device_states: dict[str, dict[str, Any]] = {}
# Background task reference
background_task: asyncio.Task | None = None
app = FastAPI( app = FastAPI(
title="Home Automation API", title="Home Automation API",
description="API for home automation system", description="API for home automation system",
@@ -49,6 +56,77 @@ async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
async def redis_state_listener():
"""Background task that listens to Redis pub/sub and updates state cache."""
redis_client = None
pubsub = None
try:
redis_url, redis_channel = get_redis_settings()
logger.info(f"Starting Redis state listener for channel {redis_channel}")
redis_client = await aioredis.from_url(redis_url, decode_responses=True)
pubsub = redis_client.pubsub()
await pubsub.subscribe(redis_channel)
logger.info("Redis state listener connected")
while True:
try:
message = await asyncio.wait_for(
pubsub.get_message(ignore_subscribe_messages=True),
timeout=1.0
)
if message and message["type"] == "message":
data = message["data"]
try:
state_data = json.loads(data)
if state_data.get("type") == "state" and state_data.get("device_id"):
device_id = state_data["device_id"]
payload = state_data.get("payload", {})
device_states[device_id] = payload
logger.debug(f"Updated state cache for {device_id}: {payload}")
except Exception as e:
logger.warning(f"Failed to parse state data: {e}")
except asyncio.TimeoutError:
pass # No message, continue
except asyncio.CancelledError:
logger.info("Redis state listener cancelled")
raise
except Exception as e:
logger.error(f"Redis state listener error: {e}")
finally:
if pubsub:
await pubsub.unsubscribe(redis_channel)
await pubsub.close()
if redis_client:
await redis_client.close()
@app.on_event("startup")
async def startup_event():
"""Start background tasks on application startup."""
global background_task
background_task = asyncio.create_task(redis_state_listener())
logger.info("Started background Redis state listener")
@app.on_event("shutdown")
async def shutdown_event():
"""Clean up background tasks on application shutdown."""
global background_task
if background_task:
background_task.cancel()
try:
await background_task
except asyncio.CancelledError:
pass
logger.info("Stopped background Redis state listener")
@app.get("/spec") @app.get("/spec")
async def spec() -> dict[str, dict[str, str]]: async def spec() -> dict[str, dict[str, str]]:
"""Capability specification endpoint. """Capability specification endpoint.
@@ -182,6 +260,16 @@ async def get_devices() -> list[DeviceInfo]:
] ]
@app.get("/devices/states")
async def get_device_states() -> dict[str, dict[str, Any]]:
"""Get current states of all devices from in-memory cache.
Returns:
dict: Dictionary mapping device_id to state payload
"""
return device_states
@app.get("/layout") @app.get("/layout")
async def get_layout() -> dict[str, Any]: async def get_layout() -> dict[str, Any]:
"""Get UI layout configuration. """Get UI layout configuration.
@@ -287,7 +375,13 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
async def event_generator(request: Request) -> AsyncGenerator[str, None]: async def event_generator(request: Request) -> AsyncGenerator[str, None]:
"""Generate SSE events from Redis Pub/Sub. """Generate SSE events from Redis Pub/Sub with Safari compatibility.
Safari-compatible features:
- Immediate retry hint on connection
- Regular heartbeats every 15s (comment-only, no data)
- Proper flushing after each yield
- Graceful disconnect handling
Args: Args:
request: FastAPI request object for disconnect detection request: FastAPI request object for disconnect detection
@@ -295,17 +389,28 @@ async def event_generator(request: Request) -> AsyncGenerator[str, None]:
Yields: Yields:
str: SSE formatted event strings str: SSE formatted event strings
""" """
redis_url, redis_channel = get_redis_settings() redis_client = None
redis_client = await aioredis.from_url(redis_url, decode_responses=True) pubsub = None
pubsub = redis_client.pubsub()
try: try:
await pubsub.subscribe(redis_channel) # Send retry hint immediately for EventSource reconnect behavior
logger.info(f"SSE client connected, subscribed to {redis_channel}") yield "retry: 2500\n\n"
# Create heartbeat tracking # Try to connect to Redis
redis_url, redis_channel = get_redis_settings()
try:
redis_client = await aioredis.from_url(redis_url, decode_responses=True)
pubsub = redis_client.pubsub()
await pubsub.subscribe(redis_channel)
logger.info(f"SSE client connected, subscribed to {redis_channel}")
except Exception as e:
logger.warning(f"Redis unavailable, running in heartbeat-only mode: {e}")
redis_client = None
pubsub = None
# Heartbeat tracking
last_heartbeat = asyncio.get_event_loop().time() last_heartbeat = asyncio.get_event_loop().time()
heartbeat_interval = 25 heartbeat_interval = 15 # Safari-friendly: shorter interval
while True: while True:
# Check if client disconnected # Check if client disconnected
@@ -313,29 +418,67 @@ async def event_generator(request: Request) -> AsyncGenerator[str, None]:
logger.info("SSE client disconnected") logger.info("SSE client disconnected")
break break
# Try to get message (non-blocking) # Try to get message from Redis (if available)
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=0.1) if pubsub:
try:
message = await asyncio.wait_for(
pubsub.get_message(ignore_subscribe_messages=True),
timeout=0.1
)
if message and message["type"] == "message":
data = message["data"]
logger.debug(f"Sending SSE message: {data[:100]}...")
# Update in-memory cache with latest state
try:
state_data = json.loads(data)
if state_data.get("type") == "state" and state_data.get("device_id"):
device_states[state_data["device_id"]] = state_data.get("payload", {})
except Exception as e:
logger.warning(f"Failed to parse state data for cache: {e}")
yield f"event: message\ndata: {data}\n\n"
last_heartbeat = asyncio.get_event_loop().time()
continue # Skip sleep, check for more messages immediately
except asyncio.TimeoutError:
pass # No message, continue to heartbeat check
except Exception as e:
logger.error(f"Redis error: {e}")
# Continue with heartbeats even if Redis fails
# Handle actual data messages # Sleep briefly to avoid busy loop
if message and message["type"] == "message": await asyncio.sleep(0.1)
data = message["data"]
logger.debug(f"Sending SSE message: {data[:100]}...")
yield f"event: message\ndata: {data}\n\n"
last_heartbeat = asyncio.get_event_loop().time()
else:
# No message, sleep a bit to avoid busy loop
await asyncio.sleep(0.1)
# Send heartbeat every 25 seconds # Send heartbeat if interval elapsed
current_time = asyncio.get_event_loop().time() current_time = asyncio.get_event_loop().time()
if current_time - last_heartbeat >= heartbeat_interval: if current_time - last_heartbeat >= heartbeat_interval:
yield "event: ping\ndata: heartbeat\n\n" # Comment-style ping (Safari-compatible, no event type)
yield ": ping\n\n"
last_heartbeat = current_time last_heartbeat = current_time
except asyncio.CancelledError:
logger.info("SSE connection cancelled by client")
raise
except Exception as e:
logger.error(f"SSE error: {e}")
raise
finally: finally:
await pubsub.unsubscribe(redis_channel) # Cleanup Redis connection
await pubsub.aclose() if pubsub:
await redis_client.aclose() try:
await pubsub.unsubscribe(redis_channel)
await pubsub.aclose()
except Exception as e:
logger.error(f"Error closing pubsub: {e}")
if redis_client:
try:
await redis_client.aclose()
except Exception as e:
logger.error(f"Error closing redis: {e}")
logger.info("SSE connection closed") logger.info("SSE connection closed")
@@ -343,23 +486,28 @@ async def event_generator(request: Request) -> AsyncGenerator[str, None]:
async def realtime_events(request: Request) -> StreamingResponse: async def realtime_events(request: Request) -> StreamingResponse:
"""Server-Sent Events endpoint for real-time updates. """Server-Sent Events endpoint for real-time updates.
Safari-compatible SSE implementation:
- Immediate retry hint (2.5s reconnect delay)
- Heartbeat every 15s using comment syntax ": ping"
- Proper Cache-Control headers
- No buffering (nginx compatibility)
- Graceful Redis fallback (heartbeat-only mode)
Args: Args:
request: FastAPI request object request: FastAPI request object
Returns: Returns:
StreamingResponse: SSE stream of Redis messages StreamingResponse: SSE stream with Redis messages and heartbeats
""" """
return StreamingResponse( return StreamingResponse(
event_generator(request), event_generator(request),
media_type="text/event-stream", media_type="text/event-stream",
headers={ headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive", "Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering "X-Accel-Buffering": "no", # Disable nginx buffering
} }
) )
return {"message": f"Command sent to {device_id}"}
def main() -> None: def main() -> None:

View File

@@ -29,6 +29,16 @@
padding: 2rem; padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem; 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 { h1 {
@@ -36,6 +46,65 @@
margin-bottom: 0.5rem; 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 { .status {
display: inline-block; display: inline-block;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
@@ -57,27 +126,67 @@
.room { .room {
background: white; background: white;
border-radius: 20px; border-radius: 20px;
padding: 2rem; margin-bottom: 1rem;
margin-bottom: 2rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); 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 { .room:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); 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 { .room-title {
color: #333; color: #333;
font-size: 1.75rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 3px solid #667eea;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; 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 { .devices {
@@ -394,16 +503,30 @@
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>🏠 Home Automation</h1> <div class="header-content">
<p>Realtime Status: <span class="status disconnected" id="connection-status">Verbinde...</span></p> <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> </header>
{% if rooms %} {% if rooms %}
{% for room in rooms %} {% for room in rooms %}
<section class="room"> <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 %} {% for device in room.devices %}
<div class="device-card" data-device-id="{{ device.device_id }}"> <div class="device-card" data-device-id="{{ device.device_id }}">
<div class="device-header"> <div class="device-header">
@@ -515,6 +638,7 @@
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div>
</div> </div>
</section> </section>
{% endfor %} {% endfor %}
@@ -534,6 +658,50 @@
</div> </div>
<script> <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 // Set room icons based on room name
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const roomTitles = document.querySelectorAll('.room-title'); const roomTitles = document.querySelectorAll('.room-title');
@@ -558,14 +726,38 @@
}); });
}); });
// 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) // API_BASE injected from backend (supports Docker/K8s environments)
window.API_BASE = '{{ api_base }}'; window.API_BASE = '{{ api_base }}';
window.RUNTIME_CONFIG = window.RUNTIME_CONFIG || {};
// Helper function to construct API URLs // Helper function to construct API URLs
function api(url) { function api(url) {
return `${window.API_BASE}${url}`; return `${window.API_BASE}${url}`;
} }
// iOS/Safari Polyfill laden (nur wenn nötig)
(function() {
var isIOS = /iP(hone|od|ad)/.test(navigator.platform) ||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
if (isIOS && typeof window.EventSourcePolyfill === "undefined") {
var s = document.createElement("script");
s.src = "https://cdn.jsdelivr.net/npm/event-source-polyfill@1.0.31/src/eventsource.min.js";
s.onerror = function() {
console.warn("EventSource polyfill konnte nicht geladen werden");
};
document.head.appendChild(s);
}
})();
let eventSource = null; let eventSource = null;
let currentState = {}; let currentState = {};
let thermostatTargets = {}; let thermostatTargets = {};
@@ -737,6 +929,8 @@
toggleButton.textContent = 'Einschalten'; toggleButton.textContent = 'Einschalten';
toggleButton.className = 'toggle-button off'; toggleButton.className = 'toggle-button off';
} }
// Force reflow for iOS Safari
void toggleButton.offsetHeight;
} }
// Update brightness display and slider // Update brightness display and slider
@@ -820,167 +1014,170 @@
} }
} }
// Connect to SSE // Safari/iOS-kompatibler SSE Client mit Auto-Reconnect
let reconnectAttempts = 0; let reconnectDelay = 2500;
const maxReconnectDelay = 30000; // Max 30 seconds let reconnectTimer = null;
function connectSSE() { // Global handleSSE function für SSE-Nachrichten
// Close existing connection if any window.handleSSE = function(data) {
console.log('SSE message:', data);
addEvent(data);
// Update device state
if (data.type === 'state' && data.device_id && data.payload) {
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
if (!card) {
console.warn(`No card found for device ${data.device_id}`);
return;
}
// Check if it's a light
if (data.payload.power !== undefined) {
currentState[data.device_id] = data.payload.power;
updateDeviceUI(
data.device_id,
data.payload.power,
data.payload.brightness
);
}
// Check if it's a thermostat
if (data.payload.mode !== undefined || data.payload.target !== undefined || data.payload.current !== undefined) {
if (data.payload.mode !== undefined) {
thermostatModes[data.device_id] = data.payload.mode;
}
if (data.payload.target !== undefined) {
thermostatTargets[data.device_id] = data.payload.target;
}
updateThermostatUI(
data.device_id,
data.payload.current,
data.payload.target,
data.payload.mode
);
}
}
};
function cleanupSSE() {
if (eventSource) { if (eventSource) {
try { try {
eventSource.close(); eventSource.close();
} catch (e) { } catch(e) {
console.error('Error closing EventSource:', e); console.error('Error closing EventSource:', e);
} }
eventSource = null; eventSource = null;
} }
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function scheduleReconnect() {
if (reconnectTimer) return;
console.log(`Reconnecting in ${reconnectDelay}ms...`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connectSSE();
// Backoff bis 10s
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
}, reconnectDelay);
}
function connectSSE() {
cleanupSSE();
console.log(`Connecting to SSE... (attempt ${reconnectAttempts + 1})`); const REALTIME_URL = (window.RUNTIME_CONFIG && window.RUNTIME_CONFIG.REALTIME_URL)
? window.RUNTIME_CONFIG.REALTIME_URL
: api('/realtime');
console.log('Connecting to SSE:', REALTIME_URL);
try { try {
eventSource = new EventSource(api('/realtime')); // Verwende Polyfill wenn verfügbar, sonst native EventSource
const EventSourceImpl = window.EventSourcePolyfill || window.EventSource;
eventSource = new EventSourceImpl(REALTIME_URL, {
withCredentials: false
});
eventSource.onopen = () => { eventSource.onopen = function() {
console.log('SSE connected successfully'); console.log('SSE connected successfully');
reconnectAttempts = 0; // Reset counter on successful connection reconnectDelay = 2500; // Reset backoff
document.getElementById('connection-status').textContent = 'Verbunden'; document.getElementById('connection-status').textContent = 'Verbunden';
document.getElementById('connection-status').className = 'status connected'; document.getElementById('connection-status').className = 'status connected';
}; };
eventSource.addEventListener('message', (e) => { eventSource.onmessage = function(evt) {
const data = JSON.parse(e.data); if (!evt || !evt.data) return;
console.log('SSE message:', data);
addEvent(data); // Heartbeats beginnen mit ":" -> ignorieren
if (typeof evt.data === "string" && evt.data.charAt(0) === ":") {
// Update device state return;
if (data.type === 'state' && data.device_id && data.payload) {
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
if (!card) {
console.warn(`No card found for device ${data.device_id}`);
return;
}
// Check if it's a light
if (data.payload.power !== undefined) {
currentState[data.device_id] = data.payload.power;
updateDeviceUI(
data.device_id,
data.payload.power,
data.payload.brightness
);
}
// Check if it's a thermostat
if (data.payload.mode !== undefined || data.payload.target !== undefined || data.payload.current !== undefined) {
if (data.payload.mode !== undefined) {
thermostatModes[data.device_id] = data.payload.mode;
}
if (data.payload.target !== undefined) {
thermostatTargets[data.device_id] = data.payload.target;
}
updateThermostatUI(
data.device_id,
data.payload.current,
data.payload.target,
data.payload.mode
);
}
} }
});
try {
const data = JSON.parse(evt.data);
if (window.handleSSE) {
window.handleSSE(data);
}
} catch (e) {
console.error('Error parsing SSE message:', e);
}
};
eventSource.addEventListener('ping', (e) => { eventSource.onerror = function(error) {
console.log('Heartbeat received');
});
eventSource.onerror = (error) => {
console.error('SSE error:', error, 'readyState:', eventSource?.readyState); console.error('SSE error:', error, 'readyState:', eventSource?.readyState);
document.getElementById('connection-status').textContent = 'Getrennt'; document.getElementById('connection-status').textContent = 'Getrennt';
document.getElementById('connection-status').className = 'status disconnected'; document.getElementById('connection-status').className = 'status disconnected';
if (eventSource) { // Safari/iOS verliert Netz beim App-Switch: ruhig reconnecten
try { scheduleReconnect();
eventSource.close();
} catch (e) {
console.error('Error closing EventSource on error:', e);
}
eventSource = null;
}
// Exponential backoff with max delay
reconnectAttempts++;
const delay = Math.min(
1000 * Math.pow(2, reconnectAttempts - 1),
maxReconnectDelay
);
console.log(`Reconnecting in ${delay}ms... (attempt ${reconnectAttempts})`);
setTimeout(connectSSE, delay);
}; };
} catch (error) { } catch (error) {
console.error('Failed to create EventSource:', error); console.error('Failed to create EventSource:', error);
document.getElementById('connection-status').textContent = 'Getrennt'; document.getElementById('connection-status').textContent = 'Getrennt';
document.getElementById('connection-status').className = 'status disconnected'; document.getElementById('connection-status').className = 'status disconnected';
scheduleReconnect();
reconnectAttempts++;
const delay = Math.min(
1000 * Math.pow(2, reconnectAttempts - 1),
maxReconnectDelay
);
setTimeout(connectSSE, delay);
} }
} }
// Safari/iOS specific: Reconnect when page becomes visible // Visibility-Change Handler für iOS App-Switch
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') { if (!document.hidden) {
console.log('Page visible, checking SSE connection...'); // Wenn wieder sichtbar & keine offene Verbindung: verbinden
// Only reconnect if connection is actually dead (CLOSED = 2) if (!eventSource || eventSource.readyState !== 1) {
if (!eventSource || eventSource.readyState === EventSource.CLOSED) { console.log('Page visible again, reconnecting SSE...');
console.log('SSE connection dead, forcing reconnect...');
reconnectAttempts = 0; // Reset for immediate reconnect
connectSSE(); connectSSE();
} else {
console.log('SSE connection OK, readyState:', eventSource.readyState);
} }
} }
}); });
// Safari/iOS specific: Reconnect on page focus // Start SSE connection
window.addEventListener('focus', () => {
console.log('Window focused, checking SSE connection...');
// Only reconnect if connection is actually dead (CLOSED = 2)
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
console.log('SSE connection dead, forcing reconnect...');
reconnectAttempts = 0; // Reset for immediate reconnect
connectSSE();
} else {
console.log('SSE connection OK, readyState:', eventSource.readyState);
}
});
// Initialize
connectSSE(); connectSSE();
// Load initial device states // Load initial device states
async function loadDevices() { async function loadDevices() {
try { try {
const response = await fetch(api('/devices')); const response = await fetch(api('/devices/states'));
const devices = await response.json(); const states = await response.json();
console.log('Loaded initial device states:', devices); console.log('Loaded initial device states:', states);
// Update UI with initial states // Update UI with initial states
devices.forEach(device => { for (const [deviceId, state] of Object.entries(states)) {
if (device.type === 'light' && device.state) { if (state.power !== undefined) {
currentState[device.id] = device.state.power; // It's a light
updateDeviceUI(device.id, device.state.power, device.state.brightness); currentState[deviceId] = state.power;
} else if (device.type === 'thermostat' && device.state) { updateDeviceUI(deviceId, state.power, state.brightness);
if (device.state.mode) thermostatModes[device.id] = device.state.mode; } else if (state.mode !== undefined || state.target !== undefined) {
if (device.state.target) thermostatTargets[device.id] = device.state.target; // It's a thermostat
updateThermostatUI(device.id, device.state.current, device.state.target, device.state.mode); if (state.mode) thermostatModes[deviceId] = state.mode;
if (state.target) thermostatTargets[deviceId] = state.target;
updateThermostatUI(deviceId, state.current, state.target, state.mode);
} }
}); }
} catch (error) { } catch (error) {
console.error('Failed to load initial device states:', error); console.error('Failed to load initial device states:', error);
} }

View File

@@ -1,5 +1,4 @@
version: 1 version: 1
mqtt: mqtt:
broker: "172.16.2.16" broker: "172.16.2.16"
port: 1883 port: 1883
@@ -7,50 +6,407 @@ mqtt:
username: null username: null
password: null password: null
keepalive: 60 keepalive: 60
redis: redis:
url: "redis://172.23.1.116:6379/8" url: "redis://172.23.1.116:6379/8"
channel: "ui:updates" channel: "ui:updates"
devices: devices:
- device_id: test_lampe_1 - device_id: lampe_semeniere_wohnzimmer
type: light type: light
cap_version: "light@1.2.0" cap_version: "light@1.2.0"
technology: zigbee2mqtt technology: zigbee2mqtt
features: features:
power: true power: true
brightness: true brightness: false
topics: topics:
set: "vendor/test_lampe_1/set" state: "zigbee2mqtt/0xf0d1b8000015480b"
state: "vendor/test_lampe_1/state" set: "zigbee2mqtt/0xf0d1b8000015480b/set"
- device_id: test_lampe_2 metadata:
type: light friendly_name: "Lampe Semeniere Wohnzimmer"
cap_version: "light@1.2.0" ieee_address: "0xf0d1b8000015480b"
technology: zigbee2mqtt model: "AC10691"
features: vendor: "OSRAM"
power: true - device_id: grosse_lampe_wohnzimmer
topics: type: light
set: "vendor/test_lampe_2/set" cap_version: "light@1.2.0"
state: "vendor/test_lampe_2/state" technology: zigbee2mqtt
- device_id: test_lampe_3 features:
type: light power: true
cap_version: "light@1.2.0" brightness: false
technology: zigbee2mqtt topics:
features: state: "zigbee2mqtt/0xf0d1b80000151aca"
power: true set: "zigbee2mqtt/0xf0d1b80000151aca/set"
brightness: true metadata:
topics: friendly_name: "grosse Lampe Wohnzimmer"
set: "vendor/test_lampe_3/set" ieee_address: "0xf0d1b80000151aca"
state: "vendor/test_lampe_3/state" model: "AC10691"
- device_id: test_thermo_1 vendor: "OSRAM"
type: thermostat - device_id: lampe_naehtischchen_wohnzimmer
cap_version: "thermostat@2.0.0" type: light
technology: zigbee2mqtt cap_version: "light@1.2.0"
features: technology: zigbee2mqtt
mode: true features:
target: true power: true
current: true brightness: false
battery: true topics:
topics: state: "zigbee2mqtt/0x842e14fffee560ee"
set: "vendor/test_thermo_1/set" set: "zigbee2mqtt/0x842e14fffee560ee/set"
state: "vendor/test_thermo_1/state" metadata:
friendly_name: "Lampe Naehtischchen Wohnzimmer"
ieee_address: "0x842e14fffee560ee"
model: "HG06337"
vendor: "Lidl"
- device_id: kleine_lampe_rechts_esszimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000156645"
set: "zigbee2mqtt/0xf0d1b80000156645/set"
metadata:
friendly_name: "kleine Lampe rechts Esszimmer"
ieee_address: "0xf0d1b80000156645"
model: "AC10691"
vendor: "OSRAM"
- device_id: kleine_lampe_links_esszimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000153099"
set: "zigbee2mqtt/0xf0d1b80000153099/set"
metadata:
friendly_name: "kleine Lampe links Esszimmer"
ieee_address: "0xf0d1b80000153099"
model: "AC10691"
vendor: "OSRAM"
- device_id: leselampe_esszimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0xec1bbdfffe7b84f2"
set: "zigbee2mqtt/0xec1bbdfffe7b84f2/set"
metadata:
friendly_name: "Leselampe Esszimmer"
ieee_address: "0xec1bbdfffe7b84f2"
model: "LED1842G3"
vendor: "IKEA"
- device_id: medusalampe_schlafzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000154c7c"
set: "zigbee2mqtt/0xf0d1b80000154c7c/set"
metadata:
friendly_name: "Medusa-Lampe Schlafzimmer"
ieee_address: "0xf0d1b80000154c7c"
model: "AC10691"
vendor: "OSRAM"
- device_id: sportlicht_am_fernseher_studierzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
color_temperature: true
topics:
state: "zigbee2mqtt/0x842e14fffe76a23a"
set: "zigbee2mqtt/0x842e14fffe76a23a/set"
metadata:
friendly_name: "Sportlicht am Fernseher, Studierzimmer"
ieee_address: "0x842e14fffe76a23a"
model: "LED1733G7"
vendor: "IKEA"
- device_id: deckenlampe_schlafzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x0017880108a406a7"
set: "zigbee2mqtt/0x0017880108a406a7/set"
metadata:
friendly_name: "Deckenlampe Schlafzimmer"
ieee_address: "0x0017880108a406a7"
model: "8718699688882"
vendor: "Philips"
- device_id: bettlicht_wolfgang
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x00178801081570bf"
set: "zigbee2mqtt/0x00178801081570bf/set"
metadata:
friendly_name: "Bettlicht Wolfgang"
ieee_address: "0x00178801081570bf"
model: "9290020399"
vendor: "Philips"
- device_id: bettlicht_patty
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x0017880108158b32"
set: "zigbee2mqtt/0x0017880108158b32/set"
metadata:
friendly_name: "Bettlicht Patty"
ieee_address: "0x0017880108158b32"
model: "9290020399"
vendor: "Philips"
- device_id: schranklicht_hinten_patty
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x0017880106e29571"
set: "zigbee2mqtt/0x0017880106e29571/set"
metadata:
friendly_name: "Schranklicht hinten Patty"
ieee_address: "0x0017880106e29571"
model: "8718699673147"
vendor: "Philips"
- device_id: schranklicht_vorne_patty
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000154cf5"
set: "zigbee2mqtt/0xf0d1b80000154cf5/set"
metadata:
friendly_name: "Schranklicht vorne Patty"
ieee_address: "0xf0d1b80000154cf5"
model: "AC10691"
vendor: "OSRAM"
- device_id: leselampe_patty
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x001788010600ec9d"
set: "zigbee2mqtt/0x001788010600ec9d/set"
metadata:
friendly_name: "Leselampe Patty"
ieee_address: "0x001788010600ec9d"
model: "8718699673147"
vendor: "Philips"
- device_id: deckenlampe_esszimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x0017880108a03e45"
set: "zigbee2mqtt/0x0017880108a03e45/set"
metadata:
friendly_name: "Deckenlampe Esszimmer"
ieee_address: "0x0017880108a03e45"
model: "929002241201"
vendor: "Philips"
- device_id: standlampe_esszimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
color_temperature: true
topics:
state: "zigbee2mqtt/0xbc33acfffe21f547"
set: "zigbee2mqtt/0xbc33acfffe21f547/set"
metadata:
friendly_name: "Standlampe Esszimmer"
ieee_address: "0xbc33acfffe21f547"
model: "LED1732G11"
vendor: "IKEA"
- device_id: haustuer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0xec1bbdfffea6a3da"
set: "zigbee2mqtt/0xec1bbdfffea6a3da/set"
metadata:
friendly_name: "Haustür"
ieee_address: "0xec1bbdfffea6a3da"
model: "LED1842G3"
vendor: "IKEA"
- device_id: deckenlampe_flur_oben
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
color_temperature: true
topics:
state: "zigbee2mqtt/0x001788010d2123a7"
set: "zigbee2mqtt/0x001788010d2123a7/set"
metadata:
friendly_name: "Deckenlampe Flur oben"
ieee_address: "0x001788010d2123a7"
model: "929003099001"
vendor: "Philips"
- device_id: kueche_deckenlampe
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0x001788010d2c40c4"
set: "zigbee2mqtt/0x001788010d2c40c4/set"
metadata:
friendly_name: "Küche Deckenlampe"
ieee_address: "0x001788010d2c40c4"
model: "929002469202"
vendor: "Philips"
- device_id: sportlicht_tisch
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0xf0d1b8be2409f31b"
set: "zigbee2mqtt/0xf0d1b8be2409f31b/set"
metadata:
friendly_name: "Sportlicht Tisch"
ieee_address: "0xf0d1b8be2409f31b"
model: "4058075729063"
vendor: "LEDVANCE"
- device_id: sportlicht_regal
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0xf0d1b8be2409f569"
set: "zigbee2mqtt/0xf0d1b8be2409f569/set"
metadata:
friendly_name: "Sportlicht Regal"
ieee_address: "0xf0d1b8be2409f569"
model: "4058075729063"
vendor: "LEDVANCE"
- device_id: licht_flur_oben_am_spiegel
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
color_temperature: true
topics:
state: "zigbee2mqtt/0x842e14fffefe4ba4"
set: "zigbee2mqtt/0x842e14fffefe4ba4/set"
metadata:
friendly_name: "Licht Flur oben am Spiegel"
ieee_address: "0x842e14fffefe4ba4"
model: "LED1732G11"
vendor: "IKEA"
- device_id: experimentlabtest
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: true
topics:
state: "zigbee2mqtt/0xf0d1b80000195038"
set: "zigbee2mqtt/0xf0d1b80000195038/set"
metadata:
friendly_name: "ExperimentLabTest"
ieee_address: "0xf0d1b80000195038"
model: "4058075208421"
vendor: "LEDVANCE"
- device_id: thermostat_wolfgang
type: thermostat
cap_version: "thermostat@1.0.0"
technology: zigbee2mqtt
features:
heating: true
temperature_range:
- 5
- 30
temperature_step: 0.5
topics:
state: "zigbee2mqtt/0x540f57fffe7e3cfe"
set: "zigbee2mqtt/0x540f57fffe7e3cfe/set"
metadata:
friendly_name: "Wolfgang"
ieee_address: "0x540f57fffe7e3cfe"
model: "GS361A-H04"
vendor: "Siterwell"
- device_id: thermostat_kueche
type: thermostat
cap_version: "thermostat@1.0.0"
technology: zigbee2mqtt
features:
heating: true
temperature_range:
- 5
- 30
temperature_step: 0.5
topics:
state: "zigbee2mqtt/0x94deb8fffe2e5c06"
set: "zigbee2mqtt/0x94deb8fffe2e5c06/set"
metadata:
friendly_name: "Kueche"
ieee_address: "0x94deb8fffe2e5c06"
model: "GS361A-H04"
vendor: "Siterwell"
- device_id: sterne_wohnzimmer
type: light
cap_version: "light@1.2.0"
technology: zigbee2mqtt
features:
power: true
brightness: false
topics:
state: "zigbee2mqtt/0xf0d1b80000155fc2"
set: "zigbee2mqtt/0xf0d1b80000155fc2/set"
metadata:
friendly_name: "Sterne Wohnzimmer"
ieee_address: "0xf0d1b80000155fc2"
model: "AC10691"
vendor: "OSRAM"

View File

@@ -0,0 +1,66 @@
version: 1
mqtt:
broker: "172.16.2.16"
port: 1883
client_id: "home-automation-abstraction"
username: null
password: null
keepalive: 60
redis:
url: "redis://172.23.1.116:6379/8"
channel: "ui:updates"
devices:
- device_id: test_lampe_1
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
brightness: true
topics:
set: "vendor/test_lampe_1/set"
state: "vendor/test_lampe_1/state"
- device_id: test_lampe_2
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
topics:
set: "vendor/test_lampe_2/set"
state: "vendor/test_lampe_2/state"
- device_id: test_lampe_3
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
brightness: true
topics:
set: "vendor/test_lampe_3/set"
state: "vendor/test_lampe_3/state"
- device_id: test_thermo_1
type: thermostat
cap_version: "thermostat@2.0.0"
technology: simulator
features:
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

@@ -0,0 +1,66 @@
version: 1
mqtt:
broker: "172.16.2.16"
port: 1883
client_id: "home-automation-abstraction"
username: null
password: null
keepalive: 60
redis:
url: "redis://172.23.1.116:6379/8"
channel: "ui:updates"
devices:
- device_id: test_lampe_1
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
brightness: true
topics:
set: "vendor/test_lampe_1/set"
state: "vendor/test_lampe_1/state"
- device_id: test_lampe_2
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
topics:
set: "vendor/test_lampe_2/set"
state: "vendor/test_lampe_2/state"
- device_id: test_lampe_3
type: light
cap_version: "light@1.2.0"
technology: simulator
features:
power: true
brightness: true
topics:
set: "vendor/test_lampe_3/set"
state: "vendor/test_lampe_3/state"
- device_id: test_thermo_1
type: thermostat
cap_version: "thermostat@2.0.0"
technology: simulator
features:
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

@@ -1,29 +1,121 @@
# UI Layout Configuration
# Defines rooms and device tiles for the home automation UI
rooms: rooms:
- name: Wohnzimmer - name: Schlafzimmer
devices: devices:
- device_id: test_lampe_2 - device_id: bettlicht_patty
title: Deckenlampe title: Bettlicht Patty
icon: "💡" icon: 🛏️
rank: 5 rank: 10
- device_id: test_lampe_1 - device_id: bettlicht_wolfgang
title: Stehlampe title: Bettlicht Wolfgang
icon: "🔆" icon: 🛏️
rank: 10 rank: 20
- device_id: test_thermo_1 - device_id: deckenlampe_schlafzimmer
title: Thermostat title: Deckenlampe Schlafzimmer
icon: "🌡️" icon: 💡
rank: 15 rank: 30
- device_id: medusalampe_schlafzimmer
- name: Schlafzimmer title: Medusa-Lampe Schlafzimmer
devices: icon: 💡
- device_id: test_lampe_3 rank: 40
title: Nachttischlampe - name: Esszimmer
icon: "🛏️" devices:
rank: 10 - device_id: deckenlampe_esszimmer
title: Deckenlampe Esszimmer
icon: 💡
rank: 50
- device_id: leselampe_esszimmer
title: Leselampe Esszimmer
icon: 💡
rank: 60
- device_id: standlampe_esszimmer
title: Standlampe Esszimmer
icon: 💡
rank: 70
- device_id: kleine_lampe_links_esszimmer
title: kleine Lampe links Esszimmer
icon: 💡
rank: 80
- device_id: kleine_lampe_rechts_esszimmer
title: kleine Lampe rechts Esszimmer
icon: 💡
rank: 90
- name: Wohnzimmer
devices:
- device_id: lampe_naehtischchen_wohnzimmer
title: Lampe Naehtischchen Wohnzimmer
icon: 💡
rank: 100
- device_id: lampe_semeniere_wohnzimmer
title: Lampe Semeniere Wohnzimmer
icon: 💡
rank: 110
- device_id: sterne_wohnzimmer
title: Sterne Wohnzimmer
icon: 💡
rank: 120
- device_id: grosse_lampe_wohnzimmer
title: grosse Lampe Wohnzimmer
icon: 💡
rank: 130
- name: Küche
devices:
- device_id: kueche_deckenlampe
title: Küche Deckenlampe
icon: 💡
rank: 140
- device_id: thermostat_kueche
title: Kueche
icon: 🌡️
rank: 150
- name: Arbeitszimmer Patty
devices:
- device_id: leselampe_patty
title: Leselampe Patty
icon: 💡
rank: 160
- device_id: schranklicht_hinten_patty
title: Schranklicht hinten Patty
icon: 💡
rank: 170
- device_id: schranklicht_vorne_patty
title: Schranklicht vorne Patty
icon: 💡
rank: 180
- name: Arbeitszimmer Wolfgang
devices:
- device_id: thermostat_wolfgang
title: Wolfgang
icon: 🌡️
rank: 190
- device_id: experimentlabtest
title: ExperimentLabTest
icon: 💡
rank: 200
- name: Flur
devices:
- device_id: deckenlampe_flur_oben
title: Deckenlampe Flur oben
icon: 💡
rank: 210
- device_id: haustuer
title: Haustür
icon: 💡
rank: 220
- device_id: licht_flur_oben_am_spiegel
title: Licht Flur oben am Spiegel
icon: 💡
rank: 230
- name: Sportzimmer
devices:
- device_id: sportlicht_regal
title: Sportlicht Regal
icon: 🏃
rank: 240
- device_id: sportlicht_tisch
title: Sportlicht Tisch
icon: 🏃
rank: 250
- device_id: sportlicht_am_fernseher_studierzimmer
title: Sportlicht am Fernseher, Studierzimmer
icon: 🏃
rank: 260

View File

@@ -0,0 +1,35 @@
# UI Layout Configuration
# Defines rooms and device tiles for the home automation UI
rooms:
- name: Wohnzimmer
devices:
- device_id: test_lampe_2
title: Deckenlampe
icon: "💡"
rank: 5
- device_id: test_lampe_1
title: Stehlampe
icon: "🔆"
rank: 10
- device_id: test_thermo_1
title: Thermostat
icon: "🌡️"
rank: 15
- name: Schlafzimmer
devices:
- device_id: test_lampe_3
title: Nachttischlampe
icon: "🛏️"
rank: 10
- name: Lab
devices:
- device_id: experiment_light_1
title: Experimentierlampe
icon: "💡"
rank: 10