Rewrite HAOS integration: target-centric architecture with KC color sensors
- Rewrite integration to target-centric model: each picture target becomes a HA device under a server hub with switch, FPS, and status sensors - Replace KC light entities with color sensors (hex state + RGB attributes) for better automation support via WebSocket real-time updates - Add WebSocket manager for Key Colors color streaming - Add KC per-stage timing metrics (calc_colors, broadcast) with rolling avg - Fix KC timing fields missing from API by adding them to Pydantic schema - Make start/stop processing idempotent to prevent intermittent 404 errors - Add HAOS localization support (en, ru) using translation_key system - Rename integration from "WLED Screen Controller" to "LED Screen Controller" - Remove obsolete select.py (display select) and README.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
136
custom_components/wled_screen_controller/ws_manager.py
Normal file
136
custom_components/wled_screen_controller/ws_manager.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""WebSocket connection manager for Key Colors target color streams."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import WS_RECONNECT_DELAY, WS_MAX_RECONNECT_DELAY
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KeyColorsWebSocketManager:
|
||||
"""Manages WebSocket connections for Key Colors target color streams."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
server_url: str,
|
||||
api_key: str,
|
||||
) -> None:
|
||||
self._hass = hass
|
||||
self._server_url = server_url
|
||||
self._api_key = api_key
|
||||
self._connections: dict[str, asyncio.Task] = {}
|
||||
self._callbacks: dict[str, list[Callable]] = {}
|
||||
self._latest_colors: dict[str, dict[str, dict[str, int]]] = {}
|
||||
self._shutting_down = False
|
||||
|
||||
def _get_ws_url(self, target_id: str) -> str:
|
||||
"""Build WebSocket URL for a target."""
|
||||
ws_base = self._server_url.replace("http://", "ws://").replace(
|
||||
"https://", "wss://"
|
||||
)
|
||||
return f"{ws_base}/api/v1/picture-targets/{target_id}/ws?token={self._api_key}"
|
||||
|
||||
async def start_listening(self, target_id: str) -> None:
|
||||
"""Start WebSocket connection for a target."""
|
||||
if target_id in self._connections:
|
||||
return
|
||||
task = self._hass.async_create_background_task(
|
||||
self._ws_loop(target_id),
|
||||
f"wled_screen_controller_ws_{target_id}",
|
||||
)
|
||||
self._connections[target_id] = task
|
||||
|
||||
async def stop_listening(self, target_id: str) -> None:
|
||||
"""Stop WebSocket connection for a target."""
|
||||
task = self._connections.pop(target_id, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
self._latest_colors.pop(target_id, None)
|
||||
|
||||
def register_callback(
|
||||
self, target_id: str, callback: Callable
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback for color updates. Returns unregister function."""
|
||||
self._callbacks.setdefault(target_id, []).append(callback)
|
||||
|
||||
def unregister() -> None:
|
||||
cbs = self._callbacks.get(target_id)
|
||||
if cbs and callback in cbs:
|
||||
cbs.remove(callback)
|
||||
|
||||
return unregister
|
||||
|
||||
def get_latest_colors(self, target_id: str) -> dict[str, dict[str, int]]:
|
||||
"""Get latest colors for a target."""
|
||||
return self._latest_colors.get(target_id, {})
|
||||
|
||||
async def _ws_loop(self, target_id: str) -> None:
|
||||
"""WebSocket connection loop with reconnection."""
|
||||
delay = WS_RECONNECT_DELAY
|
||||
session = async_get_clientsession(self._hass)
|
||||
|
||||
while not self._shutting_down:
|
||||
try:
|
||||
url = self._get_ws_url(target_id)
|
||||
async with session.ws_connect(url) as ws:
|
||||
delay = WS_RECONNECT_DELAY # reset on successful connect
|
||||
_LOGGER.debug("WS connected for target %s", target_id)
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
self._handle_message(target_id, msg.data)
|
||||
elif msg.type in (
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
):
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
|
||||
_LOGGER.debug("WS connection error for %s: %s", target_id, err)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Unexpected WS error for %s: %s", target_id, err)
|
||||
|
||||
if self._shutting_down:
|
||||
break
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, WS_MAX_RECONNECT_DELAY)
|
||||
|
||||
def _handle_message(self, target_id: str, raw: str) -> None:
|
||||
"""Handle incoming WebSocket message."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
|
||||
if data.get("type") != "colors_update":
|
||||
return
|
||||
|
||||
colors: dict[str, Any] = data.get("colors", {})
|
||||
self._latest_colors[target_id] = colors
|
||||
|
||||
for cb in self._callbacks.get(target_id, []):
|
||||
try:
|
||||
cb(colors)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error in WS color callback for %s", target_id)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Stop all WebSocket connections."""
|
||||
self._shutting_down = True
|
||||
for target_id in list(self._connections):
|
||||
await self.stop_listening(target_id)
|
||||
Reference in New Issue
Block a user