Rename all Python modules, classes, API endpoints, config keys, frontend fetch URLs, and Home Assistant integration URLs from picture-targets to output-targets. Store loads both new and legacy JSON keys for backward compatibility with existing data files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
137 lines
4.8 KiB
Python
137 lines
4.8 KiB
Python
"""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/output-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)
|