Optimize KC processing and add reactive HAOS state updates

- Optimize KC frame processing: downsample to 160x90 with cv2.resize
  before rectangle extraction, pre-compute pixel coords, vectorize
  smoothing with numpy arrays
- Add WebSocket event stream for server state changes: processor manager
  fires events on start/stop, new /api/v1/events/ws endpoint streams
  them to connected clients
- Add HAOS EventStreamListener that triggers coordinator refresh on
  state changes for near-instant switch updates
- Reduce HAOS polling interval from 10s to 3s for fresher FPS metrics
- Fix overlay button tooltips: flatten nested JSON keys in locale files
  to match flat dot-notation lookup used by t() function

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 14:21:47 +03:00
parent 67da014684
commit 3ee17ed083
7 changed files with 232 additions and 61 deletions

View File

@@ -18,8 +18,10 @@ from .const import (
TARGET_TYPE_KEY_COLORS,
DATA_COORDINATOR,
DATA_WS_MANAGER,
DATA_EVENT_LISTENER,
)
from .coordinator import WLEDScreenControllerCoordinator
from .event_listener import EventStreamListener
from .ws_manager import KeyColorsWebSocketManager
_LOGGER = logging.getLogger(__name__)
@@ -48,6 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
ws_manager = KeyColorsWebSocketManager(hass, server_url, api_key)
event_listener = EventStreamListener(hass, server_url, api_key, coordinator)
await event_listener.start()
# Create device entries for each target
device_registry = dr.async_get(hass)
if coordinator.data and "targets" in coordinator.data:
@@ -73,6 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
DATA_WS_MANAGER: ws_manager,
DATA_EVENT_LISTENER: event_listener,
}
# Track target IDs to detect changes
@@ -114,10 +120,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
ws_manager: KeyColorsWebSocketManager = hass.data[DOMAIN][entry.entry_id][
DATA_WS_MANAGER
]
await ws_manager.shutdown()
entry_data = hass.data[DOMAIN][entry.entry_id]
await entry_data[DATA_WS_MANAGER].shutdown()
await entry_data[DATA_EVENT_LISTENER].shutdown()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -7,7 +7,7 @@ CONF_SERVER_URL = "server_url"
CONF_API_KEY = "api_key"
# Default values
DEFAULT_SCAN_INTERVAL = 10 # seconds
DEFAULT_SCAN_INTERVAL = 3 # seconds
DEFAULT_TIMEOUT = 10 # seconds
WS_RECONNECT_DELAY = 5 # seconds
WS_MAX_RECONNECT_DELAY = 60 # seconds
@@ -19,3 +19,4 @@ TARGET_TYPE_KEY_COLORS = "key_colors"
# Data keys stored in hass.data[DOMAIN][entry_id]
DATA_COORDINATOR = "coordinator"
DATA_WS_MANAGER = "ws_manager"
DATA_EVENT_LISTENER = "event_listener"

View File

@@ -0,0 +1,95 @@
"""WebSocket event listener for server state change notifications."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import WS_RECONNECT_DELAY, WS_MAX_RECONNECT_DELAY
_LOGGER = logging.getLogger(__name__)
class EventStreamListener:
"""Listens to server WS endpoint for state change events.
Triggers a coordinator refresh whenever a target starts or stops processing,
so HAOS entities react near-instantly to external state changes.
"""
def __init__(
self,
hass: HomeAssistant,
server_url: str,
api_key: str,
coordinator: DataUpdateCoordinator,
) -> None:
self._hass = hass
self._server_url = server_url
self._api_key = api_key
self._coordinator = coordinator
self._task: asyncio.Task | None = None
self._shutting_down = False
async def start(self) -> None:
"""Start listening to the event stream."""
self._task = self._hass.async_create_background_task(
self._ws_loop(),
"wled_screen_controller_events",
)
async def _ws_loop(self) -> None:
"""WebSocket connection loop with reconnection."""
delay = WS_RECONNECT_DELAY
session = async_get_clientsession(self._hass)
ws_base = self._server_url.replace("http://", "ws://").replace(
"https://", "wss://"
)
url = f"{ws_base}/api/v1/events/ws?token={self._api_key}"
while not self._shutting_down:
try:
async with session.ws_connect(url) as ws:
delay = WS_RECONNECT_DELAY # reset on successful connect
_LOGGER.debug("Event stream connected")
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
except json.JSONDecodeError:
continue
if data.get("type") == "state_change":
await self._coordinator.async_request_refresh()
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("Event stream connection error: %s", err)
except Exception as err:
_LOGGER.error("Unexpected event stream error: %s", err)
if self._shutting_down:
break
await asyncio.sleep(delay)
delay = min(delay * 2, WS_MAX_RECONNECT_DELAY)
async def shutdown(self) -> None:
"""Stop listening."""
self._shutting_down = True
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None