Initial commit: WLED Screen Controller with FastAPI server and Home Assistant integration
Some checks failed
Validate / validate (push) Failing after 1m6s
Some checks failed
Validate / validate (push) Failing after 1m6s
This is a complete WLED ambient lighting controller that captures screen border pixels and sends them to WLED devices for immersive ambient lighting effects. ## Server Features: - FastAPI-based REST API with 17+ endpoints - Real-time screen capture with multi-monitor support - Advanced LED calibration system with visual GUI - API key authentication with labeled tokens - Per-device brightness control (0-100%) - Configurable FPS (1-60), border width, and color correction - Persistent device storage (JSON-based) - Comprehensive Web UI with dark/light themes - Docker support with docker-compose - Windows monitor name detection via WMI (shows "LG ULTRAWIDE" etc.) ## Web UI Features: - Device management (add, configure, remove WLED devices) - Real-time status monitoring with FPS metrics - Settings modal for device configuration - Visual calibration GUI with edge testing - Brightness slider per device - Display selection with friendly monitor names - Token-based authentication with login/logout - Responsive button layout ## Calibration System: - Support for any LED strip layout (clockwise/counterclockwise) - 4 starting position options (corners) - Per-edge LED count configuration - Visual preview with starting position indicator - Test buttons to light up individual edges - Smart LED ordering based on start position and direction ## Home Assistant Integration: - Custom HACS integration - Switch entities for processing control - Sensor entities for status and FPS - Select entities for display selection - Config flow for easy setup - Auto-discovery of devices from server ## Technical Stack: - Python 3.11+ - FastAPI + uvicorn - mss (screen capture) - httpx (async WLED client) - Pydantic (validation) - WMI (Windows monitor detection) - Structlog (logging) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
214
custom_components/wled_screen_controller/README.md
Normal file
214
custom_components/wled_screen_controller/README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# 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)
|
||||
100
custom_components/wled_screen_controller/__init__.py
Normal file
100
custom_components/wled_screen_controller/__init__.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""The WLED Screen Controller integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, CONF_SERVER_URL, DEFAULT_SCAN_INTERVAL
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.SWITCH,
|
||||
Platform.SENSOR,
|
||||
Platform.SELECT,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up WLED Screen Controller from a config entry."""
|
||||
server_url = entry.data[CONF_SERVER_URL]
|
||||
server_name = entry.data.get(CONF_NAME, "WLED Screen Controller")
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
coordinator = WLEDScreenControllerCoordinator(
|
||||
hass,
|
||||
session,
|
||||
server_url,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
)
|
||||
|
||||
# Fetch initial data
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Create hub device (the server PC)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Parse URL for hub identifier
|
||||
parsed_url = urlparse(server_url)
|
||||
hub_identifier = f"{parsed_url.hostname}:{parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)}"
|
||||
|
||||
hub_device = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, hub_identifier)},
|
||||
name=server_name,
|
||||
manufacturer="WLED Screen Controller",
|
||||
model="Server",
|
||||
sw_version=coordinator.server_version,
|
||||
configuration_url=server_url,
|
||||
)
|
||||
|
||||
# Create device entries for each WLED device
|
||||
if coordinator.data and "devices" in coordinator.data:
|
||||
for device_id, device_data in coordinator.data["devices"].items():
|
||||
device_info = device_data["info"]
|
||||
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[DOMAIN][entry.entry_id] = {
|
||||
"coordinator": coordinator,
|
||||
"hub_device_id": hub_device.id,
|
||||
}
|
||||
|
||||
# Setup platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry."""
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_setup_entry(hass, entry)
|
||||
111
custom_components/wled_screen_controller/config_flow.py
Normal file
111
custom_components/wled_screen_controller/config_flow.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Config flow for WLED Screen Controller integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, CONF_SERVER_URL, DEFAULT_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default="WLED Screen Controller"): str,
|
||||
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
"""Normalize URL to ensure port is an integer."""
|
||||
parsed = urlparse(url)
|
||||
|
||||
# If port is specified, ensure it's an integer
|
||||
if parsed.port is not None:
|
||||
# Reconstruct URL with integer port
|
||||
netloc = parsed.hostname or "localhost"
|
||||
port = int(parsed.port) # Cast to int to avoid float
|
||||
if port != (443 if parsed.scheme == "https" else 80):
|
||||
netloc = f"{netloc}:{port}"
|
||||
|
||||
parsed = parsed._replace(netloc=netloc)
|
||||
|
||||
return urlunparse(parsed)
|
||||
|
||||
|
||||
async def validate_server_connection(
|
||||
hass: HomeAssistant, server_url: str
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the server URL by checking the health endpoint."""
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
try:
|
||||
async with session.get(
|
||||
f"{server_url}/health",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return {
|
||||
"version": data.get("version", "unknown"),
|
||||
"status": data.get("status", "unknown"),
|
||||
}
|
||||
raise ConnectionError(f"Server returned status {response.status}")
|
||||
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConnectionError(f"Cannot connect to server: {err}")
|
||||
except Exception as err:
|
||||
raise ConnectionError(f"Unexpected error: {err}")
|
||||
|
||||
|
||||
class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for WLED Screen Controller."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
|
||||
|
||||
try:
|
||||
info = await validate_server_connection(self.hass, server_url)
|
||||
|
||||
# Set unique ID based on server URL
|
||||
await self.async_set_unique_id(server_url)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME],
|
||||
data={
|
||||
CONF_SERVER_URL: server_url,
|
||||
"version": info["version"],
|
||||
},
|
||||
)
|
||||
|
||||
except ConnectionError as err:
|
||||
_LOGGER.error("Connection error: %s", err)
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception: %s", err)
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
23
custom_components/wled_screen_controller/const.py
Normal file
23
custom_components/wled_screen_controller/const.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Constants for the WLED Screen Controller integration."""
|
||||
|
||||
DOMAIN = "wled_screen_controller"
|
||||
|
||||
# Configuration
|
||||
CONF_SERVER_URL = "server_url"
|
||||
|
||||
# Default values
|
||||
DEFAULT_SCAN_INTERVAL = 10 # seconds
|
||||
DEFAULT_TIMEOUT = 10 # seconds
|
||||
|
||||
# Attributes
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
ATTR_FPS_ACTUAL = "fps_actual"
|
||||
ATTR_FPS_TARGET = "fps_target"
|
||||
ATTR_DISPLAY_INDEX = "display_index"
|
||||
ATTR_FRAMES_PROCESSED = "frames_processed"
|
||||
ATTR_ERRORS_COUNT = "errors_count"
|
||||
ATTR_UPTIME = "uptime_seconds"
|
||||
|
||||
# Services
|
||||
SERVICE_START_PROCESSING = "start_processing"
|
||||
SERVICE_STOP_PROCESSING = "stop_processing"
|
||||
179
custom_components/wled_screen_controller/coordinator.py
Normal file
179
custom_components/wled_screen_controller/coordinator.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Data update coordinator for WLED Screen Controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, DEFAULT_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
"""Class to manage fetching WLED Screen Controller data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
session: aiohttp.ClientSession,
|
||||
server_url: str,
|
||||
update_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.server_url = server_url
|
||||
self.session = session
|
||||
self.server_version = "unknown"
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
async with asyncio.timeout(DEFAULT_TIMEOUT):
|
||||
# Fetch server version on first update
|
||||
if self.server_version == "unknown":
|
||||
await self._fetch_server_version()
|
||||
|
||||
# Fetch devices list
|
||||
devices = await self._fetch_devices()
|
||||
|
||||
# Fetch state for each device
|
||||
devices_data = {}
|
||||
for device in devices:
|
||||
device_id = device["id"]
|
||||
try:
|
||||
state = await self._fetch_device_state(device_id)
|
||||
metrics = await self._fetch_device_metrics(device_id)
|
||||
|
||||
devices_data[device_id] = {
|
||||
"info": device,
|
||||
"state": state,
|
||||
"metrics": metrics,
|
||||
}
|
||||
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
|
||||
displays = await self._fetch_displays()
|
||||
|
||||
return {
|
||||
"devices": devices_data,
|
||||
"displays": displays,
|
||||
}
|
||||
|
||||
except asyncio.TimeoutError as err:
|
||||
raise UpdateFailed(f"Timeout fetching data: {err}") from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
async def _fetch_server_version(self) -> None:
|
||||
"""Fetch server version from health endpoint."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/health",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
self.server_version = data.get("version", "unknown")
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to fetch server version: %s", err)
|
||||
self.server_version = "unknown"
|
||||
|
||||
async def _fetch_devices(self) -> list[dict[str, Any]]:
|
||||
"""Fetch devices list."""
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
return data.get("devices", [])
|
||||
|
||||
async def _fetch_device_state(self, device_id: str) -> dict[str, Any]:
|
||||
"""Fetch device processing state."""
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/state",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
|
||||
async def _fetch_device_metrics(self, device_id: str) -> dict[str, Any]:
|
||||
"""Fetch device metrics."""
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/metrics",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
|
||||
async def _fetch_displays(self) -> list[dict[str, Any]]:
|
||||
"""Fetch available displays."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/config/displays",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
return data.get("displays", [])
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to fetch displays: %s", err)
|
||||
return []
|
||||
|
||||
async def start_processing(self, device_id: str) -> None:
|
||||
"""Start processing for a device."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/start",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Refresh data immediately
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def stop_processing(self, device_id: str) -> None:
|
||||
"""Stop processing for a device."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/stop",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Refresh data immediately
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def update_settings(
|
||||
self, device_id: str, settings: dict[str, Any]
|
||||
) -> None:
|
||||
"""Update device settings."""
|
||||
async with self.session.put(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/settings",
|
||||
json=settings,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Refresh data immediately
|
||||
await self.async_request_refresh()
|
||||
12
custom_components/wled_screen_controller/manifest.json
Normal file
12
custom_components/wled_screen_controller/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "wled_screen_controller",
|
||||
"name": "WLED Screen Controller",
|
||||
"codeowners": ["@alexeidolgolyov"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/yourusername/wled-screen-controller",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/yourusername/wled-screen-controller/issues",
|
||||
"requirements": ["aiohttp>=3.9.0"],
|
||||
"version": "0.1.0"
|
||||
}
|
||||
117
custom_components/wled_screen_controller/select.py
Normal file
117
custom_components/wled_screen_controller/select.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""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
|
||||
205
custom_components/wled_screen_controller/sensor.py
Normal file
205
custom_components/wled_screen_controller/sensor.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Sensor platform for WLED Screen Controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .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 sensors."""
|
||||
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"]
|
||||
|
||||
# FPS sensor
|
||||
entities.append(
|
||||
WLEDScreenControllerFPSSensor(
|
||||
coordinator, device_id, device_info, entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
# Status sensor
|
||||
entities.append(
|
||||
WLEDScreenControllerStatusSensor(
|
||||
coordinator, device_id, device_info, entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
# Frames processed sensor
|
||||
entities.append(
|
||||
WLEDScreenControllerFramesSensor(
|
||||
coordinator, device_id, device_info, entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class WLEDScreenControllerFPSSensor(CoordinatorEntity, SensorEntity):
|
||||
"""FPS sensor for WLED Screen Controller."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = "FPS"
|
||||
_attr_icon = "mdi:speedometer"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
device_id: str,
|
||||
device_info: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
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}_fps"
|
||||
self._attr_name = "FPS"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device_id)},
|
||||
}
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the FPS value."""
|
||||
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
|
||||
|
||||
return device_data["state"].get("fps_actual")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional attributes."""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data or not device_data.get("state"):
|
||||
return {}
|
||||
|
||||
return {
|
||||
"target_fps": device_data["state"].get("fps_target"),
|
||||
}
|
||||
|
||||
|
||||
class WLEDScreenControllerStatusSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Status sensor for WLED Screen Controller."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_icon = "mdi:information-outline"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
device_id: str,
|
||||
device_info: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
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}_status"
|
||||
self._attr_name = "Status"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device_id)},
|
||||
}
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
"""Return the status."""
|
||||
if not self.coordinator.data:
|
||||
return "unknown"
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data:
|
||||
return "unavailable"
|
||||
|
||||
if device_data.get("state") and device_data["state"].get("processing"):
|
||||
return "processing"
|
||||
|
||||
return "idle"
|
||||
|
||||
|
||||
class WLEDScreenControllerFramesSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Frames processed sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
_attr_icon = "mdi:counter"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
device_id: str,
|
||||
device_info: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
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}_frames"
|
||||
self._attr_name = "Frames Processed"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device_id)},
|
||||
}
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return frames processed."""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data or not device_data.get("metrics"):
|
||||
return None
|
||||
|
||||
return device_data["metrics"].get("frames_processed", 0)
|
||||
21
custom_components/wled_screen_controller/strings.json
Normal file
21
custom_components/wled_screen_controller/strings.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up WLED Screen Controller",
|
||||
"description": "Enter the URL of your WLED Screen Controller server",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"server_url": "Server URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to server. Check the URL and ensure the server is running.",
|
||||
"unknown": "Unexpected error occurred"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This server is already configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
133
custom_components/wled_screen_controller/switch.py
Normal file
133
custom_components/wled_screen_controller/switch.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Switch platform for WLED Screen Controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
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, ATTR_DEVICE_ID
|
||||
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 switches."""
|
||||
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():
|
||||
entities.append(
|
||||
WLEDScreenControllerSwitch(
|
||||
coordinator, device_id, device_data["info"], entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class WLEDScreenControllerSwitch(CoordinatorEntity, SwitchEntity):
|
||||
"""Representation of a WLED Screen Controller processing switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
device_id: str,
|
||||
device_info: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
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}_processing"
|
||||
self._attr_name = "Processing"
|
||||
self._attr_icon = "mdi:television-ambient-light"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device_id)},
|
||||
}
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if processing is active."""
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data or not device_data.get("state"):
|
||||
return False
|
||||
|
||||
return device_data["state"].get("processing", False)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
return device_data is not None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional state attributes."""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data:
|
||||
return {}
|
||||
|
||||
state = device_data.get("state", {})
|
||||
metrics = device_data.get("metrics", {})
|
||||
|
||||
attrs = {
|
||||
ATTR_DEVICE_ID: self._device_id,
|
||||
}
|
||||
|
||||
if state:
|
||||
attrs["fps_target"] = state.get("fps_target")
|
||||
attrs["fps_actual"] = state.get("fps_actual")
|
||||
attrs["display_index"] = state.get("display_index")
|
||||
|
||||
if metrics:
|
||||
attrs["frames_processed"] = metrics.get("frames_processed")
|
||||
attrs["errors_count"] = metrics.get("errors_count")
|
||||
attrs["uptime_seconds"] = metrics.get("uptime_seconds")
|
||||
|
||||
return attrs
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on processing."""
|
||||
try:
|
||||
await self.coordinator.start_processing(self._device_id)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Failed to start processing: %s", err)
|
||||
raise
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off processing."""
|
||||
try:
|
||||
await self.coordinator.stop_processing(self._device_id)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Failed to stop processing: %s", err)
|
||||
raise
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up WLED Screen Controller",
|
||||
"description": "Enter the URL of your WLED Screen Controller server",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"server_url": "Server URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to server. Check the URL and ensure the server is running.",
|
||||
"unknown": "Unexpected error occurred"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This server is already configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user