diff --git a/custom_components/wled_screen_controller/README.md b/custom_components/wled_screen_controller/README.md deleted file mode 100644 index 647f9e5..0000000 --- a/custom_components/wled_screen_controller/README.md +++ /dev/null @@ -1,214 +0,0 @@ -# WLED Screen Controller - Home Assistant Integration - -Native Home Assistant integration for WLED Screen Controller with full HACS support. - -## Overview - -This integration connects Home Assistant to the WLED Screen Controller server, providing: - -- 🎛️ **Switch Entities** - Turn processing on/off per device -- 📊 **Sensor Entities** - Monitor FPS, status, and frame count -- 🖥️ **Select Entities** - Choose which display to capture -- 🔄 **Auto-Discovery** - Devices appear automatically -- 📦 **HACS Compatible** - Install directly from HACS -- ⚙️ **Config Flow** - Easy setup through UI - -## Installation - -### Method 1: HACS (Recommended) - -1. **Install HACS** if you haven't already: - - Visit https://hacs.xyz/docs/setup/download - -2. **Add Custom Repository:** - - Open HACS in Home Assistant - - Click the menu (⋮) → Custom repositories - - Add URL: `https://github.com/yourusername/wled-screen-controller` - - Category: **Integration** - - Click **Add** - -3. **Install Integration:** - - In HACS, search for "WLED Screen Controller" - - Click **Download** - - Restart Home Assistant - -4. **Configure:** - - Go to Settings → Devices & Services - - Click **+ Add Integration** - - Search for "WLED Screen Controller" - - Enter your server URL (e.g., `http://192.168.1.100:8080`) - - Click **Submit** - -### Method 2: Manual Installation - -1. **Download:** - ```bash - cd /config # Your Home Assistant config directory - mkdir -p custom_components - ``` - -2. **Copy Files:** - Copy the entire `custom_components/wled_screen_controller` folder to your Home Assistant `custom_components/` directory. - -3. **Restart Home Assistant** - -4. **Configure:** - - Settings → Devices & Services → Add Integration - - Search for "WLED Screen Controller" - -## Configuration - -### Initial Setup - -When adding the integration, you'll be prompted for: - -- **Name**: Friendly name for the integration (default: "WLED Screen Controller") -- **Server URL**: URL of your WLED Screen Controller server (e.g., `http://192.168.1.100:8080`) - -The integration will automatically: -- Verify connection to the server -- Discover all configured WLED devices -- Create entities for each device - -### Entities Created - -For each WLED device, the following entities are created: - -#### Switch Entities - -**`switch.{device_name}_processing`** -- Controls processing on/off for the device -- Attributes: - - `device_id`: Internal device ID - - `fps_target`: Target FPS - - `fps_actual`: Current FPS - - `display_index`: Active display - - `frames_processed`: Total frames - - `errors_count`: Error count - - `uptime_seconds`: Processing uptime - -#### Sensor Entities - -**`sensor.{device_name}_fps`** -- Current FPS value -- Unit: FPS -- Attributes: - - `target_fps`: Target FPS setting - -**`sensor.{device_name}_status`** -- Processing status -- States: `processing`, `idle`, `unavailable`, `unknown` - -**`sensor.{device_name}_frames_processed`** -- Total frames processed counter -- Continuously increasing while processing - -#### Select Entities - -**`select.{device_name}_display`** -- Select which display to capture -- Options: `Display 0`, `Display 1`, etc. -- Changes take effect immediately - -## Usage Examples - -### Basic Automation - -Turn on processing when TV turns on: - -```yaml -automation: - - alias: "Auto Start WLED with TV" - trigger: - - platform: state - entity_id: media_player.living_room_tv - to: "on" - action: - - service: switch.turn_on - target: - entity_id: switch.living_room_wled_processing - - - alias: "Auto Stop WLED with TV" - trigger: - - platform: state - entity_id: media_player.living_room_tv - to: "off" - action: - - service: switch.turn_off - target: - entity_id: switch.living_room_wled_processing -``` - -### Lovelace UI Examples - -#### Simple Card - -```yaml -type: entities -title: WLED Screen Controller -entities: - - entity: switch.living_room_wled_processing - - entity: sensor.living_room_wled_fps - - entity: sensor.living_room_wled_status - - entity: select.living_room_wled_display -``` - -#### Advanced Card - -```yaml -type: vertical-stack -cards: - - type: entity - entity: switch.living_room_wled_processing - name: Ambient Lighting - icon: mdi:television-ambient-light - - - type: conditional - conditions: - - entity: switch.living_room_wled_processing - state: "on" - card: - type: entities - entities: - - entity: sensor.living_room_wled_fps - name: Current FPS - - entity: sensor.living_room_wled_frames_processed - name: Frames Processed - - entity: select.living_room_wled_display - name: Display Selection -``` - -## Troubleshooting - -### Integration Not Appearing - -1. Check HACS installation -2. Clear browser cache -3. Restart Home Assistant -4. Check logs: Settings → System → Logs - -### Connection Errors - -1. Verify server is running: - ```bash - curl http://YOUR_SERVER_IP:8080/health - ``` - -2. Check firewall settings -3. Ensure Home Assistant can reach server -4. Try http:// not https:// - -### Entities Not Updating - -1. Check coordinator logs -2. Verify server has devices -3. Restart integration - -## Support - -- 📖 [Full Documentation](../../INSTALLATION.md) -- 🐛 [Report Issues](https://github.com/yourusername/wled-screen-controller/issues) - -## License - -MIT License - see [../../LICENSE](../../LICENSE) diff --git a/custom_components/wled_screen_controller/__init__.py b/custom_components/wled_screen_controller/__init__.py index b4eb1bd..078dcc2 100644 --- a/custom_components/wled_screen_controller/__init__.py +++ b/custom_components/wled_screen_controller/__init__.py @@ -1,84 +1,112 @@ -"""The WLED Screen Controller integration.""" +"""The LED Screen Controller integration.""" from __future__ import annotations import logging from datetime import timedelta -from urllib.parse import urlparse from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform, CONF_NAME +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, CONF_SERVER_URL, DEFAULT_SCAN_INTERVAL +from .const import ( + DOMAIN, + CONF_SERVER_URL, + CONF_API_KEY, + DEFAULT_SCAN_INTERVAL, + TARGET_TYPE_KEY_COLORS, + DATA_COORDINATOR, + DATA_WS_MANAGER, +) from .coordinator import WLEDScreenControllerCoordinator +from .ws_manager import KeyColorsWebSocketManager _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ Platform.SWITCH, Platform.SENSOR, - Platform.SELECT, ] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up WLED Screen Controller from a config entry.""" + """Set up LED Screen Controller from a config entry.""" server_url = entry.data[CONF_SERVER_URL] - server_name = entry.data.get(CONF_NAME, "WLED Screen Controller") + api_key = entry.data[CONF_API_KEY] session = async_get_clientsession(hass) coordinator = WLEDScreenControllerCoordinator( hass, session, server_url, + api_key, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) - # Fetch initial data await coordinator.async_config_entry_first_refresh() - # Create hub device (the server PC) + ws_manager = KeyColorsWebSocketManager(hass, server_url, api_key) + + # Create device entries for each target device_registry = dr.async_get(hass) - - # Parse URL for hub identifier - parsed_url = urlparse(server_url) - hub_identifier = f"{parsed_url.hostname}:{parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)}" - - hub_device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, hub_identifier)}, - name=server_name, - manufacturer="WLED Screen Controller", - model="Server", - sw_version=coordinator.server_version, - configuration_url=server_url, - ) - - # Create device entries for each WLED device - if coordinator.data and "devices" in coordinator.data: - for device_id, device_data in coordinator.data["devices"].items(): - device_info = device_data["info"] + if coordinator.data and "targets" in coordinator.data: + for target_id, target_data in coordinator.data["targets"].items(): + info = target_data["info"] + target_type = info.get("target_type", "led") + model = ( + "Key Colors Target" + if target_type == TARGET_TYPE_KEY_COLORS + else "LED Target" + ) device_registry.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, device_id)}, - name=device_info["name"], - manufacturer="WLED", - model="Screen Ambient Lighting", - sw_version=f"{device_info.get('led_count', 0)} LEDs", - via_device=(DOMAIN, hub_identifier), # Link to hub - configuration_url=device_info.get("url"), + identifiers={(DOMAIN, target_id)}, + name=info.get("name", target_id), + manufacturer="LED Screen Controller", + model=model, + configuration_url=server_url, ) - # Store coordinator and hub info + # Store data hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { - "coordinator": coordinator, - "hub_device_id": hub_device.id, + DATA_COORDINATOR: coordinator, + DATA_WS_MANAGER: ws_manager, } - # Setup platforms + # Track target IDs to detect changes + initial_target_ids = set( + coordinator.data.get("targets", {}).keys() if coordinator.data else [] + ) + + def _on_coordinator_update() -> None: + """Manage WS connections and detect target list changes.""" + if not coordinator.data: + return + + targets = coordinator.data.get("targets", {}) + + # Start/stop WS connections for KC targets based on processing state + for target_id, target_data in targets.items(): + info = target_data.get("info", {}) + state = target_data.get("state") or {} + if info.get("target_type") == TARGET_TYPE_KEY_COLORS: + if state.get("processing"): + hass.async_create_task(ws_manager.start_listening(target_id)) + else: + hass.async_create_task(ws_manager.stop_listening(target_id)) + + # Reload if target list changed + current_ids = set(targets.keys()) + if current_ids != initial_target_ids: + _LOGGER.info("Target list changed, reloading integration") + hass.async_create_task( + hass.config_entries.async_reload(entry.entry_id) + ) + + coordinator.async_add_listener(_on_coordinator_update) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -86,15 +114,14 @@ 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() + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) diff --git a/custom_components/wled_screen_controller/config_flow.py b/custom_components/wled_screen_controller/config_flow.py index b4c803e..08cdff5 100644 --- a/custom_components/wled_screen_controller/config_flow.py +++ b/custom_components/wled_screen_controller/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for WLED Screen Controller integration.""" +"""Config flow for LED Screen Controller integration.""" from __future__ import annotations import logging @@ -9,19 +9,18 @@ import aiohttp import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, CONF_SERVER_URL, DEFAULT_TIMEOUT +from .const import DOMAIN, CONF_SERVER_URL, CONF_API_KEY, DEFAULT_TIMEOUT _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_NAME, default="WLED Screen Controller"): str, vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str, + vol.Required(CONF_API_KEY): str, } ) @@ -30,48 +29,56 @@ def normalize_url(url: str) -> str: """Normalize URL to ensure port is an integer.""" parsed = urlparse(url) - # If port is specified, ensure it's an integer if parsed.port is not None: - # Reconstruct URL with integer port netloc = parsed.hostname or "localhost" - port = int(parsed.port) # Cast to int to avoid float + port = int(parsed.port) if port != (443 if parsed.scheme == "https" else 80): netloc = f"{netloc}:{port}" - parsed = parsed._replace(netloc=netloc) return urlunparse(parsed) -async def validate_server_connection( - hass: HomeAssistant, server_url: str +async def validate_server( + hass: HomeAssistant, server_url: str, api_key: str ) -> dict[str, Any]: - """Validate the server URL by checking the health endpoint.""" + """Validate server connectivity and API key.""" session = async_get_clientsession(hass) + timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT) + # Step 1: Check connectivity via health endpoint (no auth needed) + try: + async with session.get(f"{server_url}/health", timeout=timeout) as resp: + if resp.status != 200: + raise ConnectionError(f"Server returned status {resp.status}") + data = await resp.json() + version = data.get("version", "unknown") + except aiohttp.ClientError as err: + raise ConnectionError(f"Cannot connect to server: {err}") from err + + # Step 2: Validate API key via authenticated endpoint + headers = {"Authorization": f"Bearer {api_key}"} try: async with session.get( - f"{server_url}/health", - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), - ) as response: - if response.status == 200: - data = await response.json() - return { - "version": data.get("version", "unknown"), - "status": data.get("status", "unknown"), - } - raise ConnectionError(f"Server returned status {response.status}") - + f"{server_url}/api/v1/picture-targets", + headers=headers, + timeout=timeout, + ) as resp: + if resp.status == 401: + raise PermissionError("Invalid API key") + resp.raise_for_status() + except PermissionError: + raise except aiohttp.ClientError as err: - raise ConnectionError(f"Cannot connect to server: {err}") - except Exception as err: - raise ConnectionError(f"Unexpected error: {err}") + raise ConnectionError(f"API request failed: {err}") from err + + return {"version": version} class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for WLED Screen Controller.""" + """Handle a config flow for LED Screen Controller.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -81,26 +88,28 @@ class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/")) + api_key = user_input[CONF_API_KEY] try: - info = await validate_server_connection(self.hass, server_url) + await validate_server(self.hass, server_url, api_key) - # Set unique ID based on server URL await self.async_set_unique_id(server_url) self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input[CONF_NAME], + title="LED Screen Controller", data={ CONF_SERVER_URL: server_url, - "version": info["version"], + CONF_API_KEY: api_key, }, ) except ConnectionError as err: _LOGGER.error("Connection error: %s", err) errors["base"] = "cannot_connect" - except Exception as err: # pylint: disable=broad-except + except PermissionError: + errors["base"] = "invalid_api_key" + except Exception as err: _LOGGER.exception("Unexpected exception: %s", err) errors["base"] = "unknown" diff --git a/custom_components/wled_screen_controller/const.py b/custom_components/wled_screen_controller/const.py index 917a2fc..c7af21a 100644 --- a/custom_components/wled_screen_controller/const.py +++ b/custom_components/wled_screen_controller/const.py @@ -1,23 +1,21 @@ -"""Constants for the WLED Screen Controller integration.""" +"""Constants for the LED Screen Controller integration.""" DOMAIN = "wled_screen_controller" # Configuration CONF_SERVER_URL = "server_url" +CONF_API_KEY = "api_key" # Default values DEFAULT_SCAN_INTERVAL = 10 # seconds DEFAULT_TIMEOUT = 10 # seconds +WS_RECONNECT_DELAY = 5 # seconds +WS_MAX_RECONNECT_DELAY = 60 # seconds -# Attributes -ATTR_DEVICE_ID = "device_id" -ATTR_FPS_ACTUAL = "fps_actual" -ATTR_FPS_TARGET = "fps_target" -ATTR_DISPLAY_INDEX = "display_index" -ATTR_FRAMES_PROCESSED = "frames_processed" -ATTR_ERRORS_COUNT = "errors_count" -ATTR_UPTIME = "uptime_seconds" +# Target types +TARGET_TYPE_LED = "led" +TARGET_TYPE_KEY_COLORS = "key_colors" -# Services -SERVICE_START_PROCESSING = "start_processing" -SERVICE_STOP_PROCESSING = "stop_processing" +# Data keys stored in hass.data[DOMAIN][entry_id] +DATA_COORDINATOR = "coordinator" +DATA_WS_MANAGER = "ws_manager" diff --git a/custom_components/wled_screen_controller/coordinator.py b/custom_components/wled_screen_controller/coordinator.py index 5a51625..38c277a 100644 --- a/custom_components/wled_screen_controller/coordinator.py +++ b/custom_components/wled_screen_controller/coordinator.py @@ -1,4 +1,4 @@ -"""Data update coordinator for WLED Screen Controller.""" +"""Data update coordinator for LED Screen Controller.""" from __future__ import annotations import asyncio @@ -11,25 +11,33 @@ import aiohttp from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, DEFAULT_TIMEOUT +from .const import ( + DOMAIN, + DEFAULT_TIMEOUT, + TARGET_TYPE_KEY_COLORS, +) _LOGGER = logging.getLogger(__name__) class WLEDScreenControllerCoordinator(DataUpdateCoordinator): - """Class to manage fetching WLED Screen Controller data.""" + """Class to manage fetching LED Screen Controller data.""" def __init__( self, hass: HomeAssistant, session: aiohttp.ClientSession, server_url: str, + api_key: str, update_interval: timedelta, ) -> None: """Initialize the coordinator.""" self.server_url = server_url self.session = session + self.api_key = api_key self.server_version = "unknown" + self._auth_headers = {"Authorization": f"Bearer {api_key}"} + self._pattern_cache: dict[str, list[dict]] = {} super().__init__( hass, @@ -41,44 +49,67 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Fetch data from API.""" try: - async with asyncio.timeout(DEFAULT_TIMEOUT): - # Fetch server version on first update + async with asyncio.timeout(DEFAULT_TIMEOUT * 3): if self.server_version == "unknown": await self._fetch_server_version() - # Fetch devices list - devices = await self._fetch_devices() + targets_list = await self._fetch_targets() - # Fetch state for each device - devices_data = {} - for device in devices: - device_id = device["id"] + # Fetch state and metrics for all targets in parallel + targets_data: dict[str, dict[str, Any]] = {} + + async def fetch_target_data(target: dict) -> tuple[str, dict]: + target_id = target["id"] try: - state = await self._fetch_device_state(device_id) - metrics = await self._fetch_device_metrics(device_id) - - devices_data[device_id] = { - "info": device, - "state": state, - "metrics": metrics, - } + state, metrics = await asyncio.gather( + self._fetch_target_state(target_id), + self._fetch_target_metrics(target_id), + ) except Exception as err: _LOGGER.warning( - "Failed to fetch data for device %s: %s", device_id, err + "Failed to fetch data for target %s: %s", + target_id, + err, ) - # Include device info even if state fetch fails - devices_data[device_id] = { - "info": device, - "state": None, - "metrics": None, - } + state = None + metrics = None - # Fetch available displays - displays = await self._fetch_displays() + result: dict[str, Any] = { + "info": target, + "state": state, + "metrics": metrics, + } + + # Fetch rectangles for key_colors targets + if target.get("target_type") == TARGET_TYPE_KEY_COLORS: + kc_settings = target.get("key_colors_settings") or {} + template_id = kc_settings.get("pattern_template_id", "") + if template_id: + result["rectangles"] = await self._get_rectangles( + template_id + ) + else: + result["rectangles"] = [] + else: + result["rectangles"] = [] + + return target_id, result + + results = await asyncio.gather( + *(fetch_target_data(t) for t in targets_list), + return_exceptions=True, + ) + + for r in results: + if isinstance(r, Exception): + _LOGGER.warning("Target fetch failed: %s", r) + continue + target_id, data = r + targets_data[target_id] = data return { - "devices": devices_data, - "displays": displays, + "targets": targets_data, + "server_version": self.server_version, } except asyncio.TimeoutError as err: @@ -92,88 +123,99 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.get( f"{self.server_url}/health", timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), - ) as response: - response.raise_for_status() - data = await response.json() + ) as resp: + resp.raise_for_status() + data = await resp.json() self.server_version = data.get("version", "unknown") except Exception as err: _LOGGER.warning("Failed to fetch server version: %s", err) self.server_version = "unknown" - async def _fetch_devices(self) -> list[dict[str, Any]]: - """Fetch devices list.""" + async def _fetch_targets(self) -> list[dict[str, Any]]: + """Fetch all picture targets.""" async with self.session.get( - f"{self.server_url}/api/v1/devices", + f"{self.server_url}/api/v1/picture-targets", + headers=self._auth_headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), - ) as response: - response.raise_for_status() - data = await response.json() - return data.get("devices", []) + ) as resp: + resp.raise_for_status() + data = await resp.json() + return data.get("targets", []) - async def _fetch_device_state(self, device_id: str) -> dict[str, Any]: - """Fetch device processing state.""" + async def _fetch_target_state(self, target_id: str) -> dict[str, Any]: + """Fetch target processing state.""" async with self.session.get( - f"{self.server_url}/api/v1/devices/{device_id}/state", + f"{self.server_url}/api/v1/picture-targets/{target_id}/state", + headers=self._auth_headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), - ) as response: - response.raise_for_status() - return await response.json() + ) as resp: + resp.raise_for_status() + return await resp.json() - async def _fetch_device_metrics(self, device_id: str) -> dict[str, Any]: - """Fetch device metrics.""" + async def _fetch_target_metrics(self, target_id: str) -> dict[str, Any]: + """Fetch target metrics.""" async with self.session.get( - f"{self.server_url}/api/v1/devices/{device_id}/metrics", + f"{self.server_url}/api/v1/picture-targets/{target_id}/metrics", + headers=self._auth_headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), - ) as response: - response.raise_for_status() - return await response.json() + ) as resp: + resp.raise_for_status() + return await resp.json() + + async def _get_rectangles(self, template_id: str) -> list[dict]: + """Get rectangles for a pattern template, using cache.""" + if template_id in self._pattern_cache: + return self._pattern_cache[template_id] - async def _fetch_displays(self) -> list[dict[str, Any]]: - """Fetch available displays.""" try: async with self.session.get( - f"{self.server_url}/api/v1/config/displays", + f"{self.server_url}/api/v1/pattern-templates/{template_id}", + headers=self._auth_headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), - ) as response: - response.raise_for_status() - data = await response.json() - return data.get("displays", []) + ) as resp: + resp.raise_for_status() + data = await resp.json() + rectangles = data.get("rectangles", []) + self._pattern_cache[template_id] = rectangles + return rectangles except Exception as err: - _LOGGER.warning("Failed to fetch displays: %s", err) + _LOGGER.warning( + "Failed to fetch pattern template %s: %s", template_id, err + ) return [] - async def start_processing(self, device_id: str) -> None: - """Start processing for a device.""" + async def start_processing(self, target_id: str) -> None: + """Start processing for a target.""" async with self.session.post( - f"{self.server_url}/api/v1/devices/{device_id}/start", + f"{self.server_url}/api/v1/picture-targets/{target_id}/start", + headers=self._auth_headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), - ) as response: - response.raise_for_status() - - # Refresh data immediately + ) as resp: + if resp.status == 409: + _LOGGER.debug("Target %s already processing", target_id) + elif resp.status != 200: + body = await resp.text() + _LOGGER.error( + "Failed to start target %s: %s %s", + target_id, resp.status, body, + ) + resp.raise_for_status() await self.async_request_refresh() - async def stop_processing(self, device_id: str) -> None: - """Stop processing for a device.""" + async def stop_processing(self, target_id: str) -> None: + """Stop processing for a target.""" async with self.session.post( - f"{self.server_url}/api/v1/devices/{device_id}/stop", + f"{self.server_url}/api/v1/picture-targets/{target_id}/stop", + headers=self._auth_headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), - ) as response: - response.raise_for_status() - - # Refresh data immediately - await self.async_request_refresh() - - async def update_settings( - self, device_id: str, settings: dict[str, Any] - ) -> None: - """Update device settings.""" - async with self.session.put( - f"{self.server_url}/api/v1/devices/{device_id}/settings", - json=settings, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), - ) as response: - response.raise_for_status() - - # Refresh data immediately + ) as resp: + if resp.status == 409: + _LOGGER.debug("Target %s already stopped", target_id) + elif resp.status != 200: + body = await resp.text() + _LOGGER.error( + "Failed to stop target %s: %s %s", + target_id, resp.status, body, + ) + resp.raise_for_status() await self.async_request_refresh() diff --git a/custom_components/wled_screen_controller/manifest.json b/custom_components/wled_screen_controller/manifest.json index 1adaf31..5efc1ca 100644 --- a/custom_components/wled_screen_controller/manifest.json +++ b/custom_components/wled_screen_controller/manifest.json @@ -1,12 +1,12 @@ { "domain": "wled_screen_controller", - "name": "WLED Screen Controller", + "name": "LED Screen Controller", "codeowners": ["@alexeidolgolyov"], "config_flow": true, "dependencies": [], "documentation": "https://github.com/yourusername/wled-screen-controller", - "iot_class": "local_polling", + "iot_class": "local_push", "issue_tracker": "https://github.com/yourusername/wled-screen-controller/issues", "requirements": ["aiohttp>=3.9.0"], - "version": "0.1.0" + "version": "0.2.0" } diff --git a/custom_components/wled_screen_controller/select.py b/custom_components/wled_screen_controller/select.py deleted file mode 100644 index 4fe1907..0000000 --- a/custom_components/wled_screen_controller/select.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Select platform for WLED Screen Controller.""" -from __future__ import annotations - -import logging -from typing import Any - -from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import WLEDScreenControllerCoordinator - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up WLED Screen Controller select entities.""" - data = hass.data[DOMAIN][entry.entry_id] - coordinator: WLEDScreenControllerCoordinator = data["coordinator"] - - entities = [] - if coordinator.data and "devices" in coordinator.data: - for device_id, device_data in coordinator.data["devices"].items(): - device_info = device_data["info"] - entities.append( - WLEDScreenControllerDisplaySelect( - coordinator, device_id, device_info, entry.entry_id - ) - ) - - async_add_entities(entities) - - -class WLEDScreenControllerDisplaySelect(CoordinatorEntity, SelectEntity): - """Display selection for WLED Screen Controller.""" - - _attr_has_entity_name = True - _attr_icon = "mdi:monitor-multiple" - - def __init__( - self, - coordinator: WLEDScreenControllerCoordinator, - device_id: str, - device_info: dict[str, Any], - entry_id: str, - ) -> None: - """Initialize the select.""" - super().__init__(coordinator) - self._device_id = device_id - self._device_info = device_info - self._entry_id = entry_id - - self._attr_unique_id = f"{device_id}_display" - self._attr_name = "Display" - - @property - def device_info(self) -> dict[str, Any]: - """Return device information.""" - return { - "identifiers": {(DOMAIN, self._device_id)}, - } - - @property - def options(self) -> list[str]: - """Return available display options.""" - if not self.coordinator.data or "displays" not in self.coordinator.data: - return ["Display 0"] - - displays = self.coordinator.data["displays"] - return [f"Display {d['index']}" for d in displays] - - @property - def current_option(self) -> str | None: - """Return current display.""" - if not self.coordinator.data: - return None - - device_data = self.coordinator.data["devices"].get(self._device_id) - if not device_data or not device_data.get("state"): - return None - - display_index = device_data["state"].get("display_index", 0) - return f"Display {display_index}" - - async def async_select_option(self, option: str) -> None: - """Change the selected display.""" - try: - # Extract display index from option (e.g., "Display 1" -> 1) - display_index = int(option.split()[-1]) - - # Get current settings - device_data = self.coordinator.data["devices"].get(self._device_id) - if not device_data: - return - - info = device_data["info"] - settings = info.get("settings", {}) - - # Update settings with new display index - updated_settings = { - "display_index": display_index, - "fps": settings.get("fps", 30), - "border_width": settings.get("border_width", 10), - } - - await self.coordinator.update_settings(self._device_id, updated_settings) - - except Exception as err: - _LOGGER.error("Failed to update display: %s", err) - raise diff --git a/custom_components/wled_screen_controller/sensor.py b/custom_components/wled_screen_controller/sensor.py index 18c70a2..1f19e9e 100644 --- a/custom_components/wled_screen_controller/sensor.py +++ b/custom_components/wled_screen_controller/sensor.py @@ -1,7 +1,8 @@ -"""Sensor platform for WLED Screen Controller.""" +"""Sensor platform for LED Screen Controller.""" from __future__ import annotations import logging +from collections.abc import Callable from typing import Any from homeassistant.components.sensor import ( @@ -10,13 +11,18 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import ( + DOMAIN, + TARGET_TYPE_KEY_COLORS, + DATA_COORDINATOR, + DATA_WS_MANAGER, +) from .coordinator import WLEDScreenControllerCoordinator +from .ws_manager import KeyColorsWebSocketManager _LOGGER = logging.getLogger(__name__) @@ -26,180 +32,242 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up WLED Screen Controller sensors.""" + """Set up LED Screen Controller sensors.""" data = hass.data[DOMAIN][entry.entry_id] - coordinator: WLEDScreenControllerCoordinator = data["coordinator"] + coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR] + ws_manager: KeyColorsWebSocketManager = data[DATA_WS_MANAGER] - entities = [] - if coordinator.data and "devices" in coordinator.data: - for device_id, device_data in coordinator.data["devices"].items(): - device_info = device_data["info"] - - # FPS sensor + entities: list[SensorEntity] = [] + if coordinator.data and "targets" in coordinator.data: + for target_id, target_data in coordinator.data["targets"].items(): entities.append( - WLEDScreenControllerFPSSensor( - coordinator, device_id, device_info, entry.entry_id - ) + WLEDScreenControllerFPSSensor(coordinator, target_id, entry.entry_id) ) - - # Status sensor entities.append( WLEDScreenControllerStatusSensor( - coordinator, device_id, device_info, entry.entry_id + coordinator, target_id, entry.entry_id ) ) - # Frames processed sensor - entities.append( - WLEDScreenControllerFramesSensor( - coordinator, device_id, device_info, entry.entry_id - ) - ) + # Add color sensors for Key Colors targets + info = target_data["info"] + if info.get("target_type") == TARGET_TYPE_KEY_COLORS: + rectangles = target_data.get("rectangles", []) + for rect in rectangles: + entities.append( + WLEDScreenControllerColorSensor( + coordinator=coordinator, + ws_manager=ws_manager, + target_id=target_id, + rectangle_name=rect["name"], + entry_id=entry.entry_id, + ) + ) async_add_entities(entities) class WLEDScreenControllerFPSSensor(CoordinatorEntity, SensorEntity): - """FPS sensor for WLED Screen Controller.""" + """FPS sensor for a LED Screen Controller target.""" _attr_has_entity_name = True _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = "FPS" _attr_icon = "mdi:speedometer" + _attr_suggested_display_precision = 1 def __init__( self, coordinator: WLEDScreenControllerCoordinator, - device_id: str, - device_info: dict[str, Any], + target_id: str, entry_id: str, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._device_id = device_id - self._device_info = device_info + self._target_id = target_id self._entry_id = entry_id - - self._attr_unique_id = f"{device_id}_fps" - self._attr_name = "FPS" + self._attr_unique_id = f"{target_id}_fps" + self._attr_translation_key = "fps" @property def device_info(self) -> dict[str, Any]: """Return device information.""" - return { - "identifiers": {(DOMAIN, self._device_id)}, - } + return {"identifiers": {(DOMAIN, self._target_id)}} @property def native_value(self) -> float | None: """Return the FPS value.""" - if not self.coordinator.data: + target_data = self._get_target_data() + if not target_data or not target_data.get("state"): return None - - device_data = self.coordinator.data["devices"].get(self._device_id) - if not device_data or not device_data.get("state"): + state = target_data["state"] + if not state.get("processing"): return None - - return device_data["state"].get("fps_actual") + return state.get("fps_actual") @property def extra_state_attributes(self) -> dict[str, Any]: """Return additional attributes.""" + target_data = self._get_target_data() + if not target_data or not target_data.get("state"): + return {} + return {"fps_target": target_data["state"].get("fps_target")} + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._get_target_data() is not None + + def _get_target_data(self) -> dict[str, Any] | None: if not self.coordinator.data: - return {} - - device_data = self.coordinator.data["devices"].get(self._device_id) - if not device_data or not device_data.get("state"): - return {} - - return { - "target_fps": device_data["state"].get("fps_target"), - } + return None + return self.coordinator.data.get("targets", {}).get(self._target_id) class WLEDScreenControllerStatusSensor(CoordinatorEntity, SensorEntity): - """Status sensor for WLED Screen Controller.""" + """Status sensor for a LED Screen Controller target.""" _attr_has_entity_name = True _attr_icon = "mdi:information-outline" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = ["processing", "idle", "error", "unavailable"] def __init__( self, coordinator: WLEDScreenControllerCoordinator, - device_id: str, - device_info: dict[str, Any], + target_id: str, entry_id: str, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._device_id = device_id - self._device_info = device_info + self._target_id = target_id self._entry_id = entry_id - - self._attr_unique_id = f"{device_id}_status" - self._attr_name = "Status" + self._attr_unique_id = f"{target_id}_status" + self._attr_translation_key = "status" @property def device_info(self) -> dict[str, Any]: """Return device information.""" - return { - "identifiers": {(DOMAIN, self._device_id)}, - } + return {"identifiers": {(DOMAIN, self._target_id)}} @property def native_value(self) -> str: """Return the status.""" - if not self.coordinator.data: - return "unknown" - - device_data = self.coordinator.data["devices"].get(self._device_id) - if not device_data: + target_data = self._get_target_data() + if not target_data: return "unavailable" - - if device_data.get("state") and device_data["state"].get("processing"): + state = target_data.get("state") + if not state: + return "unavailable" + if state.get("processing"): + errors = state.get("errors", []) + if errors: + return "error" return "processing" - return "idle" + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._get_target_data() is not None -class WLEDScreenControllerFramesSensor(CoordinatorEntity, SensorEntity): - """Frames processed sensor.""" + def _get_target_data(self) -> dict[str, Any] | None: + if not self.coordinator.data: + return None + return self.coordinator.data.get("targets", {}).get(self._target_id) + + +class WLEDScreenControllerColorSensor(CoordinatorEntity, SensorEntity): + """Color sensor reporting the extracted screen color for a Key Colors rectangle.""" _attr_has_entity_name = True - _attr_state_class = SensorStateClass.TOTAL_INCREASING - _attr_icon = "mdi:counter" + _attr_icon = "mdi:palette" def __init__( self, coordinator: WLEDScreenControllerCoordinator, - device_id: str, - device_info: dict[str, Any], + ws_manager: KeyColorsWebSocketManager, + target_id: str, + rectangle_name: str, entry_id: str, ) -> None: - """Initialize the sensor.""" + """Initialize the color sensor.""" super().__init__(coordinator) - self._device_id = device_id - self._device_info = device_info + self._target_id = target_id + self._rectangle_name = rectangle_name + self._ws_manager = ws_manager self._entry_id = entry_id + self._unregister_ws: Callable[[], None] | None = None - self._attr_unique_id = f"{device_id}_frames" - self._attr_name = "Frames Processed" + sanitized = rectangle_name.lower().replace(" ", "_").replace("-", "_") + self._attr_unique_id = f"{target_id}_{sanitized}_color" + self._attr_translation_key = "rectangle_color" + self._attr_translation_placeholders = {"rectangle_name": rectangle_name} @property def device_info(self) -> dict[str, Any]: """Return device information.""" + return {"identifiers": {(DOMAIN, self._target_id)}} + + async def async_added_to_hass(self) -> None: + """Register WS callback when entity is added.""" + await super().async_added_to_hass() + self._unregister_ws = self._ws_manager.register_callback( + self._target_id, self._handle_color_update + ) + + async def async_will_remove_from_hass(self) -> None: + """Unregister WS callback when entity is removed.""" + if self._unregister_ws: + self._unregister_ws() + self._unregister_ws = None + await super().async_will_remove_from_hass() + + def _handle_color_update(self, colors: dict) -> None: + """Handle incoming color update from WebSocket.""" + if self._rectangle_name in colors: + self.async_write_ha_state() + + @property + def native_value(self) -> str | None: + """Return the hex color string (e.g. #FF8800).""" + color = self._get_color() + if color is None: + return None + return f"#{color['r']:02X}{color['g']:02X}{color['b']:02X}" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return r, g, b, brightness as attributes.""" + color = self._get_color() + if color is None: + return {} + r, g, b = color["r"], color["g"], color["b"] + brightness = int(0.299 * r + 0.587 * g + 0.114 * b) return { - "identifiers": {(DOMAIN, self._device_id)}, + "r": r, + "g": g, + "b": b, + "brightness": brightness, + "rgb_color": [r, g, b], } @property - def native_value(self) -> int | None: - """Return frames processed.""" + def available(self) -> bool: + """Return if entity is available.""" + return self._get_target_data() is not None + + def _get_color(self) -> dict[str, int] | None: + """Get the current color for this rectangle from WS manager.""" + target_data = self._get_target_data() + if not target_data or not target_data.get("state"): + return None + if not target_data["state"].get("processing"): + return None + colors = self._ws_manager.get_latest_colors(self._target_id) + return colors.get(self._rectangle_name) + + def _get_target_data(self) -> dict[str, Any] | None: if not self.coordinator.data: return None - - device_data = self.coordinator.data["devices"].get(self._device_id) - if not device_data or not device_data.get("metrics"): - return None - - return device_data["metrics"].get("frames_processed", 0) + return self.coordinator.data.get("targets", {}).get(self._target_id) diff --git a/custom_components/wled_screen_controller/strings.json b/custom_components/wled_screen_controller/strings.json index aaae5bf..296bdc6 100644 --- a/custom_components/wled_screen_controller/strings.json +++ b/custom_components/wled_screen_controller/strings.json @@ -2,20 +2,49 @@ "config": { "step": { "user": { - "title": "Set up WLED Screen Controller", - "description": "Enter the URL of your WLED Screen Controller server", + "title": "Set up LED Screen Controller", + "description": "Enter the URL and API key for your LED Screen Controller server.", "data": { - "name": "Name", - "server_url": "Server URL" + "server_url": "Server URL", + "api_key": "API Key" + }, + "data_description": { + "server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)", + "api_key": "API key from your server's configuration file" } } }, "error": { - "cannot_connect": "Failed to connect to server. Check the URL and ensure the server is running.", - "unknown": "Unexpected error occurred" + "cannot_connect": "Failed to connect to server.", + "invalid_api_key": "Invalid API key.", + "unknown": "Unexpected error occurred." }, "abort": { - "already_configured": "This server is already configured" + "already_configured": "This server is already configured." + } + }, + "entity": { + "switch": { + "processing": { + "name": "Processing" + } + }, + "sensor": { + "fps": { + "name": "FPS" + }, + "status": { + "name": "Status", + "state": { + "processing": "Processing", + "idle": "Idle", + "error": "Error", + "unavailable": "Unavailable" + } + }, + "rectangle_color": { + "name": "{rectangle_name} Color" + } } } } diff --git a/custom_components/wled_screen_controller/switch.py b/custom_components/wled_screen_controller/switch.py index ff70aee..436d658 100644 --- a/custom_components/wled_screen_controller/switch.py +++ b/custom_components/wled_screen_controller/switch.py @@ -1,4 +1,4 @@ -"""Switch platform for WLED Screen Controller.""" +"""Switch platform for LED Screen Controller.""" from __future__ import annotations import logging @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, ATTR_DEVICE_ID +from .const import DOMAIN, DATA_COORDINATOR from .coordinator import WLEDScreenControllerCoordinator _LOGGER = logging.getLogger(__name__) @@ -21,93 +21,71 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up WLED Screen Controller switches.""" + """Set up LED Screen Controller switches.""" data = hass.data[DOMAIN][entry.entry_id] - coordinator: WLEDScreenControllerCoordinator = data["coordinator"] + coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR] entities = [] - if coordinator.data and "devices" in coordinator.data: - for device_id, device_data in coordinator.data["devices"].items(): + if coordinator.data and "targets" in coordinator.data: + for target_id, target_data in coordinator.data["targets"].items(): entities.append( - WLEDScreenControllerSwitch( - coordinator, device_id, device_data["info"], entry.entry_id - ) + WLEDScreenControllerSwitch(coordinator, target_id, entry.entry_id) ) async_add_entities(entities) class WLEDScreenControllerSwitch(CoordinatorEntity, SwitchEntity): - """Representation of a WLED Screen Controller processing switch.""" + """Representation of a LED Screen Controller target processing switch.""" _attr_has_entity_name = True def __init__( self, coordinator: WLEDScreenControllerCoordinator, - device_id: str, - device_info: dict[str, Any], + target_id: str, entry_id: str, ) -> None: """Initialize the switch.""" super().__init__(coordinator) - self._device_id = device_id - self._device_info = device_info + self._target_id = target_id self._entry_id = entry_id - - self._attr_unique_id = f"{device_id}_processing" - self._attr_name = "Processing" + self._attr_unique_id = f"{target_id}_processing" + self._attr_translation_key = "processing" self._attr_icon = "mdi:television-ambient-light" @property def device_info(self) -> dict[str, Any]: """Return device information.""" - return { - "identifiers": {(DOMAIN, self._device_id)}, - } + return {"identifiers": {(DOMAIN, self._target_id)}} @property def is_on(self) -> bool: """Return true if processing is active.""" - if not self.coordinator.data: + target_data = self._get_target_data() + if not target_data or not target_data.get("state"): return False - - device_data = self.coordinator.data["devices"].get(self._device_id) - if not device_data or not device_data.get("state"): - return False - - return device_data["state"].get("processing", False) + return target_data["state"].get("processing", False) @property def available(self) -> bool: """Return if entity is available.""" - if not self.coordinator.data: - return False - - device_data = self.coordinator.data["devices"].get(self._device_id) - return device_data is not None + return self._get_target_data() is not None @property def extra_state_attributes(self) -> dict[str, Any]: """Return additional state attributes.""" - if not self.coordinator.data: + target_data = self._get_target_data() + if not target_data: return {} - device_data = self.coordinator.data["devices"].get(self._device_id) - if not device_data: - return {} - - state = device_data.get("state", {}) - metrics = device_data.get("metrics", {}) - - attrs = { - ATTR_DEVICE_ID: self._device_id, - } + attrs: dict[str, Any] = {"target_id": self._target_id} + state = target_data.get("state") or {} + metrics = target_data.get("metrics") or {} if state: attrs["fps_target"] = state.get("fps_target") attrs["fps_actual"] = state.get("fps_actual") - attrs["display_index"] = state.get("display_index") if metrics: attrs["frames_processed"] = metrics.get("frames_processed") @@ -117,17 +95,15 @@ class WLEDScreenControllerSwitch(CoordinatorEntity, SwitchEntity): return attrs async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on processing.""" - try: - await self.coordinator.start_processing(self._device_id) - except Exception as err: - _LOGGER.error("Failed to start processing: %s", err) - raise + """Start processing.""" + await self.coordinator.start_processing(self._target_id) async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off processing.""" - try: - await self.coordinator.stop_processing(self._device_id) - except Exception as err: - _LOGGER.error("Failed to stop processing: %s", err) - raise + """Stop processing.""" + await self.coordinator.stop_processing(self._target_id) + + def _get_target_data(self) -> dict[str, Any] | None: + """Get target data from coordinator.""" + if not self.coordinator.data: + return None + return self.coordinator.data.get("targets", {}).get(self._target_id) diff --git a/custom_components/wled_screen_controller/translations/en.json b/custom_components/wled_screen_controller/translations/en.json index aaae5bf..296bdc6 100644 --- a/custom_components/wled_screen_controller/translations/en.json +++ b/custom_components/wled_screen_controller/translations/en.json @@ -2,20 +2,49 @@ "config": { "step": { "user": { - "title": "Set up WLED Screen Controller", - "description": "Enter the URL of your WLED Screen Controller server", + "title": "Set up LED Screen Controller", + "description": "Enter the URL and API key for your LED Screen Controller server.", "data": { - "name": "Name", - "server_url": "Server URL" + "server_url": "Server URL", + "api_key": "API Key" + }, + "data_description": { + "server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)", + "api_key": "API key from your server's configuration file" } } }, "error": { - "cannot_connect": "Failed to connect to server. Check the URL and ensure the server is running.", - "unknown": "Unexpected error occurred" + "cannot_connect": "Failed to connect to server.", + "invalid_api_key": "Invalid API key.", + "unknown": "Unexpected error occurred." }, "abort": { - "already_configured": "This server is already configured" + "already_configured": "This server is already configured." + } + }, + "entity": { + "switch": { + "processing": { + "name": "Processing" + } + }, + "sensor": { + "fps": { + "name": "FPS" + }, + "status": { + "name": "Status", + "state": { + "processing": "Processing", + "idle": "Idle", + "error": "Error", + "unavailable": "Unavailable" + } + }, + "rectangle_color": { + "name": "{rectangle_name} Color" + } } } } diff --git a/custom_components/wled_screen_controller/translations/ru.json b/custom_components/wled_screen_controller/translations/ru.json new file mode 100644 index 0000000..67f4dbf --- /dev/null +++ b/custom_components/wled_screen_controller/translations/ru.json @@ -0,0 +1,50 @@ +{ + "config": { + "step": { + "user": { + "title": "Настройка LED Screen Controller", + "description": "Введите URL и API-ключ вашего сервера LED Screen Controller.", + "data": { + "server_url": "URL сервера", + "api_key": "API-ключ" + }, + "data_description": { + "server_url": "URL сервера LED Screen Controller (например, http://192.168.1.100:8080)", + "api_key": "API-ключ из конфигурационного файла сервера" + } + } + }, + "error": { + "cannot_connect": "Не удалось подключиться к серверу.", + "invalid_api_key": "Неверный API-ключ.", + "unknown": "Произошла непредвиденная ошибка." + }, + "abort": { + "already_configured": "Этот сервер уже настроен." + } + }, + "entity": { + "switch": { + "processing": { + "name": "Обработка" + } + }, + "sensor": { + "fps": { + "name": "FPS" + }, + "status": { + "name": "Статус", + "state": { + "processing": "Обработка", + "idle": "Ожидание", + "error": "Ошибка", + "unavailable": "Недоступен" + } + }, + "rectangle_color": { + "name": "{rectangle_name} Цвет" + } + } + } +} diff --git a/custom_components/wled_screen_controller/ws_manager.py b/custom_components/wled_screen_controller/ws_manager.py new file mode 100644 index 0000000..dcdd399 --- /dev/null +++ b/custom_components/wled_screen_controller/ws_manager.py @@ -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) diff --git a/server/config/default_config.yaml b/server/config/default_config.yaml index 6643f98..8580fef 100644 --- a/server/config/default_config.yaml +++ b/server/config/default_config.yaml @@ -12,25 +12,13 @@ auth: # Generate secure keys: openssl rand -hex 32 dev: "development-key-change-in-production" # Development key - CHANGE THIS! -processing: - default_fps: 30 - max_fps: 60 - min_fps: 1 - border_width: 10 # pixels to sample from screen edge - interpolation_mode: "average" # average, median, dominant - -screen_capture: - buffer_size: 2 # Number of frames to buffer - -wled: - timeout: 5 # seconds - retry_attempts: 3 - retry_delay: 1 # seconds - protocol: "http" # http or https - max_brightness: 255 - storage: devices_file: "data/devices.json" + templates_file: "data/capture_templates.json" + postprocessing_templates_file: "data/postprocessing_templates.json" + picture_sources_file: "data/picture_sources.json" + picture_targets_file: "data/picture_targets.json" + pattern_templates_file: "data/pattern_templates.json" logging: format: "json" # json or text diff --git a/server/config/test_config.yaml b/server/config/test_config.yaml index 8112812..222606e 100644 --- a/server/config/test_config.yaml +++ b/server/config/test_config.yaml @@ -1,7 +1,7 @@ server: - host: "0.0.0.0" # Listen on all interfaces (accessible from local network) + host: "0.0.0.0" port: 8080 - log_level: "DEBUG" # Verbose logging for testing + log_level: "DEBUG" cors_origins: - "*" @@ -10,28 +10,16 @@ auth: api_keys: test_client: "eb8a89cfd33ab067751fd0e38f74ddf7ac3d75ff012fbab35a616c45c12e0c8d" -processing: - default_fps: 30 - max_fps: 60 - min_fps: 1 - border_width: 10 - interpolation_mode: "average" - -screen_capture: - buffer_size: 2 - -wled: - timeout: 5 - retry_attempts: 3 - retry_delay: 1 - protocol: "http" - max_brightness: 255 - storage: devices_file: "data/test_devices.json" + templates_file: "data/capture_templates.json" + postprocessing_templates_file: "data/postprocessing_templates.json" + picture_sources_file: "data/picture_sources.json" + picture_targets_file: "data/picture_targets.json" + pattern_templates_file: "data/pattern_templates.json" logging: - format: "text" # Easier to read during testing + format: "text" file: "logs/wled_test.log" max_size_mb: 10 backup_count: 2 diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index a733742..d31da46 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -133,6 +133,8 @@ class TargetProcessingState(BaseModel): timing_smooth_ms: Optional[float] = Field(None, description="Smoothing time (ms)") timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)") timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)") + timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)") + timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)") display_index: int = Field(default=0, description="Current display index") overlay_active: bool = Field(default=False, description="Whether visualization overlay is active") last_update: Optional[datetime] = Field(None, description="Last successful update") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index 02d4f90..ebe1f08 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -1,4 +1,4 @@ -"""Configuration management for LED Grab.""" +"""Configuration management for WLED Screen Controller.""" import os from pathlib import Path @@ -24,32 +24,6 @@ class AuthConfig(BaseSettings): api_keys: dict[str, str] = {} # label: key mapping (required for security) -class ProcessingConfig(BaseSettings): - """Processing configuration.""" - - default_fps: int = 30 - max_fps: int = 90 - min_fps: int = 10 - border_width: int = 10 - interpolation_mode: Literal["average", "median", "dominant"] = "average" - - -class ScreenCaptureConfig(BaseSettings): - """Screen capture configuration.""" - - buffer_size: int = 2 - - -class WLEDConfig(BaseSettings): - """WLED client configuration.""" - - timeout: int = 5 - retry_attempts: int = 3 - retry_delay: int = 1 - protocol: Literal["http", "https"] = "http" - max_brightness: int = 255 - - class StorageConfig(BaseSettings): """Storage configuration.""" @@ -81,9 +55,6 @@ class Config(BaseSettings): server: ServerConfig = Field(default_factory=ServerConfig) auth: AuthConfig = Field(default_factory=AuthConfig) - processing: ProcessingConfig = Field(default_factory=ProcessingConfig) - screen_capture: ScreenCaptureConfig = Field(default_factory=ScreenCaptureConfig) - wled: WLEDConfig = Field(default_factory=WLEDConfig) storage: StorageConfig = Field(default_factory=StorageConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig) diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 1614eab..9394952 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -74,13 +74,10 @@ def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothi def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing): """All CPU-bound work for one KC frame (runs in thread pool). - Args: - capture: ScreenCapture from live_stream.get_latest_frame() - rectangles: List of pattern rectangles to extract colors from - calc_fn: Color calculation function (average/median/dominant) - previous_colors: Previous frame colors for smoothing - smoothing: Smoothing factor (0-1) + Returns (colors, timing_ms) where colors is a dict {name: (r, g, b)} + and timing_ms is a dict with per-stage timing in milliseconds. """ + t0 = time.perf_counter() img = capture.image h, w = img.shape[:2] colors = {} @@ -95,6 +92,7 @@ def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing): px_h = min(px_h, h - px_y) sub_img = img[px_y:px_y + px_h, px_x:px_x + px_w] colors[rect.name] = calc_fn(sub_img) + t1 = time.perf_counter() if previous_colors and smoothing > 0: for name, color in colors.items(): if name in previous_colors: @@ -105,7 +103,13 @@ def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing): int(color[1] * (1 - alpha) + prev[1] * alpha), int(color[2] * (1 - alpha) + prev[2] * alpha), ) - return colors + t2 = time.perf_counter() + timing_ms = { + "calc_colors": (t1 - t0) * 1000, + "smooth": (t2 - t1) * 1000, + "total": (t2 - t0) * 1000, + } + return colors, timing_ms @dataclass class ProcessingSettings: @@ -137,11 +141,15 @@ class ProcessingMetrics: fps_potential: float = 0.0 fps_current: int = 0 # Per-stage timing (ms), averaged over last 10 frames + # LED targets timing_extract_ms: float = 0.0 timing_map_leds_ms: float = 0.0 timing_smooth_ms: float = 0.0 timing_send_ms: float = 0.0 timing_total_ms: float = 0.0 + # KC targets + timing_calc_colors_ms: float = 0.0 + timing_broadcast_ms: float = 0.0 @dataclass @@ -541,7 +549,8 @@ class ProcessorManager: state = self._targets[target_id] if state.is_running: - raise RuntimeError(f"Processing already running for target {target_id}") + logger.debug(f"Processing already running for target {target_id}") + return # Enforce one-target-per-device constraint for other_id, other in self._targets.items(): @@ -1230,7 +1239,8 @@ class ProcessorManager: state = self._kc_targets[target_id] if state.is_running: - raise ValueError(f"KC target {target_id} is already running") + logger.debug(f"KC target {target_id} is already running") + return if not state.picture_source_id: raise ValueError(f"KC target {target_id} has no picture source assigned") @@ -1324,6 +1334,7 @@ class ProcessorManager: frame_time = 1.0 / target_fps fps_samples: List[float] = [] + timing_samples: collections.deque = collections.deque(maxlen=10) prev_frame_time_stamp = time.time() prev_capture = None # Track previous ScreenCapture for change detection last_broadcast_time = 0.0 # Timestamp of last WS broadcast (for keepalive) @@ -1367,7 +1378,7 @@ class ProcessorManager: prev_capture = capture # CPU-bound work in thread pool - colors = await asyncio.to_thread( + colors, frame_timing = await asyncio.to_thread( _process_kc_frame, capture, rectangles, calc_fn, state.previous_colors, smoothing, @@ -1377,10 +1388,21 @@ class ProcessorManager: state.latest_colors = dict(colors) # Broadcast to WebSocket clients + t_broadcast_start = time.perf_counter() await self._broadcast_kc_colors(target_id, colors) + broadcast_ms = (time.perf_counter() - t_broadcast_start) * 1000 last_broadcast_time = time.time() send_timestamps.append(last_broadcast_time) + # Per-stage timing (rolling average over last 10 frames) + frame_timing["broadcast"] = broadcast_ms + timing_samples.append(frame_timing) + n = len(timing_samples) + state.metrics.timing_calc_colors_ms = sum(s["calc_colors"] for s in timing_samples) / n + state.metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n + state.metrics.timing_broadcast_ms = sum(s["broadcast"] for s in timing_samples) / n + state.metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + broadcast_ms + # Update metrics state.metrics.frames_processed += 1 state.metrics.last_update = datetime.utcnow() @@ -1475,6 +1497,10 @@ class ProcessorManager: "frames_skipped": metrics.frames_skipped if state.is_running else None, "frames_keepalive": metrics.frames_keepalive if state.is_running else None, "fps_current": metrics.fps_current if state.is_running else None, + "timing_calc_colors_ms": round(metrics.timing_calc_colors_ms, 1) if state.is_running else None, + "timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if state.is_running else None, + "timing_broadcast_ms": round(metrics.timing_broadcast_ms, 1) if state.is_running else None, + "timing_total_ms": round(metrics.timing_total_ms, 1) if state.is_running else None, "last_update": metrics.last_update, "errors": [metrics.last_error] if metrics.last_error else [], } diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 8da24e4..473b8d4 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -4894,6 +4894,24 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {