zwei Lampen und Test-Werkzeug

This commit is contained in:
2025-10-31 15:17:28 +01:00
parent ea17d048ad
commit c3ec6e3fc4
7 changed files with 949 additions and 43 deletions

View File

@@ -1,14 +1,17 @@
"""API main entry point."""
import asyncio
import json
import os
from pathlib import Path
from typing import Any
from typing import Any, AsyncGenerator
import redis.asyncio as aioredis
import yaml
from aiomqtt import Client
from fastapi import FastAPI, HTTPException, status
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ValidationError
from packages.home_capabilities import CAP_VERSION, LightState
@@ -85,7 +88,12 @@ def load_devices() -> list[dict[str, Any]]:
with open(config_path, "r") as f:
config = yaml.safe_load(f)
return config.get("devices", [])
# Normalize device entries: accept both 'id' and 'device_id', use 'device_id' internally
devices = config.get("devices", [])
for device in devices:
device["device_id"] = device.pop("device_id", device.pop("id", None))
return devices
def get_mqtt_settings() -> tuple[str, int]:
@@ -99,6 +107,25 @@ def get_mqtt_settings() -> tuple[str, int]:
return host, port
def get_redis_settings() -> tuple[str, str]:
"""Get Redis settings from configuration.
Returns:
tuple: (url, channel)
"""
config_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
if config_path.exists():
with open(config_path, "r") as f:
config = yaml.safe_load(f)
redis_config = config.get("redis", {})
url = redis_config.get("url", "redis://localhost:6379/0")
channel = redis_config.get("channel", "ui:updates")
return url, channel
return "redis://localhost:6379/0", "ui:updates"
async def publish_mqtt(topic: str, payload: dict[str, Any]) -> None:
"""Publish message to MQTT broker.
@@ -123,9 +150,9 @@ async def get_devices() -> list[DeviceInfo]:
devices = load_devices()
return [
DeviceInfo(
device_id=device["id"],
device_id=device["device_id"],
type=device["type"],
name=device.get("name", device["id"])
name=device.get("name", device["device_id"])
)
for device in devices
]
@@ -147,7 +174,7 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
"""
# Load devices and check if device exists
devices = load_devices()
device = next((d for d in devices if d["id"] == device_id), None)
device = next((d for d in devices if d["device_id"] == device_id), None)
if not device:
raise HTTPException(
@@ -182,6 +209,81 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
return {"message": f"Command sent to {device_id}"}
async def event_generator(request: Request) -> AsyncGenerator[str, None]:
"""Generate SSE events from Redis Pub/Sub.
Args:
request: FastAPI request object for disconnect detection
Yields:
str: SSE formatted event strings
"""
redis_url, redis_channel = get_redis_settings()
redis_client = await aioredis.from_url(redis_url, decode_responses=True)
pubsub = redis_client.pubsub()
try:
await pubsub.subscribe(redis_channel)
# Create heartbeat task
last_heartbeat = asyncio.get_event_loop().time()
while True:
# Check if client disconnected
if await request.is_disconnected():
break
# Get message with timeout for heartbeat
try:
message = await asyncio.wait_for(
pubsub.get_message(ignore_subscribe_messages=True),
timeout=1.0
)
if message and message["type"] == "message":
# Send data event
data = message["data"]
yield f"event: message\ndata: {data}\n\n"
last_heartbeat = asyncio.get_event_loop().time()
except asyncio.TimeoutError:
pass
# Send heartbeat every 25 seconds
current_time = asyncio.get_event_loop().time()
if current_time - last_heartbeat >= 25:
yield "event: ping\ndata: heartbeat\n\n"
last_heartbeat = current_time
finally:
await pubsub.unsubscribe(redis_channel)
await pubsub.close()
await redis_client.close()
@app.get("/realtime")
async def realtime_events(request: Request) -> StreamingResponse:
"""Server-Sent Events endpoint for real-time updates.
Args:
request: FastAPI request object
Returns:
StreamingResponse: SSE stream of Redis messages
"""
return StreamingResponse(
event_generator(request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
}
)
return {"message": f"Command sent to {device_id}"}
def main() -> None:
"""Run the API application with uvicorn."""
import uvicorn