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:
@@ -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)
|
|
||||||
@@ -1,84 +1,112 @@
|
|||||||
"""The WLED Screen Controller integration."""
|
"""The LED Screen Controller integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
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.core import HomeAssistant
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
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 .coordinator import WLEDScreenControllerCoordinator
|
||||||
|
from .ws_manager import KeyColorsWebSocketManager
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [
|
PLATFORMS: list[Platform] = [
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SELECT,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
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_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)
|
session = async_get_clientsession(hass)
|
||||||
coordinator = WLEDScreenControllerCoordinator(
|
coordinator = WLEDScreenControllerCoordinator(
|
||||||
hass,
|
hass,
|
||||||
session,
|
session,
|
||||||
server_url,
|
server_url,
|
||||||
|
api_key,
|
||||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch initial data
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
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)
|
device_registry = dr.async_get(hass)
|
||||||
|
if coordinator.data and "targets" in coordinator.data:
|
||||||
# Parse URL for hub identifier
|
for target_id, target_data in coordinator.data["targets"].items():
|
||||||
parsed_url = urlparse(server_url)
|
info = target_data["info"]
|
||||||
hub_identifier = f"{parsed_url.hostname}:{parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)}"
|
target_type = info.get("target_type", "led")
|
||||||
|
model = (
|
||||||
hub_device = device_registry.async_get_or_create(
|
"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,
|
config_entry_id=entry.entry_id,
|
||||||
identifiers={(DOMAIN, hub_identifier)},
|
identifiers={(DOMAIN, target_id)},
|
||||||
name=server_name,
|
name=info.get("name", target_id),
|
||||||
manufacturer="WLED Screen Controller",
|
manufacturer="LED Screen Controller",
|
||||||
model="Server",
|
model=model,
|
||||||
sw_version=coordinator.server_version,
|
|
||||||
configuration_url=server_url,
|
configuration_url=server_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create device entries for each WLED device
|
# Store data
|
||||||
if coordinator.data and "devices" in coordinator.data:
|
|
||||||
for device_id, device_data in coordinator.data["devices"].items():
|
|
||||||
device_info = device_data["info"]
|
|
||||||
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"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store coordinator and hub info
|
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN][entry.entry_id] = {
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
"coordinator": coordinator,
|
DATA_COORDINATOR: coordinator,
|
||||||
"hub_device_id": hub_device.id,
|
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)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
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:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""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)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
return unload_ok
|
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)
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Config flow for WLED Screen Controller integration."""
|
"""Config flow for LED Screen Controller integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -9,19 +9,18 @@ import aiohttp
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_NAME
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
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_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."""
|
"""Normalize URL to ensure port is an integer."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
|
|
||||||
# If port is specified, ensure it's an integer
|
|
||||||
if parsed.port is not None:
|
if parsed.port is not None:
|
||||||
# Reconstruct URL with integer port
|
|
||||||
netloc = parsed.hostname or "localhost"
|
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):
|
if port != (443 if parsed.scheme == "https" else 80):
|
||||||
netloc = f"{netloc}:{port}"
|
netloc = f"{netloc}:{port}"
|
||||||
|
|
||||||
parsed = parsed._replace(netloc=netloc)
|
parsed = parsed._replace(netloc=netloc)
|
||||||
|
|
||||||
return urlunparse(parsed)
|
return urlunparse(parsed)
|
||||||
|
|
||||||
|
|
||||||
async def validate_server_connection(
|
async def validate_server(
|
||||||
hass: HomeAssistant, server_url: str
|
hass: HomeAssistant, server_url: str, api_key: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Validate the server URL by checking the health endpoint."""
|
"""Validate server connectivity and API key."""
|
||||||
session = async_get_clientsession(hass)
|
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:
|
try:
|
||||||
async with session.get(
|
async with session.get(
|
||||||
f"{server_url}/health",
|
f"{server_url}/api/v1/picture-targets",
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
headers=headers,
|
||||||
) as response:
|
timeout=timeout,
|
||||||
if response.status == 200:
|
) as resp:
|
||||||
data = await response.json()
|
if resp.status == 401:
|
||||||
return {
|
raise PermissionError("Invalid API key")
|
||||||
"version": data.get("version", "unknown"),
|
resp.raise_for_status()
|
||||||
"status": data.get("status", "unknown"),
|
except PermissionError:
|
||||||
}
|
raise
|
||||||
raise ConnectionError(f"Server returned status {response.status}")
|
|
||||||
|
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
raise ConnectionError(f"Cannot connect to server: {err}")
|
raise ConnectionError(f"API request failed: {err}") from err
|
||||||
except Exception as err:
|
|
||||||
raise ConnectionError(f"Unexpected error: {err}")
|
return {"version": version}
|
||||||
|
|
||||||
|
|
||||||
class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
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(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
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:
|
if user_input is not None:
|
||||||
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
|
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
|
||||||
|
api_key = user_input[CONF_API_KEY]
|
||||||
|
|
||||||
try:
|
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)
|
await self.async_set_unique_id(server_url)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=user_input[CONF_NAME],
|
title="LED Screen Controller",
|
||||||
data={
|
data={
|
||||||
CONF_SERVER_URL: server_url,
|
CONF_SERVER_URL: server_url,
|
||||||
"version": info["version"],
|
CONF_API_KEY: api_key,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
except ConnectionError as err:
|
except ConnectionError as err:
|
||||||
_LOGGER.error("Connection error: %s", err)
|
_LOGGER.error("Connection error: %s", err)
|
||||||
errors["base"] = "cannot_connect"
|
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)
|
_LOGGER.exception("Unexpected exception: %s", err)
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
"""Constants for the WLED Screen Controller integration."""
|
"""Constants for the LED Screen Controller integration."""
|
||||||
|
|
||||||
DOMAIN = "wled_screen_controller"
|
DOMAIN = "wled_screen_controller"
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
CONF_SERVER_URL = "server_url"
|
CONF_SERVER_URL = "server_url"
|
||||||
|
CONF_API_KEY = "api_key"
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
DEFAULT_SCAN_INTERVAL = 10 # seconds
|
DEFAULT_SCAN_INTERVAL = 10 # seconds
|
||||||
DEFAULT_TIMEOUT = 10 # seconds
|
DEFAULT_TIMEOUT = 10 # seconds
|
||||||
|
WS_RECONNECT_DELAY = 5 # seconds
|
||||||
|
WS_MAX_RECONNECT_DELAY = 60 # seconds
|
||||||
|
|
||||||
# Attributes
|
# Target types
|
||||||
ATTR_DEVICE_ID = "device_id"
|
TARGET_TYPE_LED = "led"
|
||||||
ATTR_FPS_ACTUAL = "fps_actual"
|
TARGET_TYPE_KEY_COLORS = "key_colors"
|
||||||
ATTR_FPS_TARGET = "fps_target"
|
|
||||||
ATTR_DISPLAY_INDEX = "display_index"
|
|
||||||
ATTR_FRAMES_PROCESSED = "frames_processed"
|
|
||||||
ATTR_ERRORS_COUNT = "errors_count"
|
|
||||||
ATTR_UPTIME = "uptime_seconds"
|
|
||||||
|
|
||||||
# Services
|
# Data keys stored in hass.data[DOMAIN][entry_id]
|
||||||
SERVICE_START_PROCESSING = "start_processing"
|
DATA_COORDINATOR = "coordinator"
|
||||||
SERVICE_STOP_PROCESSING = "stop_processing"
|
DATA_WS_MANAGER = "ws_manager"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Data update coordinator for WLED Screen Controller."""
|
"""Data update coordinator for LED Screen Controller."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -11,25 +11,33 @@ import aiohttp
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||||
"""Class to manage fetching WLED Screen Controller data."""
|
"""Class to manage fetching LED Screen Controller data."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
server_url: str,
|
server_url: str,
|
||||||
|
api_key: str,
|
||||||
update_interval: timedelta,
|
update_interval: timedelta,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the coordinator."""
|
"""Initialize the coordinator."""
|
||||||
self.server_url = server_url
|
self.server_url = server_url
|
||||||
self.session = session
|
self.session = session
|
||||||
|
self.api_key = api_key
|
||||||
self.server_version = "unknown"
|
self.server_version = "unknown"
|
||||||
|
self._auth_headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
self._pattern_cache: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
@@ -41,44 +49,67 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
"""Fetch data from API."""
|
"""Fetch data from API."""
|
||||||
try:
|
try:
|
||||||
async with asyncio.timeout(DEFAULT_TIMEOUT):
|
async with asyncio.timeout(DEFAULT_TIMEOUT * 3):
|
||||||
# Fetch server version on first update
|
|
||||||
if self.server_version == "unknown":
|
if self.server_version == "unknown":
|
||||||
await self._fetch_server_version()
|
await self._fetch_server_version()
|
||||||
|
|
||||||
# Fetch devices list
|
targets_list = await self._fetch_targets()
|
||||||
devices = await self._fetch_devices()
|
|
||||||
|
|
||||||
# Fetch state for each device
|
# Fetch state and metrics for all targets in parallel
|
||||||
devices_data = {}
|
targets_data: dict[str, dict[str, Any]] = {}
|
||||||
for device in devices:
|
|
||||||
device_id = device["id"]
|
async def fetch_target_data(target: dict) -> tuple[str, dict]:
|
||||||
|
target_id = target["id"]
|
||||||
try:
|
try:
|
||||||
state = await self._fetch_device_state(device_id)
|
state, metrics = await asyncio.gather(
|
||||||
metrics = await self._fetch_device_metrics(device_id)
|
self._fetch_target_state(target_id),
|
||||||
|
self._fetch_target_metrics(target_id),
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Failed to fetch data for target %s: %s",
|
||||||
|
target_id,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
state = None
|
||||||
|
metrics = None
|
||||||
|
|
||||||
devices_data[device_id] = {
|
result: dict[str, Any] = {
|
||||||
"info": device,
|
"info": target,
|
||||||
"state": state,
|
"state": state,
|
||||||
"metrics": metrics,
|
"metrics": metrics,
|
||||||
}
|
}
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Failed to fetch data for device %s: %s", device_id, err
|
|
||||||
)
|
|
||||||
# Include device info even if state fetch fails
|
|
||||||
devices_data[device_id] = {
|
|
||||||
"info": device,
|
|
||||||
"state": None,
|
|
||||||
"metrics": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fetch available displays
|
# Fetch rectangles for key_colors targets
|
||||||
displays = await self._fetch_displays()
|
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 {
|
return {
|
||||||
"devices": devices_data,
|
"targets": targets_data,
|
||||||
"displays": displays,
|
"server_version": self.server_version,
|
||||||
}
|
}
|
||||||
|
|
||||||
except asyncio.TimeoutError as err:
|
except asyncio.TimeoutError as err:
|
||||||
@@ -92,88 +123,99 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
async with self.session.get(
|
async with self.session.get(
|
||||||
f"{self.server_url}/health",
|
f"{self.server_url}/health",
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||||
) as response:
|
) as resp:
|
||||||
response.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = await response.json()
|
data = await resp.json()
|
||||||
self.server_version = data.get("version", "unknown")
|
self.server_version = data.get("version", "unknown")
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.warning("Failed to fetch server version: %s", err)
|
_LOGGER.warning("Failed to fetch server version: %s", err)
|
||||||
self.server_version = "unknown"
|
self.server_version = "unknown"
|
||||||
|
|
||||||
async def _fetch_devices(self) -> list[dict[str, Any]]:
|
async def _fetch_targets(self) -> list[dict[str, Any]]:
|
||||||
"""Fetch devices list."""
|
"""Fetch all picture targets."""
|
||||||
async with self.session.get(
|
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),
|
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||||
) as response:
|
) as resp:
|
||||||
response.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = await response.json()
|
data = await resp.json()
|
||||||
return data.get("devices", [])
|
return data.get("targets", [])
|
||||||
|
|
||||||
async def _fetch_device_state(self, device_id: str) -> dict[str, Any]:
|
async def _fetch_target_state(self, target_id: str) -> dict[str, Any]:
|
||||||
"""Fetch device processing state."""
|
"""Fetch target processing state."""
|
||||||
async with self.session.get(
|
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),
|
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||||
) as response:
|
) as resp:
|
||||||
response.raise_for_status()
|
resp.raise_for_status()
|
||||||
return await response.json()
|
return await resp.json()
|
||||||
|
|
||||||
async def _fetch_device_metrics(self, device_id: str) -> dict[str, Any]:
|
async def _fetch_target_metrics(self, target_id: str) -> dict[str, Any]:
|
||||||
"""Fetch device metrics."""
|
"""Fetch target metrics."""
|
||||||
async with self.session.get(
|
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),
|
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||||
) as response:
|
) as resp:
|
||||||
response.raise_for_status()
|
resp.raise_for_status()
|
||||||
return await response.json()
|
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:
|
try:
|
||||||
async with self.session.get(
|
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),
|
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||||
) as response:
|
) as resp:
|
||||||
response.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = await response.json()
|
data = await resp.json()
|
||||||
return data.get("displays", [])
|
rectangles = data.get("rectangles", [])
|
||||||
|
self._pattern_cache[template_id] = rectangles
|
||||||
|
return rectangles
|
||||||
except Exception as err:
|
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 []
|
return []
|
||||||
|
|
||||||
async def start_processing(self, device_id: str) -> None:
|
async def start_processing(self, target_id: str) -> None:
|
||||||
"""Start processing for a device."""
|
"""Start processing for a target."""
|
||||||
async with self.session.post(
|
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),
|
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||||
) as response:
|
) as resp:
|
||||||
response.raise_for_status()
|
if resp.status == 409:
|
||||||
|
_LOGGER.debug("Target %s already processing", target_id)
|
||||||
# Refresh data immediately
|
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()
|
await self.async_request_refresh()
|
||||||
|
|
||||||
async def stop_processing(self, device_id: str) -> None:
|
async def stop_processing(self, target_id: str) -> None:
|
||||||
"""Stop processing for a device."""
|
"""Stop processing for a target."""
|
||||||
async with self.session.post(
|
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),
|
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||||
) as response:
|
) as resp:
|
||||||
response.raise_for_status()
|
if resp.status == 409:
|
||||||
|
_LOGGER.debug("Target %s already stopped", target_id)
|
||||||
# Refresh data immediately
|
elif resp.status != 200:
|
||||||
await self.async_request_refresh()
|
body = await resp.text()
|
||||||
|
_LOGGER.error(
|
||||||
async def update_settings(
|
"Failed to stop target %s: %s %s",
|
||||||
self, device_id: str, settings: dict[str, Any]
|
target_id, resp.status, body,
|
||||||
) -> None:
|
)
|
||||||
"""Update device settings."""
|
resp.raise_for_status()
|
||||||
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
|
|
||||||
await self.async_request_refresh()
|
await self.async_request_refresh()
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"domain": "wled_screen_controller",
|
"domain": "wled_screen_controller",
|
||||||
"name": "WLED Screen Controller",
|
"name": "LED Screen Controller",
|
||||||
"codeowners": ["@alexeidolgolyov"],
|
"codeowners": ["@alexeidolgolyov"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"documentation": "https://github.com/yourusername/wled-screen-controller",
|
"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",
|
"issue_tracker": "https://github.com/yourusername/wled-screen-controller/issues",
|
||||||
"requirements": ["aiohttp>=3.9.0"],
|
"requirements": ["aiohttp>=3.9.0"],
|
||||||
"version": "0.1.0"
|
"version": "0.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Sensor platform for WLED Screen Controller."""
|
"""Sensor platform for LED Screen Controller."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
@@ -10,13 +11,18 @@ from homeassistant.components.sensor import (
|
|||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import UnitOfTime
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
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 .coordinator import WLEDScreenControllerCoordinator
|
||||||
|
from .ws_manager import KeyColorsWebSocketManager
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,33 +32,35 @@ async def async_setup_entry(
|
|||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up WLED Screen Controller sensors."""
|
"""Set up LED Screen Controller sensors."""
|
||||||
data = hass.data[DOMAIN][entry.entry_id]
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
coordinator: WLEDScreenControllerCoordinator = data["coordinator"]
|
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||||
|
ws_manager: KeyColorsWebSocketManager = data[DATA_WS_MANAGER]
|
||||||
|
|
||||||
entities = []
|
entities: list[SensorEntity] = []
|
||||||
if coordinator.data and "devices" in coordinator.data:
|
if coordinator.data and "targets" in coordinator.data:
|
||||||
for device_id, device_data in coordinator.data["devices"].items():
|
for target_id, target_data in coordinator.data["targets"].items():
|
||||||
device_info = device_data["info"]
|
|
||||||
|
|
||||||
# FPS sensor
|
|
||||||
entities.append(
|
entities.append(
|
||||||
WLEDScreenControllerFPSSensor(
|
WLEDScreenControllerFPSSensor(coordinator, target_id, entry.entry_id)
|
||||||
coordinator, device_id, device_info, entry.entry_id
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Status sensor
|
|
||||||
entities.append(
|
entities.append(
|
||||||
WLEDScreenControllerStatusSensor(
|
WLEDScreenControllerStatusSensor(
|
||||||
coordinator, device_id, device_info, entry.entry_id
|
coordinator, target_id, entry.entry_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Frames processed sensor
|
# 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(
|
entities.append(
|
||||||
WLEDScreenControllerFramesSensor(
|
WLEDScreenControllerColorSensor(
|
||||||
coordinator, device_id, device_info, entry.entry_id
|
coordinator=coordinator,
|
||||||
|
ws_manager=ws_manager,
|
||||||
|
target_id=target_id,
|
||||||
|
rectangle_name=rect["name"],
|
||||||
|
entry_id=entry.entry_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,146 +68,206 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
class WLEDScreenControllerFPSSensor(CoordinatorEntity, SensorEntity):
|
class WLEDScreenControllerFPSSensor(CoordinatorEntity, SensorEntity):
|
||||||
"""FPS sensor for WLED Screen Controller."""
|
"""FPS sensor for a LED Screen Controller target."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||||
_attr_native_unit_of_measurement = "FPS"
|
_attr_native_unit_of_measurement = "FPS"
|
||||||
_attr_icon = "mdi:speedometer"
|
_attr_icon = "mdi:speedometer"
|
||||||
|
_attr_suggested_display_precision = 1
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: WLEDScreenControllerCoordinator,
|
coordinator: WLEDScreenControllerCoordinator,
|
||||||
device_id: str,
|
target_id: str,
|
||||||
device_info: dict[str, Any],
|
|
||||||
entry_id: str,
|
entry_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the sensor."""
|
"""Initialize the sensor."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self._device_id = device_id
|
self._target_id = target_id
|
||||||
self._device_info = device_info
|
|
||||||
self._entry_id = entry_id
|
self._entry_id = entry_id
|
||||||
|
self._attr_unique_id = f"{target_id}_fps"
|
||||||
self._attr_unique_id = f"{device_id}_fps"
|
self._attr_translation_key = "fps"
|
||||||
self._attr_name = "FPS"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> dict[str, Any]:
|
def device_info(self) -> dict[str, Any]:
|
||||||
"""Return device information."""
|
"""Return device information."""
|
||||||
return {
|
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||||
"identifiers": {(DOMAIN, self._device_id)},
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> float | None:
|
def native_value(self) -> float | None:
|
||||||
"""Return the FPS value."""
|
"""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
|
return None
|
||||||
|
state = target_data["state"]
|
||||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
if not state.get("processing"):
|
||||||
if not device_data or not device_data.get("state"):
|
|
||||||
return None
|
return None
|
||||||
|
return state.get("fps_actual")
|
||||||
return device_data["state"].get("fps_actual")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, Any]:
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
"""Return additional attributes."""
|
"""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:
|
if not self.coordinator.data:
|
||||||
return {}
|
return None
|
||||||
|
return self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||||
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"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WLEDScreenControllerStatusSensor(CoordinatorEntity, SensorEntity):
|
class WLEDScreenControllerStatusSensor(CoordinatorEntity, SensorEntity):
|
||||||
"""Status sensor for WLED Screen Controller."""
|
"""Status sensor for a LED Screen Controller target."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_icon = "mdi:information-outline"
|
_attr_icon = "mdi:information-outline"
|
||||||
|
_attr_device_class = SensorDeviceClass.ENUM
|
||||||
|
_attr_options = ["processing", "idle", "error", "unavailable"]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: WLEDScreenControllerCoordinator,
|
coordinator: WLEDScreenControllerCoordinator,
|
||||||
device_id: str,
|
target_id: str,
|
||||||
device_info: dict[str, Any],
|
|
||||||
entry_id: str,
|
entry_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the sensor."""
|
"""Initialize the sensor."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self._device_id = device_id
|
self._target_id = target_id
|
||||||
self._device_info = device_info
|
|
||||||
self._entry_id = entry_id
|
self._entry_id = entry_id
|
||||||
|
self._attr_unique_id = f"{target_id}_status"
|
||||||
self._attr_unique_id = f"{device_id}_status"
|
self._attr_translation_key = "status"
|
||||||
self._attr_name = "Status"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> dict[str, Any]:
|
def device_info(self) -> dict[str, Any]:
|
||||||
"""Return device information."""
|
"""Return device information."""
|
||||||
return {
|
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||||
"identifiers": {(DOMAIN, self._device_id)},
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> str:
|
def native_value(self) -> str:
|
||||||
"""Return the status."""
|
"""Return the status."""
|
||||||
if not self.coordinator.data:
|
target_data = self._get_target_data()
|
||||||
return "unknown"
|
if not target_data:
|
||||||
|
|
||||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
|
||||||
if not device_data:
|
|
||||||
return "unavailable"
|
return "unavailable"
|
||||||
|
state = target_data.get("state")
|
||||||
if device_data.get("state") and device_data["state"].get("processing"):
|
if not state:
|
||||||
|
return "unavailable"
|
||||||
|
if state.get("processing"):
|
||||||
|
errors = state.get("errors", [])
|
||||||
|
if errors:
|
||||||
|
return "error"
|
||||||
return "processing"
|
return "processing"
|
||||||
|
|
||||||
return "idle"
|
return "idle"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return if entity is available."""
|
||||||
|
return self._get_target_data() is not None
|
||||||
|
|
||||||
class WLEDScreenControllerFramesSensor(CoordinatorEntity, SensorEntity):
|
def _get_target_data(self) -> dict[str, Any] | None:
|
||||||
"""Frames processed sensor."""
|
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_has_entity_name = True
|
||||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
_attr_icon = "mdi:palette"
|
||||||
_attr_icon = "mdi:counter"
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: WLEDScreenControllerCoordinator,
|
coordinator: WLEDScreenControllerCoordinator,
|
||||||
device_id: str,
|
ws_manager: KeyColorsWebSocketManager,
|
||||||
device_info: dict[str, Any],
|
target_id: str,
|
||||||
|
rectangle_name: str,
|
||||||
entry_id: str,
|
entry_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the sensor."""
|
"""Initialize the color sensor."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self._device_id = device_id
|
self._target_id = target_id
|
||||||
self._device_info = device_info
|
self._rectangle_name = rectangle_name
|
||||||
|
self._ws_manager = ws_manager
|
||||||
self._entry_id = entry_id
|
self._entry_id = entry_id
|
||||||
|
self._unregister_ws: Callable[[], None] | None = None
|
||||||
|
|
||||||
self._attr_unique_id = f"{device_id}_frames"
|
sanitized = rectangle_name.lower().replace(" ", "_").replace("-", "_")
|
||||||
self._attr_name = "Frames Processed"
|
self._attr_unique_id = f"{target_id}_{sanitized}_color"
|
||||||
|
self._attr_translation_key = "rectangle_color"
|
||||||
|
self._attr_translation_placeholders = {"rectangle_name": rectangle_name}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> dict[str, Any]:
|
def device_info(self) -> dict[str, Any]:
|
||||||
"""Return device information."""
|
"""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 {
|
return {
|
||||||
"identifiers": {(DOMAIN, self._device_id)},
|
"r": r,
|
||||||
|
"g": g,
|
||||||
|
"b": b,
|
||||||
|
"brightness": brightness,
|
||||||
|
"rgb_color": [r, g, b],
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> int | None:
|
def available(self) -> bool:
|
||||||
"""Return frames processed."""
|
"""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:
|
if not self.coordinator.data:
|
||||||
return None
|
return None
|
||||||
|
return self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||||
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)
|
|
||||||
|
|||||||
@@ -2,20 +2,49 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Set up WLED Screen Controller",
|
"title": "Set up LED Screen Controller",
|
||||||
"description": "Enter the URL of your WLED Screen Controller server",
|
"description": "Enter the URL and API key for your LED Screen Controller server.",
|
||||||
"data": {
|
"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": {
|
"error": {
|
||||||
"cannot_connect": "Failed to connect to server. Check the URL and ensure the server is running.",
|
"cannot_connect": "Failed to connect to server.",
|
||||||
"unknown": "Unexpected error occurred"
|
"invalid_api_key": "Invalid API key.",
|
||||||
|
"unknown": "Unexpected error occurred."
|
||||||
},
|
},
|
||||||
"abort": {
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Switch platform for WLED Screen Controller."""
|
"""Switch platform for LED Screen Controller."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .const import DOMAIN, ATTR_DEVICE_ID
|
from .const import DOMAIN, DATA_COORDINATOR
|
||||||
from .coordinator import WLEDScreenControllerCoordinator
|
from .coordinator import WLEDScreenControllerCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -21,93 +21,71 @@ async def async_setup_entry(
|
|||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up WLED Screen Controller switches."""
|
"""Set up LED Screen Controller switches."""
|
||||||
data = hass.data[DOMAIN][entry.entry_id]
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
coordinator: WLEDScreenControllerCoordinator = data["coordinator"]
|
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||||
|
|
||||||
entities = []
|
entities = []
|
||||||
if coordinator.data and "devices" in coordinator.data:
|
if coordinator.data and "targets" in coordinator.data:
|
||||||
for device_id, device_data in coordinator.data["devices"].items():
|
for target_id, target_data in coordinator.data["targets"].items():
|
||||||
entities.append(
|
entities.append(
|
||||||
WLEDScreenControllerSwitch(
|
WLEDScreenControllerSwitch(coordinator, target_id, entry.entry_id)
|
||||||
coordinator, device_id, device_data["info"], entry.entry_id
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class WLEDScreenControllerSwitch(CoordinatorEntity, SwitchEntity):
|
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
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: WLEDScreenControllerCoordinator,
|
coordinator: WLEDScreenControllerCoordinator,
|
||||||
device_id: str,
|
target_id: str,
|
||||||
device_info: dict[str, Any],
|
|
||||||
entry_id: str,
|
entry_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the switch."""
|
"""Initialize the switch."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self._device_id = device_id
|
self._target_id = target_id
|
||||||
self._device_info = device_info
|
|
||||||
self._entry_id = entry_id
|
self._entry_id = entry_id
|
||||||
|
self._attr_unique_id = f"{target_id}_processing"
|
||||||
self._attr_unique_id = f"{device_id}_processing"
|
self._attr_translation_key = "processing"
|
||||||
self._attr_name = "Processing"
|
|
||||||
self._attr_icon = "mdi:television-ambient-light"
|
self._attr_icon = "mdi:television-ambient-light"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> dict[str, Any]:
|
def device_info(self) -> dict[str, Any]:
|
||||||
"""Return device information."""
|
"""Return device information."""
|
||||||
return {
|
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||||
"identifiers": {(DOMAIN, self._device_id)},
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return true if processing is active."""
|
"""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
|
return False
|
||||||
|
return target_data["state"].get("processing", 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)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return if entity is available."""
|
"""Return if entity is available."""
|
||||||
if not self.coordinator.data:
|
return self._get_target_data() is not None
|
||||||
return False
|
|
||||||
|
|
||||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
|
||||||
return device_data is not None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, Any]:
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
"""Return additional state attributes."""
|
"""Return additional state attributes."""
|
||||||
if not self.coordinator.data:
|
target_data = self._get_target_data()
|
||||||
|
if not target_data:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
attrs: dict[str, Any] = {"target_id": self._target_id}
|
||||||
if not device_data:
|
state = target_data.get("state") or {}
|
||||||
return {}
|
metrics = target_data.get("metrics") or {}
|
||||||
|
|
||||||
state = device_data.get("state", {})
|
|
||||||
metrics = device_data.get("metrics", {})
|
|
||||||
|
|
||||||
attrs = {
|
|
||||||
ATTR_DEVICE_ID: self._device_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
if state:
|
if state:
|
||||||
attrs["fps_target"] = state.get("fps_target")
|
attrs["fps_target"] = state.get("fps_target")
|
||||||
attrs["fps_actual"] = state.get("fps_actual")
|
attrs["fps_actual"] = state.get("fps_actual")
|
||||||
attrs["display_index"] = state.get("display_index")
|
|
||||||
|
|
||||||
if metrics:
|
if metrics:
|
||||||
attrs["frames_processed"] = metrics.get("frames_processed")
|
attrs["frames_processed"] = metrics.get("frames_processed")
|
||||||
@@ -117,17 +95,15 @@ class WLEDScreenControllerSwitch(CoordinatorEntity, SwitchEntity):
|
|||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn on processing."""
|
"""Start processing."""
|
||||||
try:
|
await self.coordinator.start_processing(self._target_id)
|
||||||
await self.coordinator.start_processing(self._device_id)
|
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.error("Failed to start processing: %s", err)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn off processing."""
|
"""Stop processing."""
|
||||||
try:
|
await self.coordinator.stop_processing(self._target_id)
|
||||||
await self.coordinator.stop_processing(self._device_id)
|
|
||||||
except Exception as err:
|
def _get_target_data(self) -> dict[str, Any] | None:
|
||||||
_LOGGER.error("Failed to stop processing: %s", err)
|
"""Get target data from coordinator."""
|
||||||
raise
|
if not self.coordinator.data:
|
||||||
|
return None
|
||||||
|
return self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||||
|
|||||||
@@ -2,20 +2,49 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Set up WLED Screen Controller",
|
"title": "Set up LED Screen Controller",
|
||||||
"description": "Enter the URL of your WLED Screen Controller server",
|
"description": "Enter the URL and API key for your LED Screen Controller server.",
|
||||||
"data": {
|
"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": {
|
"error": {
|
||||||
"cannot_connect": "Failed to connect to server. Check the URL and ensure the server is running.",
|
"cannot_connect": "Failed to connect to server.",
|
||||||
"unknown": "Unexpected error occurred"
|
"invalid_api_key": "Invalid API key.",
|
||||||
|
"unknown": "Unexpected error occurred."
|
||||||
},
|
},
|
||||||
"abort": {
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} Цвет"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
@@ -12,25 +12,13 @@ auth:
|
|||||||
# Generate secure keys: openssl rand -hex 32
|
# Generate secure keys: openssl rand -hex 32
|
||||||
dev: "development-key-change-in-production" # Development key - CHANGE THIS!
|
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:
|
storage:
|
||||||
devices_file: "data/devices.json"
|
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:
|
logging:
|
||||||
format: "json" # json or text
|
format: "json" # json or text
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
server:
|
server:
|
||||||
host: "0.0.0.0" # Listen on all interfaces (accessible from local network)
|
host: "0.0.0.0"
|
||||||
port: 8080
|
port: 8080
|
||||||
log_level: "DEBUG" # Verbose logging for testing
|
log_level: "DEBUG"
|
||||||
cors_origins:
|
cors_origins:
|
||||||
- "*"
|
- "*"
|
||||||
|
|
||||||
@@ -10,28 +10,16 @@ auth:
|
|||||||
api_keys:
|
api_keys:
|
||||||
test_client: "eb8a89cfd33ab067751fd0e38f74ddf7ac3d75ff012fbab35a616c45c12e0c8d"
|
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:
|
storage:
|
||||||
devices_file: "data/test_devices.json"
|
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:
|
logging:
|
||||||
format: "text" # Easier to read during testing
|
format: "text"
|
||||||
file: "logs/wled_test.log"
|
file: "logs/wled_test.log"
|
||||||
max_size_mb: 10
|
max_size_mb: 10
|
||||||
backup_count: 2
|
backup_count: 2
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ class TargetProcessingState(BaseModel):
|
|||||||
timing_smooth_ms: Optional[float] = Field(None, description="Smoothing time (ms)")
|
timing_smooth_ms: Optional[float] = Field(None, description="Smoothing time (ms)")
|
||||||
timing_send_ms: Optional[float] = Field(None, description="DDP send 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_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")
|
display_index: int = Field(default=0, description="Current display index")
|
||||||
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
|
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
|
||||||
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Configuration management for LED Grab."""
|
"""Configuration management for WLED Screen Controller."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -24,32 +24,6 @@ class AuthConfig(BaseSettings):
|
|||||||
api_keys: dict[str, str] = {} # label: key mapping (required for security)
|
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):
|
class StorageConfig(BaseSettings):
|
||||||
"""Storage configuration."""
|
"""Storage configuration."""
|
||||||
|
|
||||||
@@ -81,9 +55,6 @@ class Config(BaseSettings):
|
|||||||
|
|
||||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
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)
|
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing):
|
||||||
"""All CPU-bound work for one KC frame (runs in thread pool).
|
"""All CPU-bound work for one KC frame (runs in thread pool).
|
||||||
|
|
||||||
Args:
|
Returns (colors, timing_ms) where colors is a dict {name: (r, g, b)}
|
||||||
capture: ScreenCapture from live_stream.get_latest_frame()
|
and timing_ms is a dict with per-stage timing in milliseconds.
|
||||||
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)
|
|
||||||
"""
|
"""
|
||||||
|
t0 = time.perf_counter()
|
||||||
img = capture.image
|
img = capture.image
|
||||||
h, w = img.shape[:2]
|
h, w = img.shape[:2]
|
||||||
colors = {}
|
colors = {}
|
||||||
@@ -95,6 +92,7 @@ def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing):
|
|||||||
px_h = min(px_h, h - px_y)
|
px_h = min(px_h, h - px_y)
|
||||||
sub_img = img[px_y:px_y + px_h, px_x:px_x + px_w]
|
sub_img = img[px_y:px_y + px_h, px_x:px_x + px_w]
|
||||||
colors[rect.name] = calc_fn(sub_img)
|
colors[rect.name] = calc_fn(sub_img)
|
||||||
|
t1 = time.perf_counter()
|
||||||
if previous_colors and smoothing > 0:
|
if previous_colors and smoothing > 0:
|
||||||
for name, color in colors.items():
|
for name, color in colors.items():
|
||||||
if name in previous_colors:
|
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[1] * (1 - alpha) + prev[1] * alpha),
|
||||||
int(color[2] * (1 - alpha) + prev[2] * 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
|
@dataclass
|
||||||
class ProcessingSettings:
|
class ProcessingSettings:
|
||||||
@@ -137,11 +141,15 @@ class ProcessingMetrics:
|
|||||||
fps_potential: float = 0.0
|
fps_potential: float = 0.0
|
||||||
fps_current: int = 0
|
fps_current: int = 0
|
||||||
# Per-stage timing (ms), averaged over last 10 frames
|
# Per-stage timing (ms), averaged over last 10 frames
|
||||||
|
# LED targets
|
||||||
timing_extract_ms: float = 0.0
|
timing_extract_ms: float = 0.0
|
||||||
timing_map_leds_ms: float = 0.0
|
timing_map_leds_ms: float = 0.0
|
||||||
timing_smooth_ms: float = 0.0
|
timing_smooth_ms: float = 0.0
|
||||||
timing_send_ms: float = 0.0
|
timing_send_ms: float = 0.0
|
||||||
timing_total_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
|
@dataclass
|
||||||
@@ -541,7 +549,8 @@ class ProcessorManager:
|
|||||||
state = self._targets[target_id]
|
state = self._targets[target_id]
|
||||||
|
|
||||||
if state.is_running:
|
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
|
# Enforce one-target-per-device constraint
|
||||||
for other_id, other in self._targets.items():
|
for other_id, other in self._targets.items():
|
||||||
@@ -1230,7 +1239,8 @@ class ProcessorManager:
|
|||||||
|
|
||||||
state = self._kc_targets[target_id]
|
state = self._kc_targets[target_id]
|
||||||
if state.is_running:
|
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:
|
if not state.picture_source_id:
|
||||||
raise ValueError(f"KC target {target_id} has no picture source assigned")
|
raise ValueError(f"KC target {target_id} has no picture source assigned")
|
||||||
@@ -1324,6 +1334,7 @@ class ProcessorManager:
|
|||||||
|
|
||||||
frame_time = 1.0 / target_fps
|
frame_time = 1.0 / target_fps
|
||||||
fps_samples: List[float] = []
|
fps_samples: List[float] = []
|
||||||
|
timing_samples: collections.deque = collections.deque(maxlen=10)
|
||||||
prev_frame_time_stamp = time.time()
|
prev_frame_time_stamp = time.time()
|
||||||
prev_capture = None # Track previous ScreenCapture for change detection
|
prev_capture = None # Track previous ScreenCapture for change detection
|
||||||
last_broadcast_time = 0.0 # Timestamp of last WS broadcast (for keepalive)
|
last_broadcast_time = 0.0 # Timestamp of last WS broadcast (for keepalive)
|
||||||
@@ -1367,7 +1378,7 @@ class ProcessorManager:
|
|||||||
prev_capture = capture
|
prev_capture = capture
|
||||||
|
|
||||||
# CPU-bound work in thread pool
|
# CPU-bound work in thread pool
|
||||||
colors = await asyncio.to_thread(
|
colors, frame_timing = await asyncio.to_thread(
|
||||||
_process_kc_frame,
|
_process_kc_frame,
|
||||||
capture, rectangles, calc_fn,
|
capture, rectangles, calc_fn,
|
||||||
state.previous_colors, smoothing,
|
state.previous_colors, smoothing,
|
||||||
@@ -1377,10 +1388,21 @@ class ProcessorManager:
|
|||||||
state.latest_colors = dict(colors)
|
state.latest_colors = dict(colors)
|
||||||
|
|
||||||
# Broadcast to WebSocket clients
|
# Broadcast to WebSocket clients
|
||||||
|
t_broadcast_start = time.perf_counter()
|
||||||
await self._broadcast_kc_colors(target_id, colors)
|
await self._broadcast_kc_colors(target_id, colors)
|
||||||
|
broadcast_ms = (time.perf_counter() - t_broadcast_start) * 1000
|
||||||
last_broadcast_time = time.time()
|
last_broadcast_time = time.time()
|
||||||
send_timestamps.append(last_broadcast_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
|
# Update metrics
|
||||||
state.metrics.frames_processed += 1
|
state.metrics.frames_processed += 1
|
||||||
state.metrics.last_update = datetime.utcnow()
|
state.metrics.last_update = datetime.utcnow()
|
||||||
@@ -1475,6 +1497,10 @@ class ProcessorManager:
|
|||||||
"frames_skipped": metrics.frames_skipped if state.is_running else None,
|
"frames_skipped": metrics.frames_skipped if state.is_running else None,
|
||||||
"frames_keepalive": metrics.frames_keepalive 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,
|
"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,
|
"last_update": metrics.last_update,
|
||||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4894,6 +4894,24 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
|||||||
<div class="metric-value">${metrics.errors_count || 0}</div>
|
<div class="metric-value">${metrics.errors_count || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${state.timing_total_ms != null ? `
|
||||||
|
<div class="timing-breakdown">
|
||||||
|
<div class="timing-header">
|
||||||
|
<div class="metric-label">${t('device.metrics.timing')}</div>
|
||||||
|
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="timing-bar">
|
||||||
|
<span class="timing-seg timing-extract" style="flex:${state.timing_calc_colors_ms}" title="calc ${state.timing_calc_colors_ms}ms"></span>
|
||||||
|
<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>
|
||||||
|
<span class="timing-seg timing-send" style="flex:${state.timing_broadcast_ms}" title="broadcast ${state.timing_broadcast_ms}ms"></span>
|
||||||
|
</div>
|
||||||
|
<div class="timing-legend">
|
||||||
|
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>calc ${state.timing_calc_colors_ms}ms</span>
|
||||||
|
<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>
|
||||||
|
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>broadcast ${state.timing_broadcast_ms}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
|||||||
Reference in New Issue
Block a user