Initial commit: WLED Screen Controller with FastAPI server and Home Assistant integration
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:
2026-02-06 16:38:27 +03:00
commit d471a40234
57 changed files with 9726 additions and 0 deletions

17
.github/workflows/validate.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Validate
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v3"
- name: HACS validation
uses: "hacs/action@main"
with:
category: "integration"

72
.gitignore vendored Normal file
View File

@@ -0,0 +1,72 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
env/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
.claude/
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# Logs
*.log
logs/
*.log.*
# Runtime data
data/
*.db
*.sqlite
*.json.bak
# Environment variables
.env
.env.local
# Docker
.dockerignore
# Home Assistant
homeassistant/.storage/
# Temporary files
*.tmp
temp/
tmp/
# OS
Thumbs.db
.DS_Store

305
INSTALLATION.md Normal file
View File

@@ -0,0 +1,305 @@
# Installation Guide
Complete installation guide for WLED Screen Controller server and Home Assistant integration.
## Table of Contents
1. [Server Installation](#server-installation)
2. [Home Assistant Integration](#home-assistant-integration)
3. [Quick Start](#quick-start)
---
## Server Installation
### Option 1: Python (Development/Testing)
**Requirements:**
- Python 3.11 or higher
- Windows, Linux, or macOS
**Steps:**
1. **Clone the repository:**
```bash
git clone https://github.com/yourusername/wled-screen-controller.git
cd wled-screen-controller/server
```
2. **Create virtual environment:**
```bash
python -m venv venv
# Windows
venv\Scripts\activate
# Linux/Mac
source venv/bin/activate
```
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
4. **Configure (optional):**
Edit `config/default_config.yaml` to customize settings.
5. **Run the server:**
```bash
# Set PYTHONPATH
export PYTHONPATH=$(pwd)/src # Linux/Mac
set PYTHONPATH=%CD%\src # Windows
# Start server
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
```
6. **Verify:**
Open http://localhost:8080/docs in your browser.
### Option 2: Docker (Recommended for Production)
**Requirements:**
- Docker
- Docker Compose
**Steps:**
1. **Clone the repository:**
```bash
git clone https://github.com/yourusername/wled-screen-controller.git
cd wled-screen-controller/server
```
2. **Start with Docker Compose:**
```bash
docker-compose up -d
```
3. **View logs:**
```bash
docker-compose logs -f
```
4. **Verify:**
Open http://localhost:8080/docs in your browser.
### Option 3: Docker (Manual Build)
```bash
cd server
docker build -t wled-screen-controller .
docker run -d \
--name wled-controller \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \
--network host \
wled-screen-controller
```
---
## Home Assistant Integration
### Option 1: HACS (Recommended)
1. **Install HACS** if not already installed:
- Follow instructions at https://hacs.xyz/docs/setup/download
2. **Add Custom Repository:**
- Open HACS in Home Assistant
- Click the three dots 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 Integration:**
- 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
### Option 2: Manual Installation
1. **Download Integration:**
```bash
cd /config # Your Home Assistant config directory
mkdir -p custom_components
```
2. **Copy Files:**
Copy the `custom_components/wled_screen_controller` folder to your Home Assistant `custom_components` directory.
3. **Restart Home Assistant**
4. **Configure Integration:**
- Go to Settings → Devices & Services
- Click "+ Add Integration"
- Search for "WLED Screen Controller"
- Enter your server URL
- Click Submit
---
## Quick Start
### 1. Start the Server
```bash
cd wled-screen-controller/server
docker-compose up -d
```
### 2. Attach Your WLED Device
```bash
curl -X POST http://localhost:8080/api/v1/devices \
-H "Content-Type: application/json" \
-d '{
"name": "Living Room TV",
"url": "http://192.168.1.100",
"led_count": 150
}'
```
### 3. Configure in Home Assistant
1. Add the integration (see above)
2. Your WLED devices will appear automatically
3. Use the switch to turn processing on/off
4. Use the select to choose display
5. Monitor FPS and status via sensors
### 4. Start Processing
Either via API:
```bash
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
```
Or via Home Assistant:
- Turn on the "{Device Name} Processing" switch
### 5. Enjoy Ambient Lighting!
Your WLED strip should now sync with your screen content!
---
## Troubleshooting
### Server Won't Start
**Check Python version:**
```bash
python --version # Should be 3.11+
```
**Check dependencies:**
```bash
pip list | grep fastapi
```
**Check logs:**
```bash
# Docker
docker-compose logs -f
# Python
tail -f logs/wled_controller.log
```
### Home Assistant Integration Not Appearing
1. Check HACS installation
2. Clear browser cache
3. Restart Home Assistant
4. Check Home Assistant logs:
- Settings → System → Logs
- Search for "wled_screen_controller"
### Can't Connect to Server from Home Assistant
1. Verify server is running:
```bash
curl http://YOUR_SERVER_IP:8080/health
```
2. Check firewall rules
3. Ensure Home Assistant can reach server IP
4. Try http:// not https://
### WLED Device Not Responding
1. Check WLED device is powered on
2. Verify IP address is correct
3. Test WLED directly:
```bash
curl http://YOUR_WLED_IP/json/info
```
4. Check network connectivity
### Low FPS / Performance Issues
1. Reduce target FPS (Settings → Devices)
2. Reduce `border_width` in settings
3. Check CPU usage on server
4. Consider reducing LED count
---
## Configuration Examples
### Server Environment Variables
```bash
# Docker .env file
WLED_SERVER__HOST=0.0.0.0
WLED_SERVER__PORT=8080
WLED_SERVER__LOG_LEVEL=INFO
WLED_PROCESSING__DEFAULT_FPS=30
WLED_PROCESSING__BORDER_WIDTH=10
```
### Home Assistant Automation Example
```yaml
automation:
- alias: "Auto Start WLED on TV On"
trigger:
- platform: state
entity_id: media_player.living_room_tv
to: "on"
action:
- service: switch.turn_on
target:
entity_id: switch.living_room_tv_processing
- alias: "Auto Stop WLED on TV Off"
trigger:
- platform: state
entity_id: media_player.living_room_tv
to: "off"
action:
- service: switch.turn_off
target:
entity_id: switch.living_room_tv_processing
```
---
## Next Steps
- [API Documentation](docs/API.md)
- [Calibration Guide](docs/CALIBRATION.md)
- [GitHub Issues](https://github.com/yourusername/wled-screen-controller/issues)

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Alexei Dolgolyov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

194
README.md Normal file
View File

@@ -0,0 +1,194 @@
# WLED Screen Controller
Ambient lighting controller that synchronizes WLED devices with your screen content for an immersive viewing experience.
## Overview
This project consists of two components:
1. **Python Server** - Captures screen border pixels and sends color data to WLED devices via REST API
2. **Home Assistant Integration** - Controls and monitors the server from Home Assistant OS
## Features
- 🖥️ **Multi-Monitor Support** - Select which display to capture
-**Configurable FPS** - Adjust update rate (1-60 FPS)
- 🎨 **Smart Calibration** - Map screen edges to LED positions
- 🔌 **REST API** - Full control via HTTP endpoints
- 🏠 **Home Assistant Integration** - Native HAOS support with entities
- 🐳 **Docker Support** - Easy deployment with Docker Compose
- 📊 **Real-time Metrics** - Monitor FPS, status, and performance
## Requirements
### Server
- Python 3.11 or higher
- Windows, Linux, or macOS
- WLED device on the same network
### Home Assistant Integration
- Home Assistant OS 2023.1 or higher
- Running WLED Screen Controller server
## Quick Start
### Server Installation
1. **Clone the repository**
```bash
git clone https://github.com/yourusername/wled-screen-controller.git
cd wled-screen-controller/server
```
2. **Install dependencies**
```bash
pip install -r requirements.txt
```
3. **Run the server**
```bash
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
```
4. **Access the API**
- API: http://localhost:8080
- Interactive docs: http://localhost:8080/docs
### Docker Installation
```bash
cd server
docker-compose up -d
```
## Configuration
Edit `server/config/default_config.yaml`:
```yaml
server:
host: "0.0.0.0"
port: 8080
processing:
default_fps: 30
border_width: 10
wled:
timeout: 5
retry_attempts: 3
```
## API Usage
### Attach a WLED Device
```bash
curl -X POST http://localhost:8080/api/v1/devices \
-H "Content-Type: application/json" \
-d '{
"name": "Living Room TV",
"url": "http://192.168.1.100",
"led_count": 150
}'
```
### Start Processing
```bash
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
```
### Get Status
```bash
curl http://localhost:8080/api/v1/devices/{device_id}/state
```
See [API Documentation](docs/API.md) for complete API reference.
## Calibration
The calibration system maps screen border pixels to LED positions. See [Calibration Guide](docs/CALIBRATION.md) for details.
Example calibration:
```json
{
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [
{"edge": "bottom", "led_start": 0, "led_count": 40},
{"edge": "right", "led_start": 40, "led_count": 30},
{"edge": "top", "led_start": 70, "led_count": 40},
{"edge": "left", "led_start": 110, "led_count": 40}
]
}
```
## Home Assistant Integration
1. Copy `homeassistant/custom_components/wled_screen_controller` to your Home Assistant `custom_components` folder
2. Restart Home Assistant
3. Go to Settings → Integrations → Add Integration
4. Search for "WLED Screen Controller"
5. Enter your server URL
## Development
### Running Tests
```bash
cd server
pytest tests/ -v
```
### Project Structure
```
wled-screen-controller/
├── server/ # Python FastAPI server
│ ├── src/wled_controller/ # Main application code
│ ├── tests/ # Unit and integration tests
│ ├── config/ # Configuration files
│ └── requirements.txt # Python dependencies
├── homeassistant/ # Home Assistant integration
│ └── custom_components/
└── docs/ # Documentation
```
## Troubleshooting
### Screen capture fails
- **Windows**: Ensure Python has screen capture permissions
- **Linux**: Install X11 dependencies: `apt-get install libxcb1 libxcb-randr0`
- **macOS**: Grant screen recording permission in System Preferences
### WLED not responding
- Verify WLED device is on the same network
- Check firewall settings
- Test connection: `curl http://YOUR_WLED_IP/json/info`
### Low FPS
- Reduce `border_width` in configuration
- Lower target FPS
- Check network latency to WLED device
- Reduce LED count
## License
MIT License - see [LICENSE](LICENSE) file
## Contributing
Contributions welcome! Please open an issue or pull request.
## Acknowledgments
- [WLED](https://github.com/Aircoookie/WLED) - Amazing LED control software
- [FastAPI](https://fastapi.tiangolo.com/) - Modern Python web framework
- [mss](https://python-mss.readthedocs.io/) - Fast screen capture library
## Support
- GitHub Issues: [Report a bug](https://github.com/yourusername/wled-screen-controller/issues)
- Discussions: [Ask a question](https://github.com/yourusername/wled-screen-controller/discussions)

View 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)

View 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)

View 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,
)

View 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"

View 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()

View 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"
}

View 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

View 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)

View 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"
}
}
}

View 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

View 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"
}
}
}

341
docs/API.md Normal file
View File

@@ -0,0 +1,341 @@
# WLED Screen Controller API Documentation
Complete REST API reference for the WLED Screen Controller server.
**Base URL:** `http://localhost:8080`
**API Version:** v1
---
## Table of Contents
- [Health & Info](#health--info)
- [Device Management](#device-management)
- [Processing Control](#processing-control)
- [Settings Management](#settings-management)
- [Calibration](#calibration)
- [Metrics](#metrics)
---
## Health & Info
### GET /health
Health check endpoint.
**Response:**
```json
{
"status": "healthy",
"timestamp": "2026-02-06T12:00:00Z",
"version": "0.1.0"
}
```
### GET /api/v1/version
Get version information.
**Response:**
```json
{
"version": "0.1.0",
"python_version": "3.11.0",
"api_version": "v1"
}
```
### GET /api/v1/config/displays
List available displays for screen capture.
**Response:**
```json
{
"displays": [
{
"index": 0,
"name": "Display 1",
"width": 1920,
"height": 1080,
"is_primary": true
}
],
"count": 1
}
```
---
## Device Management
### POST /api/v1/devices
Create and attach a new WLED device.
**Request:**
```json
{
"name": "Living Room TV",
"url": "http://192.168.1.100",
"led_count": 150
}
```
**Response:** `201 Created`
```json
{
"id": "device_abc123",
"name": "Living Room TV",
"url": "http://192.168.1.100",
"led_count": 150,
"enabled": true,
"status": "disconnected",
"settings": {
"display_index": 0,
"fps": 30,
"border_width": 10
},
"calibration": {
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [...]
},
"created_at": "2026-02-06T12:00:00Z",
"updated_at": "2026-02-06T12:00:00Z"
}
```
### GET /api/v1/devices
List all attached devices.
**Response:**
```json
{
"devices": [...],
"count": 2
}
```
### GET /api/v1/devices/{device_id}
Get device details.
**Response:** Same as POST response
### PUT /api/v1/devices/{device_id}
Update device information.
**Request:**
```json
{
"name": "Updated Name",
"enabled": true
}
```
### DELETE /api/v1/devices/{device_id}
Delete/detach a device.
**Response:** `204 No Content`
---
## Processing Control
### POST /api/v1/devices/{device_id}/start
Start screen processing for a device.
**Response:**
```json
{
"status": "started",
"device_id": "device_abc123"
}
```
### POST /api/v1/devices/{device_id}/stop
Stop screen processing.
**Response:**
```json
{
"status": "stopped",
"device_id": "device_abc123"
}
```
### GET /api/v1/devices/{device_id}/state
Get current processing state.
**Response:**
```json
{
"device_id": "device_abc123",
"processing": true,
"fps_actual": 29.8,
"fps_target": 30,
"display_index": 0,
"last_update": "2026-02-06T12:00:00Z",
"errors": []
}
```
---
## Settings Management
### GET /api/v1/devices/{device_id}/settings
Get processing settings.
**Response:**
```json
{
"display_index": 0,
"fps": 30,
"border_width": 10,
"color_correction": {
"gamma": 2.2,
"saturation": 1.0,
"brightness": 1.0
}
}
```
### PUT /api/v1/devices/{device_id}/settings
Update processing settings.
**Request:**
```json
{
"display_index": 1,
"fps": 60,
"border_width": 15,
"color_correction": {
"gamma": 2.4,
"saturation": 1.2,
"brightness": 0.8
}
}
```
---
## Calibration
### GET /api/v1/devices/{device_id}/calibration
Get calibration configuration.
**Response:**
```json
{
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [
{
"edge": "bottom",
"led_start": 0,
"led_count": 40,
"reverse": false
},
{
"edge": "right",
"led_start": 40,
"led_count": 30,
"reverse": false
},
{
"edge": "top",
"led_start": 70,
"led_count": 40,
"reverse": true
},
{
"edge": "left",
"led_start": 110,
"led_count": 40,
"reverse": true
}
]
}
```
### PUT /api/v1/devices/{device_id}/calibration
Update calibration.
**Request:** Same as GET response
### POST /api/v1/devices/{device_id}/calibration/test
Test calibration by lighting up specific edge.
**Query Parameters:**
- `edge`: Edge to test (top, right, bottom, left)
- `color`: RGB color array (e.g., [255, 0, 0])
---
## Metrics
### GET /api/v1/devices/{device_id}/metrics
Get detailed processing metrics.
**Response:**
```json
{
"device_id": "device_abc123",
"processing": true,
"fps_actual": 29.8,
"fps_target": 30,
"uptime_seconds": 3600.5,
"frames_processed": 107415,
"errors_count": 2,
"last_error": null,
"last_update": "2026-02-06T12:00:00Z"
}
```
---
## Error Responses
All endpoints may return error responses in this format:
```json
{
"error": "ErrorType",
"message": "Human-readable error message",
"detail": {...},
"timestamp": "2026-02-06T12:00:00Z"
}
```
**Common HTTP Status Codes:**
- `200 OK` - Success
- `201 Created` - Resource created
- `204 No Content` - Success with no response body
- `400 Bad Request` - Invalid request
- `404 Not Found` - Resource not found
- `500 Internal Server Error` - Server error
---
## Interactive Documentation
The server provides interactive API documentation:
- **Swagger UI:** http://localhost:8080/docs
- **ReDoc:** http://localhost:8080/redoc
- **OpenAPI JSON:** http://localhost:8080/openapi.json

277
docs/CALIBRATION.md Normal file
View File

@@ -0,0 +1,277 @@
# Calibration Guide
This guide explains how to calibrate your WLED strip to match your screen layout.
## Overview
Calibration maps screen border pixels to LED positions on your WLED strip. Proper calibration ensures that the colors on your LEDs accurately reflect what's on your screen edges.
## Understanding LED Layout
### Physical Setup
Most WLED ambient lighting setups have LEDs arranged around a TV/monitor:
```
TOP (40 LEDs)
┌─────────────────┐
│ │
LEFT│ │RIGHT
(40)│ │(30)
│ │
└─────────────────┘
BOTTOM (40 LEDs)
```
### LED Numbering
WLED strips are numbered sequentially. You need to know:
1. **Starting Position:** Where is LED #0?
2. **Direction:** Clockwise or counterclockwise?
3. **LEDs per Edge:** How many LEDs on each side?
## Default Calibration
When you attach a device, a default calibration is created:
- **Layout:** Clockwise
- **Start Position:** Bottom-left corner
- **LED Distribution:** Evenly distributed across 4 edges
### Example (150 LEDs):
```json
{
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [
{"edge": "bottom", "led_start": 0, "led_count": 38},
{"edge": "right", "led_start": 38, "led_count": 37},
{"edge": "top", "led_start": 75, "led_count": 38, "reverse": true},
{"edge": "left", "led_start": 113, "led_count": 37, "reverse": true}
]
}
```
## Custom Calibration
### Step 1: Identify Your LED Layout
1. Turn on your WLED device
2. Note which LED is #0 (first LED)
3. Observe the direction LEDs are numbered
4. Count LEDs on each edge
### Step 2: Create Calibration Config
Create a calibration configuration matching your setup:
```json
{
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [
{
"edge": "bottom",
"led_start": 0,
"led_count": 50,
"reverse": false
},
{
"edge": "right",
"led_start": 50,
"led_count": 30,
"reverse": false
},
{
"edge": "top",
"led_start": 80,
"led_count": 50,
"reverse": true
},
{
"edge": "left",
"led_start": 130,
"led_count": 30,
"reverse": true
}
]
}
```
### Step 3: Apply Calibration
Update via API:
```bash
curl -X PUT http://localhost:8080/api/v1/devices/{device_id}/calibration \
-H "Content-Type: application/json" \
-d @calibration.json
```
### Step 4: Test Calibration
Test each edge to verify:
```bash
# Test top edge (should light up top LEDs)
curl -X POST "http://localhost:8080/api/v1/devices/{device_id}/calibration/test?edge=top&color=[255,0,0]"
# Test right edge
curl -X POST "http://localhost:8080/api/v1/devices/{device_id}/calibration/test?edge=right&color=[0,255,0]"
# Test bottom edge
curl -X POST "http://localhost:8080/api/v1/devices/{device_id}/calibration/test?edge=bottom&color=[0,0,255]"
# Test left edge
curl -X POST "http://localhost:8080/api/v1/devices/{device_id}/calibration/test?edge=left&color=[255,255,0]"
```
## Calibration Parameters
### Layout
- `clockwise`: LEDs numbered in clockwise direction
- `counterclockwise`: LEDs numbered counter-clockwise
### Start Position
Where LED #0 is located:
- `top_left`
- `top_right`
- `bottom_left` (most common)
- `bottom_right`
### Segments
Each segment defines one edge of the screen:
- `edge`: Which screen edge (`top`, `right`, `bottom`, `left`)
- `led_start`: First LED index for this edge
- `led_count`: Number of LEDs on this edge
- `reverse`: Whether to reverse LED order for this edge
### Reverse Flag
The `reverse` flag is used when LEDs go the opposite direction from screen pixels:
- **Top edge:** Usually reversed (LEDs go right-to-left)
- **Bottom edge:** Usually not reversed (LEDs go left-to-right)
- **Left edge:** Usually reversed (LEDs go bottom-to-top)
- **Right edge:** Usually not reversed (LEDs go top-to-bottom)
## Common Layouts
### Standard Clockwise (Bottom-Left Start)
```json
{
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [
{"edge": "bottom", "led_start": 0, "led_count": 40, "reverse": false},
{"edge": "right", "led_start": 40, "led_count": 30, "reverse": false},
{"edge": "top", "led_start": 70, "led_count": 40, "reverse": true},
{"edge": "left", "led_start": 110, "led_count": 40, "reverse": true}
]
}
```
### Counter-Clockwise (Top-Left Start)
```json
{
"layout": "counterclockwise",
"start_position": "top_left",
"segments": [
{"edge": "top", "led_start": 0, "led_count": 50, "reverse": false},
{"edge": "left", "led_start": 50, "led_count": 30, "reverse": false},
{"edge": "bottom", "led_start": 80, "led_count": 50, "reverse": true},
{"edge": "right", "led_start": 130, "led_count": 30, "reverse": true}
]
}
```
### Three-Sided Setup (No Top Edge)
```json
{
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [
{"edge": "bottom", "led_start": 0, "led_count": 50, "reverse": false},
{"edge": "right", "led_start": 50, "led_count": 40, "reverse": false},
{"edge": "left", "led_start": 90, "led_count": 40, "reverse": true}
]
}
```
## Troubleshooting
### Colors Don't Match
**Problem:** LED colors don't match screen content.
**Solutions:**
1. Verify LED start indices don't overlap
2. Check reverse flags for each edge
3. Test each edge individually
4. Verify total LED count matches device
### LEDs Light Up Wrong Edge
**Problem:** Top edge lights up when bottom should.
**Solutions:**
1. Check `led_start` values for each segment
2. Verify `layout` (clockwise vs counterclockwise)
3. Confirm `start_position` matches your physical setup
### Corner LEDs Wrong
**Problem:** Corner LEDs show wrong colors.
**Solutions:**
1. Adjust LED counts per edge
2. Ensure segments don't overlap
3. Check if corner LEDs should be in adjacent segment
### Some LEDs Don't Light Up
**Problem:** Part of the strip stays dark.
**Solutions:**
1. Verify total LEDs in calibration matches device
2. Check for gaps in LED indices
3. Ensure all edges are defined if LEDs exist there
## Validation
The calibration system automatically validates:
- No duplicate edges
- No overlapping LED indices
- All LED counts are positive
- All start indices are non-negative
If validation fails, you'll receive an error message explaining the issue.
## Tips
1. **Start Simple:** Use default calibration first, then customize
2. **Test Often:** Use test endpoint after each change
3. **Document Your Setup:** Save your working calibration
4. **Physical Labels:** Label your LED strip to remember layout
5. **Photos Help:** Take photos of your setup with LED numbers visible
## Example Workflow
1. Install WLED strip around TV
2. Note LED #0 position
3. Create device in API (gets default calibration)
4. Test default calibration
5. Adjust based on test results
6. Save final calibration
7. Start processing and enjoy!

6
hacs.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "WLED Screen Controller",
"render_readme": true,
"country": ["US"],
"homeassistant": "2023.1.0"
}

39
server/Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
FROM python:3.11-slim
LABEL maintainer="Alexei Dolgolyov <dolgolyov.alexei@gmail.com>"
LABEL description="WLED Screen Controller - Ambient lighting based on screen content"
WORKDIR /app
# Install system dependencies for screen capture
RUN apt-get update && apt-get install -y \
libxcb1 \
libxcb-randr0 \
libxcb-shm0 \
libxcb-xfixes0 \
libxcb-shape0 \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY src/ ./src/
COPY config/ ./config/
# Create directories for data and logs
RUN mkdir -p /app/data /app/logs
# Expose API port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8080/health', timeout=5.0)" || exit 1
# Set Python path
ENV PYTHONPATH=/app/src
# Run the application
CMD ["uvicorn", "wled_controller.main:app", "--host", "0.0.0.0", "--port", "8080"]

191
server/README.md Normal file
View File

@@ -0,0 +1,191 @@
# WLED Screen Controller - Server
High-performance FastAPI server that captures screen content and controls WLED devices for ambient lighting.
## Overview
The server component provides:
- 🎯 **Real-time Screen Capture** - Multi-monitor support with configurable FPS
- 🎨 **Advanced Processing** - Border pixel extraction with color correction
- 🔧 **Flexible Calibration** - Map screen edges to any LED layout
- 🌐 **REST API** - Complete control via 17 REST endpoints
- 💾 **Persistent Storage** - JSON-based device and configuration management
- 📊 **Metrics & Monitoring** - Real-time FPS, status, and performance data
## Quick Start
### Option 1: Docker (Recommended)
```bash
# Start server
docker-compose up -d
# View logs
docker-compose logs -f
# Stop server
docker-compose down
```
Server runs on: **http://localhost:8080**
### Option 2: Python
```bash
# Create virtual environment
python -m venv venv
# Activate
source venv/bin/activate # Linux/Mac
venv\Scripts\activate # Windows
# Install dependencies
pip install -r requirements.txt
# Set PYTHONPATH
export PYTHONPATH=$(pwd)/src # Linux/Mac
set PYTHONPATH=%CD%\src # Windows
# Run server
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
```
## Installation
### Requirements
- **Python 3.11+** (for Python installation)
- **Docker & Docker Compose** (for Docker installation)
- **WLED device** on your network
See [../INSTALLATION.md](../INSTALLATION.md) for comprehensive installation guide.
## Configuration
### Configuration File
Edit `config/default_config.yaml`:
```yaml
server:
host: "0.0.0.0"
port: 8080
log_level: "INFO"
processing:
default_fps: 30 # Target frames per second
max_fps: 60 # Maximum allowed FPS
border_width: 10 # Pixels to sample from edge
wled:
timeout: 5 # Connection timeout (seconds)
retry_attempts: 3 # Number of retries
storage:
devices_file: "data/devices.json"
logging:
format: "json"
file: "logs/wled_controller.log"
```
### Environment Variables
```bash
# Server configuration
export WLED_SERVER__HOST="0.0.0.0"
export WLED_SERVER__PORT=8080
export WLED_SERVER__LOG_LEVEL="INFO"
# Processing configuration
export WLED_PROCESSING__DEFAULT_FPS=30
export WLED_PROCESSING__BORDER_WIDTH=10
# WLED configuration
export WLED_WLED__TIMEOUT=5
```
## Usage
### WLED Device Setup
**Important**: Configure your WLED device using the official WLED web interface before connecting it to this controller:
1. **Access WLED Interface**: Open `http://[wled-ip]` in your browser
2. **Configure Device Settings**:
- Set LED count and type
- Configure brightness, color order, and power limits
- Set up segments if needed
- Configure effects and presets
**This controller only sends pixel color data** - it does not manage WLED settings like brightness, effects, or segments. All WLED device configuration should be done through the official WLED interface.
### API Documentation
- **Web UI**: http://localhost:8080 (recommended for device management)
- **Swagger UI**: http://localhost:8080/docs
- **ReDoc**: http://localhost:8080/redoc
### Quick Example
```bash
# 1. Add device
curl -X POST http://localhost:8080/api/v1/devices \
-H "Content-Type: application/json" \
-d '{"name":"Living Room","url":"http://192.168.1.100","led_count":150}'
# 2. Start processing
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
# 3. Check status
curl http://localhost:8080/api/v1/devices/{device_id}/state
```
## Testing
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=wled_controller --cov-report=html
# Run specific test
pytest tests/test_screen_capture.py -v
```
## Development
### Project Structure
```
src/wled_controller/
├── main.py # FastAPI application
├── config.py # Configuration
├── api/ # API routes
├── core/ # Core functionality
│ ├── screen_capture.py
│ ├── wled_client.py
│ ├── calibration.py
│ └── processor_manager.py
├── storage/ # Data persistence
└── utils/ # Utilities
```
### Code Quality
```bash
# Format code
black src/ tests/
# Lint code
ruff check src/ tests/
```
## License
MIT - see [../LICENSE](../LICENSE)
## Support
- 📖 [Full Documentation](../docs/)
- 🐛 [Issues](https://github.com/yourusername/wled-screen-controller/issues)

View File

@@ -0,0 +1,42 @@
server:
host: "0.0.0.0"
port: 8080
log_level: "INFO"
cors_origins:
- "*"
auth:
# API keys are REQUIRED - authentication is always enforced
# Format: label: "api-key"
api_keys:
# Generate secure keys: openssl rand -hex 32
# IMPORTANT: Add at least one key before starting the server
# home_assistant: "your-secure-api-key-1"
# web_dashboard: "your-secure-api-key-2"
# monitoring_script: "your-secure-api-key-3"
processing:
default_fps: 30
max_fps: 60
min_fps: 1
border_width: 10 # pixels to sample from screen edge
interpolation_mode: "average" # average, median, dominant
screen_capture:
buffer_size: 2 # Number of frames to buffer
wled:
timeout: 5 # seconds
retry_attempts: 3
retry_delay: 1 # seconds
protocol: "http" # http or https
max_brightness: 255
storage:
devices_file: "data/devices.json"
logging:
format: "json" # json or text
file: "logs/wled_controller.log"
max_size_mb: 100
backup_count: 5

View File

@@ -0,0 +1,38 @@
server:
host: "127.0.0.1" # localhost only for testing
port: 8080
log_level: "DEBUG" # Verbose logging for testing
cors_origins:
- "*"
auth:
# Test API keys - DO NOT use in production!
api_keys:
test_client: "eb8a89cfd33ab067751fd0e38f74ddf7ac3d75ff012fbab35a616c45c12e0c8d"
web_dashboard: "4b958666d32b368a89781da040a615283541418753d610858d6eb5411296dcb6"
processing:
default_fps: 30
max_fps: 60
min_fps: 1
border_width: 10
interpolation_mode: "average"
screen_capture:
buffer_size: 2
wled:
timeout: 5
retry_attempts: 3
retry_delay: 1
protocol: "http"
max_brightness: 255
storage:
devices_file: "data/test_devices.json"
logging:
format: "text" # Easier to read during testing
file: "logs/wled_test.log"
max_size_mb: 10
backup_count: 2

45
server/docker-compose.yml Normal file
View File

@@ -0,0 +1,45 @@
version: '3.8'
services:
wled-controller:
build:
context: .
dockerfile: Dockerfile
container_name: wled-screen-controller
restart: unless-stopped
ports:
- "8080:8080"
volumes:
# Persist device data
- ./data:/app/data
# Persist logs
- ./logs:/app/logs
# Mount configuration (optional override)
- ./config:/app/config
# Required for screen capture on Linux
- /tmp/.X11-unix:/tmp/.X11-unix:ro
environment:
# Server configuration
- WLED_SERVER__HOST=0.0.0.0
- WLED_SERVER__PORT=8080
- WLED_SERVER__LOG_LEVEL=INFO
# Display for X11 (Linux only)
- DISPLAY=${DISPLAY:-:0}
# Processing defaults
- WLED_PROCESSING__DEFAULT_FPS=30
- WLED_PROCESSING__BORDER_WIDTH=10
# Use host network for screen capture access
# network_mode: host # Uncomment for Linux screen capture
networks:
- wled-network
networks:
wled-network:
driver: bridge

View File

@@ -0,0 +1,237 @@
# API Authentication Guide
WLED Screen Controller **requires** API key authentication for all API endpoints. This ensures your server is secure and all access is properly authenticated and audited.
## Configuration
Authentication is configured in `config/default_config.yaml`. **API keys are mandatory** - the server will not start without at least one configured key.
### Configure API Keys
```yaml
auth:
enabled: true
api_keys:
home_assistant: "your-secure-api-key-1"
web_dashboard: "your-secure-api-key-2"
monitoring_script: "your-secure-api-key-3"
```
**Format:** API keys are defined as `label: "key"` pairs where:
- **Label**: Identifier for the client (e.g., `home_assistant`, `web_ui`, `admin`)
- **Key**: The actual API key (generate strong, random keys)
**Critical Requirements:**
- ⚠️ **At least one API key must be configured** - server will not start without keys
- Generate strong, random API keys (use `openssl rand -hex 32`)
- Never commit API keys to version control
- Use environment variables or secure secret management for production
- Each client/service gets its own labeled API key for audit trails
- Labels appear in server logs to track which client made requests
### Server Startup Validation
If no API keys are configured, the server will fail to start with this error:
```
CRITICAL: No API keys configured!
Authentication is REQUIRED for all API requests.
Please add API keys to your configuration:
1. Generate keys: openssl rand -hex 32
2. Add to config/default_config.yaml under auth.api_keys
3. Format: label: "your-generated-key"
```
## Using API Keys
### Web UI
The web dashboard automatically handles authentication:
1. When you first load the UI with auth enabled, you'll be prompted for an API key
2. Enter your API key - it's stored in browser localStorage
3. Click the 🔑 **API Key** button in the header to update or remove the key
### REST API Clients
Include the API key in the `Authorization` header:
```bash
curl -H "Authorization: Bearer your-api-key-here" \
http://localhost:8080/api/v1/devices
```
### Home Assistant Integration
The integration will prompt for an API key during setup if authentication is enabled. You can also configure it in `configuration.yaml`:
```yaml
wled_screen_controller:
server_url: "http://192.168.1.100:8080"
api_key: "your-api-key-here" # Optional, only if auth is enabled
```
### Python Client Example
```python
import requests
API_KEY = "your-api-key-here"
BASE_URL = "http://localhost:8080/api/v1"
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"
}
# List devices
response = requests.get(f"{BASE_URL}/devices", headers=headers)
devices = response.json()
# Start processing
response = requests.post(
f"{BASE_URL}/devices/device_001/start",
headers=headers
)
```
## Error Responses
### 401 Unauthorized
**Missing API Key:**
```json
{
"detail": "Missing API key"
}
```
**Invalid API Key:**
```json
{
"detail": "Invalid API key"
}
```
### 500 Internal Server Error
**Auth enabled but no keys configured:**
```json
{
"detail": "Server authentication not configured"
}
```
## Endpoints
### Public Endpoints (No Auth Required)
- `GET /` - Web UI dashboard (static files)
- `GET /health` - Health check (for monitoring/health checks)
- `GET /docs` - API documentation
- `GET /redoc` - Alternative API docs
- `/static/*` - Static files (CSS, JS, images)
### Protected Endpoints (Auth ALWAYS Required)
All `/api/v1/*` endpoints **require authentication**:
- Device management (`/api/v1/devices/*`)
- Processing control (`/api/v1/devices/*/start`, `/api/v1/devices/*/stop`)
- Settings and calibration (`/api/v1/devices/*/settings`, `/api/v1/devices/*/calibration`)
- Metrics (`/api/v1/devices/*/metrics`)
- Display configuration (`/api/v1/config/displays`)
## Best Practices
### Development
```yaml
auth:
enabled: false # Disabled for local development
```
### Production
```yaml
auth:
enabled: true
api_keys:
- "${WLED_API_KEY_1}" # Use environment variables
- "${WLED_API_KEY_2}"
```
Set environment variables:
```bash
export WLED_API_KEY_1="$(openssl rand -hex 32)"
export WLED_API_KEY_2="$(openssl rand -hex 32)"
```
### Docker
```yaml
# docker-compose.yml
services:
wled-controller:
environment:
- WLED_AUTH__ENABLED=true
- WLED_AUTH__API_KEYS__0=your-key-here
```
Or use Docker secrets for better security.
## Generating Secure API Keys
### OpenSSL
```bash
openssl rand -hex 32
# Output: 64-character hex string
```
### Python
```python
import secrets
print(secrets.token_hex(32))
```
### Node.js
```javascript
require('crypto').randomBytes(32).toString('hex')
```
## Logging
When authentication is enabled, the server logs:
- Auth status on startup
- Invalid API key attempts (with truncated key for security)
- Number of configured keys
Example startup logs:
```
INFO: API authentication: ENABLED (2 keys configured)
WARNING: Authentication is enabled - API requests require valid API key
```
Invalid attempts:
```
WARNING: Invalid API key attempt: 1234abcd...
```
## Security Considerations
1. **HTTPS**: Use HTTPS in production to protect API keys in transit
2. **Key Rotation**: Periodically rotate API keys
3. **Monitoring**: Monitor logs for invalid key attempts
4. **Least Privilege**: Use separate keys for different clients
5. **Storage**: Never log or display full API keys
6. **Rate Limiting**: Consider adding rate limiting for production (not implemented yet)
## Troubleshooting
### Web UI shows "API Key Required" repeatedly
- Verify the key is correct
- Check browser console for errors
- Clear localStorage and re-enter key
### Home Assistant can't connect
- Ensure API key is configured in integration settings
- Check server logs for authentication errors
- Verify `auth.enabled = true` in server config
### "Server authentication not configured" error
- You enabled auth but didn't add any API keys
- Add at least one key to `auth.api_keys` array

72
server/pyproject.toml Normal file
View File

@@ -0,0 +1,72 @@
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "wled-screen-controller"
version = "0.1.0"
description = "WLED ambient lighting controller based on screen content"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
]
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
keywords = ["wled", "ambient-lighting", "screen-capture", "home-automation"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"httpx>=0.27.2",
"mss>=9.0.2",
"Pillow>=10.4.0",
"numpy>=2.1.3",
"pydantic>=2.9.2",
"pydantic-settings>=2.6.0",
"PyYAML>=6.0.2",
"structlog>=24.4.0",
"python-json-logger>=3.1.0",
"python-dateutil>=2.9.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.3",
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.0.0",
"respx>=0.21.1",
"black>=24.0.0",
"ruff>=0.6.0",
]
[project.urls]
Homepage = "https://github.com/yourusername/wled-screen-controller"
Repository = "https://github.com/yourusername/wled-screen-controller"
Issues = "https://github.com/yourusername/wled-screen-controller/issues"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "-v --cov=wled_controller --cov-report=html --cov-report=term"
[tool.black]
line-length = 100
target-version = ['py311']
[tool.ruff]
line-length = 100
target-version = "py311"

34
server/requirements.txt Normal file
View File

@@ -0,0 +1,34 @@
# Web Framework
fastapi==0.115.0
uvicorn[standard]==0.32.0
python-multipart==0.0.12
# HTTP Client
httpx==0.27.2
# Screen Capture
mss==9.0.2
Pillow==10.4.0
numpy==2.1.3
# Configuration
pydantic==2.9.2
pydantic-settings==2.6.0
PyYAML==6.0.2
# Logging
structlog==24.4.0
python-json-logger==3.1.0
# Utilities
python-dateutil==2.9.0
# Windows-specific (optional for friendly monitor names)
wmi==1.5.1; sys_platform == 'win32'
# Testing
pytest==8.3.3
pytest-asyncio==0.24.0
pytest-cov==6.0.0
httpx==0.27.2
respx==0.21.1

View File

@@ -0,0 +1,5 @@
"""WLED Screen Controller - Ambient lighting based on screen content."""
__version__ = "0.1.0"
__author__ = "Alexei Dolgolyov"
__email__ = "dolgolyov.alexei@gmail.com"

View File

@@ -0,0 +1,5 @@
"""API routes and schemas."""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,77 @@
"""Authentication module for API key validation."""
import secrets
from typing import Annotated
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from wled_controller.config import get_config
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Security scheme for Bearer token
security = HTTPBearer(auto_error=False)
def verify_api_key(
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)]
) -> str:
"""Verify API key from Authorization header.
Args:
credentials: HTTP authorization credentials
Returns:
Label/identifier of the authenticated client
Raises:
HTTPException: If authentication is required but invalid
"""
config = get_config()
# Check if credentials are provided
if not credentials:
logger.warning("Request missing Authorization header")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key - authentication is required",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract token
token = credentials.credentials
# Verify against configured API keys
if not config.auth.api_keys:
logger.error("No API keys configured - server misconfiguration")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Server authentication not configured properly",
)
# Find matching key and return its label using constant-time comparison
authenticated_as = None
for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated_as = label
break
if not authenticated_as:
logger.warning(f"Invalid API key attempt: {token[:8]}...")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
headers={"WWW-Authenticate": "Bearer"},
)
# Log successful authentication
logger.debug(f"Authenticated as: {authenticated_as}")
return authenticated_as
# Dependency for protected routes
# Returns the label/identifier of the authenticated client
AuthRequired = Annotated[str, Depends(verify_api_key)]

View File

@@ -0,0 +1,598 @@
"""API routes and endpoints."""
import sys
from datetime import datetime
from typing import List
from fastapi import APIRouter, HTTPException, Depends
from wled_controller import __version__
from wled_controller.api.auth import AuthRequired
from wled_controller.api.schemas import (
HealthResponse,
VersionResponse,
DisplayListResponse,
DisplayInfo,
DeviceCreate,
DeviceUpdate,
DeviceResponse,
DeviceListResponse,
ProcessingSettings as ProcessingSettingsSchema,
Calibration as CalibrationSchema,
ProcessingState,
MetricsResponse,
)
from wled_controller.config import get_config
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
from wled_controller.core.calibration import (
calibration_from_dict,
calibration_to_dict,
)
from wled_controller.storage import DeviceStore
from wled_controller.utils import get_logger, get_monitor_names
logger = get_logger(__name__)
router = APIRouter()
# Global instances (initialized in main.py)
_device_store: DeviceStore | None = None
_processor_manager: ProcessorManager | None = None
def get_device_store() -> DeviceStore:
"""Get device store dependency."""
if _device_store is None:
raise RuntimeError("Device store not initialized")
return _device_store
def get_processor_manager() -> ProcessorManager:
"""Get processor manager dependency."""
if _processor_manager is None:
raise RuntimeError("Processor manager not initialized")
return _processor_manager
def init_dependencies(device_store: DeviceStore, processor_manager: ProcessorManager):
"""Initialize global dependencies."""
global _device_store, _processor_manager
_device_store = device_store
_processor_manager = processor_manager
@router.get("/health", response_model=HealthResponse, tags=["Health"])
async def health_check():
"""Check service health status.
Returns basic health information including status, version, and timestamp.
"""
logger.info("Health check requested")
return HealthResponse(
status="healthy",
timestamp=datetime.utcnow(),
version=__version__,
)
@router.get("/api/v1/version", response_model=VersionResponse, tags=["Info"])
async def get_version():
"""Get version information.
Returns application version, Python version, and API version.
"""
logger.info("Version info requested")
return VersionResponse(
version=__version__,
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
api_version="v1",
)
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
async def get_displays(_: AuthRequired):
"""Get list of available displays.
Returns information about all available monitors/displays that can be captured.
"""
logger.info("Listing available displays")
try:
# Import here to avoid issues if mss is not installed yet
import mss
# Get friendly monitor names (Windows only, falls back to generic names)
monitor_names = get_monitor_names()
with mss.mss() as sct:
displays = []
# Skip the first monitor (it's the combined virtual screen on multi-monitor setups)
for idx, monitor in enumerate(sct.monitors[1:], start=0):
# Use friendly name from WMI if available, otherwise generic name
friendly_name = monitor_names.get(idx, f"Display {idx}")
display_info = DisplayInfo(
index=idx,
name=friendly_name,
width=monitor["width"],
height=monitor["height"],
x=monitor["left"],
y=monitor["top"],
is_primary=(idx == 0),
)
displays.append(display_info)
logger.info(f"Found {len(displays)} displays")
return DisplayListResponse(
displays=displays,
count=len(displays),
)
except Exception as e:
logger.error(f"Failed to get displays: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve display information: {str(e)}"
)
# ===== DEVICE MANAGEMENT ENDPOINTS =====
@router.post("/api/v1/devices", response_model=DeviceResponse, tags=["Devices"], status_code=201)
async def create_device(
device_data: DeviceCreate,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Create and attach a new WLED device."""
try:
logger.info(f"Creating device: {device_data.name}")
# Create device in storage
device = store.create_device(
name=device_data.name,
url=device_data.url,
led_count=device_data.led_count,
)
# Add to processor manager
manager.add_device(
device_id=device.id,
device_url=device.url,
led_count=device.led_count,
settings=device.settings,
calibration=device.calibration,
)
return DeviceResponse(
id=device.id,
name=device.name,
url=device.url,
led_count=device.led_count,
enabled=device.enabled,
status="disconnected",
settings=ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
brightness=device.settings.brightness,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at,
updated_at=device.updated_at,
)
except Exception as e:
logger.error(f"Failed to create device: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
async def list_devices(
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
):
"""List all attached WLED devices."""
try:
devices = store.get_all_devices()
device_responses = [
DeviceResponse(
id=device.id,
name=device.name,
url=device.url,
led_count=device.led_count,
enabled=device.enabled,
status="disconnected",
settings=ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at,
updated_at=device.updated_at,
)
for device in devices
]
return DeviceListResponse(devices=device_responses, count=len(device_responses))
except Exception as e:
logger.error(f"Failed to list devices: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
async def get_device(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get device details by ID."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
# Determine status
status = "connected" if manager.is_processing(device_id) else "disconnected"
return DeviceResponse(
id=device.id,
name=device.name,
url=device.url,
led_count=device.led_count,
enabled=device.enabled,
status=status,
settings=ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at,
updated_at=device.updated_at,
)
@router.put("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
async def update_device(
device_id: str,
update_data: DeviceUpdate,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
):
"""Update device information."""
try:
device = store.update_device(
device_id=device_id,
name=update_data.name,
url=update_data.url,
led_count=update_data.led_count,
enabled=update_data.enabled,
)
return DeviceResponse(
id=device.id,
name=device.name,
url=device.url,
led_count=device.led_count,
enabled=device.enabled,
status="disconnected",
settings=ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
brightness=device.settings.brightness,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at,
updated_at=device.updated_at,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update device: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"])
async def delete_device(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Delete/detach a device."""
try:
# Stop processing if running
if manager.is_processing(device_id):
await manager.stop_processing(device_id)
# Remove from manager
manager.remove_device(device_id)
# Delete from storage
store.delete_device(device_id)
logger.info(f"Deleted device {device_id}")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete device: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/devices/{device_id}/start", tags=["Processing"])
async def start_processing(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start screen processing for a device."""
try:
# Verify device exists
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
await manager.start_processing(device_id)
logger.info(f"Started processing for device {device_id}")
return {"status": "started", "device_id": device_id}
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to start processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/devices/{device_id}/stop", tags=["Processing"])
async def stop_processing(
device_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop screen processing for a device."""
try:
await manager.stop_processing(device_id)
logger.info(f"Stopped processing for device {device_id}")
return {"status": "stopped", "device_id": device_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/devices/{device_id}/state", response_model=ProcessingState, tags=["Processing"])
async def get_processing_state(
device_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get current processing state for a device."""
try:
state = manager.get_state(device_id)
return ProcessingState(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get state: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== SETTINGS ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
async def get_settings(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
):
"""Get processing settings for a device."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
return ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
brightness=device.settings.brightness,
)
@router.put("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
async def update_settings(
device_id: str,
settings: ProcessingSettingsSchema,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update processing settings for a device."""
try:
# Create ProcessingSettings from schema
new_settings = ProcessingSettings(
display_index=settings.display_index,
fps=settings.fps,
border_width=settings.border_width,
brightness=settings.color_correction.brightness if settings.color_correction else 1.0,
gamma=settings.color_correction.gamma if settings.color_correction else 2.2,
saturation=settings.color_correction.saturation if settings.color_correction else 1.0,
)
# Update in storage
device = store.update_device(device_id, settings=new_settings)
# Update in manager if device exists
try:
manager.update_settings(device_id, new_settings)
except ValueError:
# Device not in manager yet, that's ok
pass
return ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update settings: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== CALIBRATION ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
async def get_calibration(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
):
"""Get calibration configuration for a device."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
return CalibrationSchema(**calibration_to_dict(device.calibration))
@router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
async def update_calibration(
device_id: str,
calibration_data: CalibrationSchema,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update calibration configuration for a device."""
try:
# Convert schema to CalibrationConfig
calibration_dict = calibration_data.model_dump()
calibration = calibration_from_dict(calibration_dict)
# Update in storage
device = store.update_device(device_id, calibration=calibration)
# Update in manager if device exists
try:
manager.update_calibration(device_id, calibration)
except ValueError:
# Device not in manager yet, that's ok
pass
return CalibrationSchema(**calibration_to_dict(device.calibration))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update calibration: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/devices/{device_id}/calibration/test", tags=["Calibration"])
async def test_calibration(
device_id: str,
_auth: AuthRequired,
edge: str = "top",
color: List[int] = [255, 0, 0],
store: DeviceStore = Depends(get_device_store),
):
"""Test calibration by lighting up specific edge.
Useful for verifying LED positions match screen edges.
"""
try:
# Get device
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
# Find the segment for this edge
segment = None
for seg in device.calibration.segments:
if seg.edge == edge:
segment = seg
break
if not segment:
raise HTTPException(status_code=400, detail=f"No LEDs configured for {edge} edge")
# Create pixel array - all black except for the test edge
pixels = [(0, 0, 0)] * device.led_count
# Light up the test edge
r, g, b = color if len(color) == 3 else [255, 0, 0]
for i in range(segment.led_start, segment.led_start + segment.led_count):
if i < device.led_count:
pixels[i] = (r, g, b)
# Send to WLED
from wled_controller.core.wled_client import WLEDClient
import asyncio
async with WLEDClient(device.url) as wled:
# Light up the edge
await wled.send_pixels(pixels)
# Wait 2 seconds
await asyncio.sleep(2)
# Turn off
pixels_off = [(0, 0, 0)] * device.led_count
await wled.send_pixels(pixels_off)
logger.info(f"Calibration test completed for edge '{edge}' on device {device_id}")
return {
"status": "test_completed",
"device_id": device_id,
"edge": edge,
"led_count": segment.led_count,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to test calibration: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== METRICS ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/metrics", response_model=MetricsResponse, tags=["Metrics"])
async def get_metrics(
device_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get processing metrics for a device."""
try:
metrics = manager.get_metrics(device_id)
return MetricsResponse(**metrics)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,175 @@
"""Pydantic schemas for API request and response models."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field, HttpUrl
# Health and Version Schemas
class HealthResponse(BaseModel):
"""Health check response."""
status: Literal["healthy", "unhealthy"] = Field(description="Service health status")
timestamp: datetime = Field(description="Current server time")
version: str = Field(description="Application version")
class VersionResponse(BaseModel):
"""Version information response."""
version: str = Field(description="Application version")
python_version: str = Field(description="Python version")
api_version: str = Field(description="API version")
# Display Schemas
class DisplayInfo(BaseModel):
"""Display/monitor information."""
index: int = Field(description="Display index")
name: str = Field(description="Display name")
width: int = Field(description="Display width in pixels")
height: int = Field(description="Display height in pixels")
x: int = Field(description="Display X position")
y: int = Field(description="Display Y position")
is_primary: bool = Field(default=False, description="Whether this is the primary display")
class DisplayListResponse(BaseModel):
"""List of available displays."""
displays: List[DisplayInfo] = Field(description="Available displays")
count: int = Field(description="Number of displays")
# Device Schemas
class DeviceCreate(BaseModel):
"""Request to create/attach a WLED device."""
name: str = Field(description="Device name", min_length=1, max_length=100)
url: str = Field(description="WLED device URL (e.g., http://192.168.1.100)")
led_count: int = Field(description="Total number of LEDs", gt=0, le=10000)
class DeviceUpdate(BaseModel):
"""Request to update device information."""
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="WLED device URL")
led_count: Optional[int] = Field(None, description="Total number of LEDs", gt=0, le=10000)
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
class ColorCorrection(BaseModel):
"""Color correction settings."""
gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0)
saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0)
brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0)
class ProcessingSettings(BaseModel):
"""Processing settings for a device."""
display_index: int = Field(default=0, description="Display to capture", ge=0)
fps: int = Field(default=30, description="Target frames per second", ge=1, le=60)
border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100)
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
color_correction: Optional[ColorCorrection] = Field(
default_factory=ColorCorrection,
description="Color correction settings"
)
class CalibrationSegment(BaseModel):
"""Calibration segment for LED mapping."""
edge: Literal["top", "right", "bottom", "left"] = Field(description="Screen edge")
led_start: int = Field(description="Starting LED index", ge=0)
led_count: int = Field(description="Number of LEDs on this edge", gt=0)
reverse: bool = Field(default=False, description="Reverse LED order on this edge")
class Calibration(BaseModel):
"""Calibration configuration for pixel-to-LED mapping."""
layout: Literal["clockwise", "counterclockwise"] = Field(
default="clockwise",
description="LED strip layout direction"
)
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
default="bottom_left",
description="Position of LED index 0"
)
segments: List[CalibrationSegment] = Field(
description="LED segments for each screen edge",
min_length=1,
max_length=4
)
class DeviceResponse(BaseModel):
"""Device information response."""
id: str = Field(description="Device ID")
name: str = Field(description="Device name")
url: str = Field(description="WLED device URL")
led_count: int = Field(description="Total number of LEDs")
enabled: bool = Field(description="Whether device is enabled")
status: Literal["connected", "disconnected", "error"] = Field(
description="Connection status"
)
settings: ProcessingSettings = Field(description="Processing settings")
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class DeviceListResponse(BaseModel):
"""List of devices response."""
devices: List[DeviceResponse] = Field(description="List of devices")
count: int = Field(description="Number of devices")
# Processing State Schemas
class ProcessingState(BaseModel):
"""Processing state for a device."""
device_id: str = Field(description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_target: int = Field(description="Target FPS")
display_index: int = Field(description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
class MetricsResponse(BaseModel):
"""Device metrics response."""
device_id: str = Field(description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS")
fps_target: int = Field(description="Target FPS")
uptime_seconds: float = Field(description="Processing uptime in seconds")
frames_processed: int = Field(description="Total frames processed")
errors_count: int = Field(description="Total error count")
last_error: Optional[str] = Field(None, description="Last error message")
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
# Error Schemas
class ErrorResponse(BaseModel):
"""Error response."""
error: str = Field(description="Error type")
message: str = Field(description="Error message")
detail: Optional[Dict] = Field(None, description="Additional error details")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp")

View File

@@ -0,0 +1,154 @@
"""Configuration management for WLED Screen Controller."""
import os
from pathlib import Path
from typing import List, Literal
import yaml
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class ServerConfig(BaseSettings):
"""Server configuration."""
host: str = "0.0.0.0"
port: int = 8080
log_level: str = "INFO"
cors_origins: List[str] = ["*"]
class AuthConfig(BaseSettings):
"""Authentication configuration."""
api_keys: dict[str, str] = {} # label: key mapping (required for security)
class ProcessingConfig(BaseSettings):
"""Processing configuration."""
default_fps: int = 30
max_fps: int = 60
min_fps: int = 1
border_width: int = 10
interpolation_mode: Literal["average", "median", "dominant"] = "average"
class ScreenCaptureConfig(BaseSettings):
"""Screen capture configuration."""
buffer_size: int = 2
class WLEDConfig(BaseSettings):
"""WLED client configuration."""
timeout: int = 5
retry_attempts: int = 3
retry_delay: int = 1
protocol: Literal["http", "https"] = "http"
max_brightness: int = 255
class StorageConfig(BaseSettings):
"""Storage configuration."""
devices_file: str = "data/devices.json"
class LoggingConfig(BaseSettings):
"""Logging configuration."""
format: Literal["json", "text"] = "json"
file: str = "logs/wled_controller.log"
max_size_mb: int = 100
backup_count: int = 5
class Config(BaseSettings):
"""Main application configuration."""
model_config = SettingsConfigDict(
env_prefix="WLED_",
env_nested_delimiter="__",
case_sensitive=False,
)
server: ServerConfig = Field(default_factory=ServerConfig)
auth: AuthConfig = Field(default_factory=AuthConfig)
processing: ProcessingConfig = Field(default_factory=ProcessingConfig)
screen_capture: ScreenCaptureConfig = Field(default_factory=ScreenCaptureConfig)
wled: WLEDConfig = Field(default_factory=WLEDConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
@classmethod
def from_yaml(cls, config_path: str | Path) -> "Config":
"""Load configuration from YAML file.
Args:
config_path: Path to YAML configuration file
Returns:
Config instance
"""
config_path = Path(config_path)
if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with open(config_path, "r") as f:
config_data = yaml.safe_load(f)
return cls(**config_data)
@classmethod
def load(cls) -> "Config":
"""Load configuration from default locations.
Tries to load from:
1. Environment variable WLED_CONFIG_PATH
2. ./config/default_config.yaml
3. Default values
Returns:
Config instance
"""
config_path = os.getenv("WLED_CONFIG_PATH")
if config_path:
return cls.from_yaml(config_path)
# Try default location
default_path = Path("config/default_config.yaml")
if default_path.exists():
return cls.from_yaml(default_path)
# Use defaults
return cls()
# Global configuration instance
config: Config | None = None
def get_config() -> Config:
"""Get global configuration instance.
Returns:
Config instance
"""
global config
if config is None:
config = Config.load()
return config
def reload_config() -> Config:
"""Reload configuration from file.
Returns:
New Config instance
"""
global config
config = Config.load()
return config

View File

@@ -0,0 +1,17 @@
"""Core functionality for screen capture and WLED control."""
from .screen_capture import (
get_available_displays,
capture_display,
extract_border_pixels,
ScreenCapture,
BorderPixels,
)
__all__ = [
"get_available_displays",
"capture_display",
"extract_border_pixels",
"ScreenCapture",
"BorderPixels",
]

View File

@@ -0,0 +1,344 @@
"""Calibration system for mapping screen pixels to LED positions."""
from dataclasses import dataclass
from typing import List, Literal, Tuple
from wled_controller.core.screen_capture import (
BorderPixels,
get_edge_segments,
calculate_average_color,
calculate_median_color,
calculate_dominant_color,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@dataclass
class CalibrationSegment:
"""Configuration for one segment of the LED strip."""
edge: Literal["top", "right", "bottom", "left"]
led_start: int
led_count: int
reverse: bool = False
@dataclass
class CalibrationConfig:
"""Complete calibration configuration."""
layout: Literal["clockwise", "counterclockwise"]
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
segments: List[CalibrationSegment]
def validate(self) -> bool:
"""Validate calibration configuration.
Returns:
True if configuration is valid
Raises:
ValueError: If configuration is invalid
"""
if not self.segments:
raise ValueError("Calibration must have at least one segment")
# Check for duplicate edges
edges = [seg.edge for seg in self.segments]
if len(edges) != len(set(edges)):
raise ValueError("Duplicate edges in calibration segments")
# Validate LED indices don't overlap
led_ranges = []
for seg in self.segments:
led_range = range(seg.led_start, seg.led_start + seg.led_count)
led_ranges.append(led_range)
# Check for overlaps
for i, range1 in enumerate(led_ranges):
for j, range2 in enumerate(led_ranges):
if i != j:
overlap = set(range1) & set(range2)
if overlap:
raise ValueError(
f"LED indices overlap between segments {i} and {j}: {overlap}"
)
# Validate LED counts are positive
for seg in self.segments:
if seg.led_count <= 0:
raise ValueError(f"LED count must be positive, got {seg.led_count}")
if seg.led_start < 0:
raise ValueError(f"LED start must be non-negative, got {seg.led_start}")
return True
def get_total_leds(self) -> int:
"""Get total number of LEDs across all segments."""
return sum(seg.led_count for seg in self.segments)
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
"""Get segment configuration for a specific edge."""
for seg in self.segments:
if seg.edge == edge:
return seg
return None
class PixelMapper:
"""Maps screen border pixels to LED colors based on calibration."""
def __init__(
self,
calibration: CalibrationConfig,
interpolation_mode: Literal["average", "median", "dominant"] = "average",
):
"""Initialize pixel mapper.
Args:
calibration: Calibration configuration
interpolation_mode: Color calculation mode
"""
self.calibration = calibration
self.interpolation_mode = interpolation_mode
# Validate calibration
self.calibration.validate()
# Select color calculation function
if interpolation_mode == "average":
self._calc_color = calculate_average_color
elif interpolation_mode == "median":
self._calc_color = calculate_median_color
elif interpolation_mode == "dominant":
self._calc_color = calculate_dominant_color
else:
raise ValueError(f"Invalid interpolation mode: {interpolation_mode}")
logger.info(
f"Initialized pixel mapper with {self.calibration.get_total_leds()} LEDs "
f"using {interpolation_mode} interpolation"
)
def map_border_to_leds(
self,
border_pixels: BorderPixels
) -> List[Tuple[int, int, int]]:
"""Map screen border pixels to LED colors.
Args:
border_pixels: Extracted border pixels from screen
Returns:
List of (R, G, B) tuples for each LED
Raises:
ValueError: If border pixels don't match calibration
"""
total_leds = self.calibration.get_total_leds()
led_colors = [(0, 0, 0)] * total_leds
# Process each edge
for edge_name in ["top", "right", "bottom", "left"]:
segment = self.calibration.get_segment_for_edge(edge_name)
if not segment:
# This edge is not configured
continue
# Get pixels for this edge
if edge_name == "top":
edge_pixels = border_pixels.top
elif edge_name == "right":
edge_pixels = border_pixels.right
elif edge_name == "bottom":
edge_pixels = border_pixels.bottom
else: # left
edge_pixels = border_pixels.left
# Divide edge into segments matching LED count
try:
pixel_segments = get_edge_segments(
edge_pixels,
segment.led_count,
edge_name
)
except ValueError as e:
logger.error(f"Failed to segment {edge_name} edge: {e}")
raise
# Calculate LED indices for this segment
led_indices = list(range(segment.led_start, segment.led_start + segment.led_count))
# Reverse if needed
if segment.reverse:
led_indices = list(reversed(led_indices))
# Map pixel segments to LEDs
for led_idx, pixel_segment in zip(led_indices, pixel_segments):
color = self._calc_color(pixel_segment)
led_colors[led_idx] = color
logger.debug(f"Mapped border pixels to {total_leds} LED colors")
return led_colors
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
"""Generate test pattern to light up specific edge.
Useful for verifying calibration configuration.
Args:
edge: Edge to light up (top, right, bottom, left)
color: RGB color to use
Returns:
List of LED colors with only the specified edge lit
Raises:
ValueError: If edge is not in calibration
"""
segment = self.calibration.get_segment_for_edge(edge)
if not segment:
raise ValueError(f"Edge '{edge}' not found in calibration")
total_leds = self.calibration.get_total_leds()
led_colors = [(0, 0, 0)] * total_leds
# Light up the specified edge
led_indices = range(segment.led_start, segment.led_start + segment.led_count)
for led_idx in led_indices:
led_colors[led_idx] = color
logger.info(f"Generated test pattern for {edge} edge with color {color}")
return led_colors
def create_default_calibration(led_count: int) -> CalibrationConfig:
"""Create a default calibration for a rectangular screen.
Assumes LEDs are evenly distributed around the screen edges in clockwise order
starting from bottom-left.
Args:
led_count: Total number of LEDs
Returns:
Default calibration configuration
"""
if led_count < 4:
raise ValueError("Need at least 4 LEDs for default calibration")
# Distribute LEDs evenly across 4 edges
leds_per_edge = led_count // 4
remainder = led_count % 4
# Distribute remainder to longer edges (bottom and top)
bottom_count = leds_per_edge + (1 if remainder > 0 else 0)
right_count = leds_per_edge
top_count = leds_per_edge + (1 if remainder > 1 else 0)
left_count = leds_per_edge + (1 if remainder > 2 else 0)
segments = [
CalibrationSegment(
edge="bottom",
led_start=0,
led_count=bottom_count,
reverse=False,
),
CalibrationSegment(
edge="right",
led_start=bottom_count,
led_count=right_count,
reverse=False,
),
CalibrationSegment(
edge="top",
led_start=bottom_count + right_count,
led_count=top_count,
reverse=True,
),
CalibrationSegment(
edge="left",
led_start=bottom_count + right_count + top_count,
led_count=left_count,
reverse=True,
),
]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
segments=segments,
)
logger.info(
f"Created default calibration for {led_count} LEDs: "
f"bottom={bottom_count}, right={right_count}, "
f"top={top_count}, left={left_count}"
)
return config
def calibration_from_dict(data: dict) -> CalibrationConfig:
"""Create calibration configuration from dictionary.
Args:
data: Dictionary with calibration data
Returns:
CalibrationConfig instance
Raises:
ValueError: If data is invalid
"""
try:
segments = [
CalibrationSegment(
edge=seg["edge"],
led_start=seg["led_start"],
led_count=seg["led_count"],
reverse=seg.get("reverse", False),
)
for seg in data["segments"]
]
config = CalibrationConfig(
layout=data["layout"],
start_position=data["start_position"],
segments=segments,
)
config.validate()
return config
except KeyError as e:
raise ValueError(f"Missing required calibration field: {e}")
except Exception as e:
raise ValueError(f"Invalid calibration data: {e}")
def calibration_to_dict(config: CalibrationConfig) -> dict:
"""Convert calibration configuration to dictionary.
Args:
config: Calibration configuration
Returns:
Dictionary representation
"""
return {
"layout": config.layout,
"start_position": config.start_position,
"segments": [
{
"edge": seg.edge,
"led_start": seg.led_start,
"led_count": seg.led_count,
"reverse": seg.reverse,
}
for seg in config.segments
],
}

View File

@@ -0,0 +1,166 @@
"""Pixel processing utilities for color correction and manipulation."""
from typing import List, Tuple
import numpy as np
from wled_controller.utils import get_logger
logger = get_logger(__name__)
def apply_color_correction(
colors: List[Tuple[int, int, int]],
gamma: float = 2.2,
saturation: float = 1.0,
brightness: float = 1.0,
) -> List[Tuple[int, int, int]]:
"""Apply color correction to LED colors.
Args:
colors: List of (R, G, B) tuples
gamma: Gamma correction factor (default 2.2)
saturation: Saturation multiplier (0.0-2.0)
brightness: Brightness multiplier (0.0-1.0)
Returns:
Corrected list of (R, G, B) tuples
"""
if not colors:
return colors
# Convert to numpy array for efficient processing
colors_array = np.array(colors, dtype=np.float32) / 255.0
# Apply brightness
if brightness != 1.0:
colors_array *= brightness
# Apply saturation
if saturation != 1.0:
# Convert RGB to HSV-like saturation adjustment
# Calculate luminance (grayscale)
luminance = np.dot(colors_array, [0.299, 0.587, 0.114])
luminance = luminance[:, np.newaxis] # Reshape for broadcasting
# Blend between grayscale and color based on saturation
colors_array = luminance + (colors_array - luminance) * saturation
# Apply gamma correction
if gamma != 1.0:
colors_array = np.power(colors_array, 1.0 / gamma)
# Clamp to valid range and convert back to integers
colors_array = np.clip(colors_array * 255.0, 0, 255).astype(np.uint8)
# Convert back to list of tuples
corrected_colors = [tuple(color) for color in colors_array]
return corrected_colors
def smooth_colors(
current_colors: List[Tuple[int, int, int]],
previous_colors: List[Tuple[int, int, int]],
smoothing_factor: float = 0.5,
) -> List[Tuple[int, int, int]]:
"""Smooth color transitions between frames.
Args:
current_colors: Current frame colors
previous_colors: Previous frame colors
smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing)
Returns:
Smoothed colors
"""
if not current_colors or not previous_colors:
return current_colors
if len(current_colors) != len(previous_colors):
logger.warning(
f"Color count mismatch: current={len(current_colors)}, "
f"previous={len(previous_colors)}. Skipping smoothing."
)
return current_colors
if smoothing_factor <= 0:
return current_colors
if smoothing_factor >= 1:
return previous_colors
# Convert to numpy arrays
current = np.array(current_colors, dtype=np.float32)
previous = np.array(previous_colors, dtype=np.float32)
# Blend between current and previous
smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor
# Convert back to integers
smoothed = np.clip(smoothed, 0, 255).astype(np.uint8)
return [tuple(color) for color in smoothed]
def adjust_brightness_global(
colors: List[Tuple[int, int, int]],
target_brightness: int,
) -> List[Tuple[int, int, int]]:
"""Adjust colors to achieve target global brightness.
Args:
colors: List of (R, G, B) tuples
target_brightness: Target brightness (0-255)
Returns:
Adjusted colors
"""
if not colors or target_brightness == 255:
return colors
# Calculate scaling factor
scale = target_brightness / 255.0
# Scale all colors
scaled = [
(
int(r * scale),
int(g * scale),
int(b * scale),
)
for r, g, b in colors
]
return scaled
def limit_brightness(
colors: List[Tuple[int, int, int]],
max_brightness: int = 255,
) -> List[Tuple[int, int, int]]:
"""Limit maximum brightness of any color channel.
Args:
colors: List of (R, G, B) tuples
max_brightness: Maximum allowed brightness (0-255)
Returns:
Limited colors
"""
if not colors or max_brightness == 255:
return colors
limited = []
for r, g, b in colors:
# Find max channel value
max_val = max(r, g, b)
if max_val > max_brightness:
# Scale down proportionally
scale = max_brightness / max_val
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
limited.append((r, g, b))
return limited

View File

@@ -0,0 +1,452 @@
"""Processing manager for coordinating screen capture and WLED updates."""
import asyncio
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Optional
from wled_controller.core.calibration import (
CalibrationConfig,
PixelMapper,
create_default_calibration,
)
from wled_controller.core.pixel_processor import apply_color_correction, smooth_colors
from wled_controller.core.screen_capture import capture_display, extract_border_pixels
from wled_controller.core.wled_client import WLEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@dataclass
class ProcessingSettings:
"""Settings for screen processing."""
display_index: int = 0
fps: int = 30
border_width: int = 10
brightness: float = 1.0
gamma: float = 2.2
saturation: float = 1.0
smoothing: float = 0.3
interpolation_mode: str = "average"
@dataclass
class ProcessingMetrics:
"""Metrics for processing performance."""
frames_processed: int = 0
errors_count: int = 0
last_error: Optional[str] = None
last_update: Optional[datetime] = None
start_time: Optional[datetime] = None
fps_actual: float = 0.0
@dataclass
class ProcessorState:
"""State of a running processor."""
device_id: str
device_url: str
led_count: int
settings: ProcessingSettings
calibration: CalibrationConfig
wled_client: Optional[WLEDClient] = None
pixel_mapper: Optional[PixelMapper] = None
is_running: bool = False
task: Optional[asyncio.Task] = None
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
previous_colors: Optional[list] = None
class ProcessorManager:
"""Manages screen processing for multiple WLED devices."""
def __init__(self):
"""Initialize processor manager."""
self._processors: Dict[str, ProcessorState] = {}
logger.info("Processor manager initialized")
def add_device(
self,
device_id: str,
device_url: str,
led_count: int,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
):
"""Add a device for processing.
Args:
device_id: Unique device identifier
device_url: WLED device URL
led_count: Number of LEDs
settings: Processing settings (uses defaults if None)
calibration: Calibration config (creates default if None)
"""
if device_id in self._processors:
raise ValueError(f"Device {device_id} already exists")
if settings is None:
settings = ProcessingSettings()
if calibration is None:
calibration = create_default_calibration(led_count)
state = ProcessorState(
device_id=device_id,
device_url=device_url,
led_count=led_count,
settings=settings,
calibration=calibration,
)
self._processors[device_id] = state
logger.info(f"Added device {device_id} with {led_count} LEDs")
def remove_device(self, device_id: str):
"""Remove a device.
Args:
device_id: Device identifier
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
# Stop processing if running
if self._processors[device_id].is_running:
raise RuntimeError(f"Cannot remove device {device_id} while processing")
del self._processors[device_id]
logger.info(f"Removed device {device_id}")
def update_settings(self, device_id: str, settings: ProcessingSettings):
"""Update processing settings for a device.
Args:
device_id: Device identifier
settings: New settings
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
self._processors[device_id].settings = settings
# Recreate pixel mapper if interpolation mode changed
state = self._processors[device_id]
if state.pixel_mapper:
state.pixel_mapper = PixelMapper(
state.calibration,
interpolation_mode=settings.interpolation_mode,
)
logger.info(f"Updated settings for device {device_id}")
def update_calibration(self, device_id: str, calibration: CalibrationConfig):
"""Update calibration for a device.
Args:
device_id: Device identifier
calibration: New calibration config
Raises:
ValueError: If device not found or calibration invalid
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
# Validate calibration
calibration.validate()
# Check LED count matches
state = self._processors[device_id]
if calibration.get_total_leds() != state.led_count:
raise ValueError(
f"Calibration LED count ({calibration.get_total_leds()}) "
f"does not match device LED count ({state.led_count})"
)
state.calibration = calibration
# Recreate pixel mapper if running
if state.pixel_mapper:
state.pixel_mapper = PixelMapper(
calibration,
interpolation_mode=state.settings.interpolation_mode,
)
logger.info(f"Updated calibration for device {device_id}")
async def start_processing(self, device_id: str):
"""Start screen processing for a device.
Args:
device_id: Device identifier
Raises:
ValueError: If device not found
RuntimeError: If processing already running
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
state = self._processors[device_id]
if state.is_running:
raise RuntimeError(f"Processing already running for device {device_id}")
# Connect to WLED device
try:
state.wled_client = WLEDClient(state.device_url)
await state.wled_client.connect()
except Exception as e:
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
raise RuntimeError(f"Failed to connect to WLED device: {e}")
# Initialize pixel mapper
state.pixel_mapper = PixelMapper(
state.calibration,
interpolation_mode=state.settings.interpolation_mode,
)
# Reset metrics
state.metrics = ProcessingMetrics(start_time=datetime.utcnow())
state.previous_colors = None
# Start processing task
state.task = asyncio.create_task(self._processing_loop(device_id))
state.is_running = True
logger.info(f"Started processing for device {device_id}")
async def stop_processing(self, device_id: str):
"""Stop screen processing for a device.
Args:
device_id: Device identifier
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
state = self._processors[device_id]
if not state.is_running:
logger.warning(f"Processing not running for device {device_id}")
return
# Stop processing
state.is_running = False
# Cancel task
if state.task:
state.task.cancel()
try:
await state.task
except asyncio.CancelledError:
pass
state.task = None
# Close WLED connection
if state.wled_client:
await state.wled_client.close()
state.wled_client = None
logger.info(f"Stopped processing for device {device_id}")
async def _processing_loop(self, device_id: str):
"""Main processing loop for a device.
Args:
device_id: Device identifier
"""
state = self._processors[device_id]
settings = state.settings
logger.info(
f"Processing loop started for {device_id} "
f"(display={settings.display_index}, fps={settings.fps})"
)
frame_time = 1.0 / settings.fps
fps_samples = []
try:
while state.is_running:
loop_start = time.time()
try:
# Capture screen
capture = capture_display(settings.display_index)
# Extract border pixels
border_pixels = extract_border_pixels(capture, settings.border_width)
# Map to LED colors
led_colors = state.pixel_mapper.map_border_to_leds(border_pixels)
# Apply color correction
led_colors = apply_color_correction(
led_colors,
gamma=settings.gamma,
saturation=settings.saturation,
brightness=settings.brightness,
)
# Apply smoothing
if state.previous_colors and settings.smoothing > 0:
led_colors = smooth_colors(
led_colors,
state.previous_colors,
settings.smoothing,
)
# Send to WLED with brightness
brightness_value = int(settings.brightness * 255)
await state.wled_client.send_pixels(led_colors, brightness=brightness_value)
# Update metrics
state.metrics.frames_processed += 1
state.metrics.last_update = datetime.utcnow()
state.previous_colors = led_colors
# Calculate actual FPS
loop_time = time.time() - loop_start
fps_samples.append(1.0 / loop_time if loop_time > 0 else 0)
if len(fps_samples) > 10:
fps_samples.pop(0)
state.metrics.fps_actual = sum(fps_samples) / len(fps_samples)
except Exception as e:
state.metrics.errors_count += 1
state.metrics.last_error = str(e)
logger.error(f"Processing error for device {device_id}: {e}")
# FPS control
elapsed = time.time() - loop_start
sleep_time = max(0, frame_time - elapsed)
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except asyncio.CancelledError:
logger.info(f"Processing loop cancelled for device {device_id}")
raise
except Exception as e:
logger.error(f"Fatal error in processing loop for {device_id}: {e}")
state.is_running = False
raise
finally:
logger.info(f"Processing loop ended for device {device_id}")
def get_state(self, device_id: str) -> dict:
"""Get current processing state for a device.
Args:
device_id: Device identifier
Returns:
State dictionary
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
state = self._processors[device_id]
metrics = state.metrics
return {
"device_id": device_id,
"processing": state.is_running,
"fps_actual": metrics.fps_actual if state.is_running else None,
"fps_target": state.settings.fps,
"display_index": state.settings.display_index,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
}
def get_metrics(self, device_id: str) -> dict:
"""Get detailed metrics for a device.
Args:
device_id: Device identifier
Returns:
Metrics dictionary
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
state = self._processors[device_id]
metrics = state.metrics
# Calculate uptime
uptime_seconds = 0.0
if metrics.start_time and state.is_running:
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
return {
"device_id": device_id,
"processing": state.is_running,
"fps_actual": metrics.fps_actual if state.is_running else None,
"fps_target": state.settings.fps,
"uptime_seconds": uptime_seconds,
"frames_processed": metrics.frames_processed,
"errors_count": metrics.errors_count,
"last_error": metrics.last_error,
"last_update": metrics.last_update,
}
def is_processing(self, device_id: str) -> bool:
"""Check if device is currently processing.
Args:
device_id: Device identifier
Returns:
True if processing
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
return self._processors[device_id].is_running
def get_all_devices(self) -> list[str]:
"""Get list of all device IDs.
Returns:
List of device IDs
"""
return list(self._processors.keys())
async def stop_all(self):
"""Stop processing for all devices."""
device_ids = list(self._processors.keys())
for device_id in device_ids:
if self._processors[device_id].is_running:
try:
await self.stop_processing(device_id)
except Exception as e:
logger.error(f"Error stopping device {device_id}: {e}")
logger.info("Stopped all processors")

View File

@@ -0,0 +1,329 @@
"""Screen capture functionality using mss library."""
from dataclasses import dataclass
from typing import Dict, List
import mss
import numpy as np
from PIL import Image
from wled_controller.utils import get_logger, get_monitor_names
logger = get_logger(__name__)
@dataclass
class DisplayInfo:
"""Information about a display/monitor."""
index: int
name: str
width: int
height: int
x: int
y: int
is_primary: bool
@dataclass
class ScreenCapture:
"""Captured screen image data."""
image: np.ndarray
width: int
height: int
display_index: int
@dataclass
class BorderPixels:
"""Border pixels extracted from screen edges."""
top: np.ndarray
right: np.ndarray
bottom: np.ndarray
left: np.ndarray
def get_available_displays() -> List[DisplayInfo]:
"""Get list of available displays/monitors.
Returns:
List of DisplayInfo objects for each available monitor
Raises:
RuntimeError: If unable to detect displays
"""
try:
# Get friendly monitor names (Windows only, falls back to generic names)
monitor_names = get_monitor_names()
with mss.mss() as sct:
displays = []
# Skip the first monitor (combined virtual screen on multi-monitor setups)
for idx, monitor in enumerate(sct.monitors[1:], start=0):
# Use friendly name from WMI if available, otherwise generic name
friendly_name = monitor_names.get(idx, f"Display {idx}")
display_info = DisplayInfo(
index=idx,
name=friendly_name,
width=monitor["width"],
height=monitor["height"],
x=monitor["left"],
y=monitor["top"],
is_primary=(idx == 0),
)
displays.append(display_info)
logger.info(f"Detected {len(displays)} display(s)")
return displays
except Exception as e:
logger.error(f"Failed to detect displays: {e}")
raise RuntimeError(f"Failed to detect displays: {e}")
def capture_display(display_index: int = 0) -> ScreenCapture:
"""Capture the specified display.
Args:
display_index: Index of the display to capture (0-based)
Returns:
ScreenCapture object containing the captured image
Raises:
ValueError: If display_index is invalid
RuntimeError: If screen capture fails
"""
try:
with mss.mss() as sct:
# mss monitors[0] is the combined screen, monitors[1+] are individual displays
monitor_index = display_index + 1
if monitor_index >= len(sct.monitors):
raise ValueError(
f"Invalid display index {display_index}. "
f"Available displays: 0-{len(sct.monitors) - 2}"
)
monitor = sct.monitors[monitor_index]
# Capture screenshot
screenshot = sct.grab(monitor)
# Convert to numpy array (RGB)
img = Image.frombytes("RGB", screenshot.size, screenshot.rgb)
img_array = np.array(img)
logger.debug(
f"Captured display {display_index}: {monitor['width']}x{monitor['height']}"
)
return ScreenCapture(
image=img_array,
width=monitor["width"],
height=monitor["height"],
display_index=display_index,
)
except ValueError:
raise
except Exception as e:
logger.error(f"Failed to capture display {display_index}: {e}")
raise RuntimeError(f"Screen capture failed: {e}")
def extract_border_pixels(
screen_capture: ScreenCapture,
border_width: int = 10
) -> BorderPixels:
"""Extract border pixels from screen capture.
Args:
screen_capture: Captured screen image
border_width: Width of the border in pixels to extract
Returns:
BorderPixels object containing pixels from each edge
Raises:
ValueError: If border_width is invalid
"""
if border_width < 1:
raise ValueError("border_width must be at least 1")
if border_width > min(screen_capture.width, screen_capture.height) // 4:
raise ValueError(
f"border_width {border_width} is too large for screen size "
f"{screen_capture.width}x{screen_capture.height}"
)
img = screen_capture.image
height, width = img.shape[:2]
# Extract border regions
# Top edge: top border_width rows, full width
top = img[:border_width, :, :]
# Bottom edge: bottom border_width rows, full width
bottom = img[-border_width:, :, :]
# Right edge: right border_width columns, full height
right = img[:, -border_width:, :]
# Left edge: left border_width columns, full height
left = img[:, :border_width, :]
logger.debug(
f"Extracted borders: top={top.shape}, right={right.shape}, "
f"bottom={bottom.shape}, left={left.shape}"
)
return BorderPixels(
top=top,
right=right,
bottom=bottom,
left=left,
)
def get_edge_segments(
edge_pixels: np.ndarray,
segment_count: int,
edge_name: str
) -> List[np.ndarray]:
"""Divide edge pixels into segments.
Args:
edge_pixels: Pixel array for one edge
segment_count: Number of segments to divide into
edge_name: Name of the edge (for orientation)
Returns:
List of pixel arrays, one per segment
Raises:
ValueError: If segment_count is invalid
"""
if segment_count < 1:
raise ValueError("segment_count must be at least 1")
# Determine the dimension to divide
# For top/bottom edges: divide along width (axis 1)
# For left/right edges: divide along height (axis 0)
if edge_name in ["top", "bottom"]:
divide_axis = 1 # Width
edge_length = edge_pixels.shape[1]
else: # left, right
divide_axis = 0 # Height
edge_length = edge_pixels.shape[0]
if segment_count > edge_length:
raise ValueError(
f"segment_count {segment_count} is larger than edge length {edge_length}"
)
# Calculate segment size
segment_size = edge_length // segment_count
segments = []
for i in range(segment_count):
start = i * segment_size
end = start + segment_size if i < segment_count - 1 else edge_length
if divide_axis == 1:
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
segments.append(segment)
return segments
def calculate_average_color(pixels: np.ndarray) -> tuple[int, int, int]:
"""Calculate average color of a pixel region.
Args:
pixels: Pixel array (height, width, 3)
Returns:
Tuple of (R, G, B) average values
"""
if pixels.size == 0:
return (0, 0, 0)
# Calculate mean across height and width dimensions
mean_color = np.mean(pixels, axis=(0, 1))
# Convert to integers and clamp to valid range
r = int(np.clip(mean_color[0], 0, 255))
g = int(np.clip(mean_color[1], 0, 255))
b = int(np.clip(mean_color[2], 0, 255))
return (r, g, b)
def calculate_median_color(pixels: np.ndarray) -> tuple[int, int, int]:
"""Calculate median color of a pixel region.
Args:
pixels: Pixel array (height, width, 3)
Returns:
Tuple of (R, G, B) median values
"""
if pixels.size == 0:
return (0, 0, 0)
# Calculate median across height and width dimensions
median_color = np.median(pixels, axis=(0, 1))
# Convert to integers and clamp to valid range
r = int(np.clip(median_color[0], 0, 255))
g = int(np.clip(median_color[1], 0, 255))
b = int(np.clip(median_color[2], 0, 255))
return (r, g, b)
def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
"""Calculate dominant color of a pixel region using simple clustering.
Args:
pixels: Pixel array (height, width, 3)
Returns:
Tuple of (R, G, B) dominant color values
"""
if pixels.size == 0:
return (0, 0, 0)
# Reshape to (n_pixels, 3)
pixels_reshaped = pixels.reshape(-1, 3)
# For performance, sample pixels if there are too many
max_samples = 1000
if len(pixels_reshaped) > max_samples:
indices = np.random.choice(len(pixels_reshaped), max_samples, replace=False)
pixels_reshaped = pixels_reshaped[indices]
# Simple dominant color: quantize colors and find most common
# Reduce color space to 32 levels per channel for binning
quantized = (pixels_reshaped // 8) * 8
# Find unique colors and their counts
unique_colors, counts = np.unique(quantized, axis=0, return_counts=True)
# Get the most common color
dominant_idx = np.argmax(counts)
dominant_color = unique_colors[dominant_idx]
r = int(np.clip(dominant_color[0], 0, 255))
g = int(np.clip(dominant_color[1], 0, 255))
b = int(np.clip(dominant_color[2], 0, 255))
return (r, g, b)

View File

@@ -0,0 +1,368 @@
"""WLED HTTP client for controlling LED devices."""
import asyncio
from dataclasses import dataclass
from typing import List, Tuple, Optional, Dict, Any
import httpx
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@dataclass
class WLEDInfo:
"""WLED device information."""
name: str
version: str
led_count: int
brand: str
product: str
mac: str
ip: str
class WLEDClient:
"""HTTP client for WLED devices."""
def __init__(
self,
url: str,
timeout: int = 5,
retry_attempts: int = 3,
retry_delay: int = 1,
):
"""Initialize WLED client.
Args:
url: WLED device URL (e.g., http://192.168.1.100)
timeout: Request timeout in seconds
retry_attempts: Number of retry attempts on failure
retry_delay: Delay between retries in seconds
"""
self.url = url.rstrip("/")
self.timeout = timeout
self.retry_attempts = retry_attempts
self.retry_delay = retry_delay
self._client: Optional[httpx.AsyncClient] = None
self._connected = False
async def __aenter__(self):
"""Async context manager entry."""
await self.connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def connect(self) -> bool:
"""Establish connection to WLED device.
Returns:
True if connection successful
Raises:
RuntimeError: If connection fails
"""
try:
self._client = httpx.AsyncClient(timeout=self.timeout)
# Test connection by getting device info
info = await self.get_info()
self._connected = True
logger.info(
f"Connected to WLED device: {info.name} ({info.version}) "
f"with {info.led_count} LEDs"
)
return True
except Exception as e:
logger.error(f"Failed to connect to WLED device at {self.url}: {e}")
self._connected = False
if self._client:
await self._client.aclose()
self._client = None
raise RuntimeError(f"Failed to connect to WLED device: {e}")
async def close(self):
"""Close the connection to WLED device."""
if self._client:
await self._client.aclose()
self._client = None
self._connected = False
logger.debug(f"Closed connection to {self.url}")
@property
def is_connected(self) -> bool:
"""Check if connected to WLED device."""
return self._connected and self._client is not None
async def _request(
self,
method: str,
endpoint: str,
json_data: Optional[Dict[str, Any]] = None,
retry: bool = True,
) -> Dict[str, Any]:
"""Make HTTP request to WLED device with retry logic.
Args:
method: HTTP method (GET, POST, etc.)
endpoint: API endpoint
json_data: JSON data for request body
retry: Whether to retry on failure
Returns:
Response JSON data
Raises:
RuntimeError: If request fails after retries
"""
if not self._client:
raise RuntimeError("Client not connected. Call connect() first.")
url = f"{self.url}{endpoint}"
attempts = self.retry_attempts if retry else 1
for attempt in range(attempts):
try:
if method == "GET":
response = await self._client.get(url)
elif method == "POST":
response = await self._client.post(url, json=json_data)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error {e.response.status_code} on attempt {attempt + 1}: {e}")
if attempt < attempts - 1:
await asyncio.sleep(self.retry_delay)
else:
raise RuntimeError(f"HTTP request failed: {e}")
except httpx.RequestError as e:
logger.error(f"Request error on attempt {attempt + 1}: {e}")
if attempt < attempts - 1:
await asyncio.sleep(self.retry_delay)
else:
self._connected = False
raise RuntimeError(f"Request to WLED device failed: {e}")
except Exception as e:
logger.error(f"Unexpected error on attempt {attempt + 1}: {e}")
if attempt < attempts - 1:
await asyncio.sleep(self.retry_delay)
else:
raise RuntimeError(f"WLED request failed: {e}")
raise RuntimeError("Request failed after all retry attempts")
async def get_info(self) -> WLEDInfo:
"""Get WLED device information.
Returns:
WLEDInfo object with device details
Raises:
RuntimeError: If request fails
"""
try:
data = await self._request("GET", "/json/info")
return WLEDInfo(
name=data.get("name", "Unknown"),
version=data.get("ver", "Unknown"),
led_count=data.get("leds", {}).get("count", 0),
brand=data.get("brand", "WLED"),
product=data.get("product", "FOSS"),
mac=data.get("mac", ""),
ip=data.get("ip", ""),
)
except Exception as e:
logger.error(f"Failed to get device info: {e}")
raise
async def get_state(self) -> Dict[str, Any]:
"""Get current WLED device state.
Returns:
State dictionary
Raises:
RuntimeError: If request fails
"""
try:
return await self._request("GET", "/json/state")
except Exception as e:
logger.error(f"Failed to get device state: {e}")
raise
async def send_pixels(
self,
pixels: List[Tuple[int, int, int]],
brightness: int = 255,
segment_id: int = 0,
) -> bool:
"""Send pixel colors to WLED device.
Args:
pixels: List of (R, G, B) tuples for each LED
brightness: Global brightness (0-255)
segment_id: Segment ID to update
Returns:
True if successful
Raises:
ValueError: If pixel values are invalid
RuntimeError: If request fails
"""
# Validate inputs
if not pixels:
raise ValueError("Pixels list cannot be empty")
if not 0 <= brightness <= 255:
raise ValueError(f"Brightness must be 0-255, got {brightness}")
# Validate pixel values
for i, (r, g, b) in enumerate(pixels):
if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
raise ValueError(f"Invalid RGB values at index {i}: ({r}, {g}, {b})")
# Build WLED JSON state
payload = {
"on": True,
"bri": brightness,
"seg": [
{
"id": segment_id,
"i": pixels, # Individual LED colors
}
],
}
try:
await self._request("POST", "/json/state", json_data=payload)
logger.debug(f"Sent {len(pixels)} pixel colors to WLED device")
return True
except Exception as e:
logger.error(f"Failed to send pixels: {e}")
raise
async def set_power(self, on: bool) -> bool:
"""Turn WLED device on or off.
Args:
on: True to turn on, False to turn off
Returns:
True if successful
Raises:
RuntimeError: If request fails
"""
payload = {"on": on}
try:
await self._request("POST", "/json/state", json_data=payload)
logger.info(f"Set WLED power: {'ON' if on else 'OFF'}")
return True
except Exception as e:
logger.error(f"Failed to set power: {e}")
raise
async def set_brightness(self, brightness: int) -> bool:
"""Set global brightness.
Args:
brightness: Brightness value (0-255)
Returns:
True if successful
Raises:
ValueError: If brightness is out of range
RuntimeError: If request fails
"""
if not 0 <= brightness <= 255:
raise ValueError(f"Brightness must be 0-255, got {brightness}")
payload = {"bri": brightness}
try:
await self._request("POST", "/json/state", json_data=payload)
logger.debug(f"Set brightness to {brightness}")
return True
except Exception as e:
logger.error(f"Failed to set brightness: {e}")
raise
async def test_connection(self) -> bool:
"""Test connection to WLED device.
Returns:
True if device is reachable
Raises:
RuntimeError: If connection test fails
"""
try:
await self.get_info()
return True
except Exception as e:
logger.error(f"Connection test failed: {e}")
raise
async def send_test_pattern(self, led_count: int, duration: float = 2.0):
"""Send a test pattern to verify LED configuration.
Cycles through red, green, blue on all LEDs.
Args:
led_count: Number of LEDs
duration: Duration for each color in seconds
Raises:
RuntimeError: If test pattern fails
"""
logger.info(f"Sending test pattern to {led_count} LEDs")
try:
# Red
pixels = [(255, 0, 0)] * led_count
await self.send_pixels(pixels)
await asyncio.sleep(duration)
# Green
pixels = [(0, 255, 0)] * led_count
await self.send_pixels(pixels)
await asyncio.sleep(duration)
# Blue
pixels = [(0, 0, 255)] * led_count
await self.send_pixels(pixels)
await asyncio.sleep(duration)
# Off
pixels = [(0, 0, 0)] * led_count
await self.send_pixels(pixels)
logger.info("Test pattern complete")
except Exception as e:
logger.error(f"Test pattern failed: {e}")
raise

View File

@@ -0,0 +1,166 @@
"""FastAPI application entry point."""
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from wled_controller import __version__
from wled_controller.api import router
from wled_controller.api.routes import init_dependencies
from wled_controller.config import get_config
from wled_controller.core.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.utils import setup_logging, get_logger
# Initialize logging
setup_logging()
logger = get_logger(__name__)
# Get configuration
config = get_config()
# Initialize storage and processing
device_store = DeviceStore(config.storage.devices_file)
processor_manager = ProcessorManager()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager.
Handles startup and shutdown events.
"""
# Startup
logger.info(f"Starting WLED Screen Controller v{__version__}")
logger.info(f"Python version: {sys.version}")
logger.info(f"Server listening on {config.server.host}:{config.server.port}")
# Validate authentication configuration
if not config.auth.api_keys:
logger.error("=" * 70)
logger.error("CRITICAL: No API keys configured!")
logger.error("Authentication is REQUIRED for all API requests.")
logger.error("Please add API keys to your configuration:")
logger.error(" 1. Generate keys: openssl rand -hex 32")
logger.error(" 2. Add to config/default_config.yaml under auth.api_keys")
logger.error(" 3. Format: label: \"your-generated-key\"")
logger.error("=" * 70)
raise RuntimeError("No API keys configured - server cannot start without authentication")
# Log authentication status
logger.info(f"API Authentication: ENFORCED ({len(config.auth.api_keys)} clients configured)")
client_labels = ", ".join(config.auth.api_keys.keys())
logger.info(f"Authorized clients: {client_labels}")
logger.info("All API requests require valid Bearer token authentication")
# Initialize API dependencies
init_dependencies(device_store, processor_manager)
# Load existing devices into processor manager
devices = device_store.get_all_devices()
for device in devices:
try:
processor_manager.add_device(
device_id=device.id,
device_url=device.url,
led_count=device.led_count,
settings=device.settings,
calibration=device.calibration,
)
logger.info(f"Loaded device: {device.name} ({device.id})")
except Exception as e:
logger.error(f"Failed to load device {device.id}: {e}")
logger.info(f"Loaded {len(devices)} devices from storage")
yield
# Shutdown
logger.info("Shutting down WLED Screen Controller")
# Stop all processing
try:
await processor_manager.stop_all()
logger.info("Stopped all processors")
except Exception as e:
logger.error(f"Error stopping processors: {e}")
# Create FastAPI application
app = FastAPI(
title="WLED Screen Controller",
description="Control WLED devices based on screen content for ambient lighting",
version=__version__,
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=config.server.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API routes
app.include_router(router)
# Mount static files
static_path = Path(__file__).parent / "static"
if static_path.exists():
app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
logger.info(f"Mounted static files from {static_path}")
else:
logger.warning(f"Static files directory not found: {static_path}")
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
"""Global exception handler for unhandled errors."""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"error": "InternalServerError",
"message": "An unexpected error occurred",
"detail": str(exc) if config.server.log_level == "DEBUG" else None,
},
)
@app.get("/")
async def root():
"""Serve the web UI dashboard."""
static_path = Path(__file__).parent / "static" / "index.html"
if static_path.exists():
return FileResponse(static_path)
# Fallback to JSON if static files not found
return {
"name": "WLED Screen Controller",
"version": __version__,
"docs": "/docs",
"health": "/health",
"api": "/api/v1",
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"wled_controller.main:app",
host=config.server.host,
port=config.server.port,
log_level=config.server.log_level.lower(),
reload=True,
)

View File

@@ -0,0 +1,780 @@
const API_BASE = '/api/v1';
let refreshInterval = null;
let apiKey = null;
// Initialize app
document.addEventListener('DOMContentLoaded', () => {
// Load API key from localStorage
apiKey = localStorage.getItem('wled_api_key');
// Setup form handler
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
// Show modal if no API key is stored
if (!apiKey) {
// Wait for modal functions to be defined
setTimeout(() => {
if (typeof showApiKeyModal === 'function') {
showApiKeyModal('Welcome! Please login with your API key to get started.', true);
}
}, 100);
return; // Don't load data yet
}
// User is logged in, load data
loadServerInfo();
loadDisplays();
loadDevices();
// Start auto-refresh
startAutoRefresh();
});
// Helper function to add auth header if needed
function getHeaders() {
const headers = {
'Content-Type': 'application/json'
};
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
return headers;
}
// Handle 401 errors by showing login modal
function handle401Error() {
// Clear invalid API key
localStorage.removeItem('wled_api_key');
apiKey = null;
if (typeof updateAuthUI === 'function') {
updateAuthUI();
}
if (typeof showApiKeyModal === 'function') {
showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true);
} else {
showToast('Authentication failed. Please reload the page and login.', 'error');
}
}
// Configure API key
function configureApiKey() {
const currentKey = localStorage.getItem('wled_api_key');
const message = currentKey
? 'Current API key is set. Enter new key to update or leave blank to remove:'
: 'Enter your API key:';
const key = prompt(message);
if (key === null) {
return; // Cancelled
}
if (key === '') {
localStorage.removeItem('wled_api_key');
apiKey = null;
document.getElementById('api-key-btn').style.display = 'none';
showToast('API key removed', 'info');
} else {
localStorage.setItem('wled_api_key', key);
apiKey = key;
document.getElementById('api-key-btn').style.display = 'inline-block';
showToast('API key updated', 'success');
}
// Reload data with new key
loadServerInfo();
loadDisplays();
loadDevices();
}
// Server info
async function loadServerInfo() {
try {
const response = await fetch('/health');
const data = await response.json();
document.getElementById('server-version').textContent = `Version: ${data.version}`;
document.getElementById('server-status').textContent = '●';
document.getElementById('server-status').className = 'status-badge online';
} catch (error) {
console.error('Failed to load server info:', error);
document.getElementById('server-status').className = 'status-badge offline';
showToast('Server offline', 'error');
}
}
// Load displays
async function loadDisplays() {
try {
const response = await fetch(`${API_BASE}/config/displays`, {
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
const data = await response.json();
const container = document.getElementById('displays-list');
if (!data.displays || data.displays.length === 0) {
container.innerHTML = '<div class="loading">No displays available</div>';
return;
}
container.innerHTML = data.displays.map(display => `
<div class="display-card">
<div class="display-index">${display.name}</div>
<div class="info-row">
<span class="info-label">Resolution:</span>
<span class="info-value">${display.width} × ${display.height}</span>
</div>
<div class="info-row">
<span class="info-label">Position:</span>
<span class="info-value">${display.x}, ${display.y}</span>
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load displays:', error);
document.getElementById('displays-list').innerHTML =
'<div class="loading">Failed to load displays</div>';
}
}
// Load devices
async function loadDevices() {
try {
const response = await fetch(`${API_BASE}/devices`, {
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
const data = await response.json();
const devices = data.devices || [];
const container = document.getElementById('devices-list');
if (!devices || devices.length === 0) {
container.innerHTML = '<div class="loading">No devices attached</div>';
return;
}
// Fetch state for each device
const devicesWithState = await Promise.all(
devices.map(async (device) => {
try {
const stateResponse = await fetch(`${API_BASE}/devices/${device.id}/state`, {
headers: getHeaders()
});
const state = await stateResponse.json();
const metricsResponse = await fetch(`${API_BASE}/devices/${device.id}/metrics`, {
headers: getHeaders()
});
const metrics = await metricsResponse.json();
return { ...device, state, metrics };
} catch (error) {
console.error(`Failed to load state for device ${device.id}:`, error);
return device;
}
})
);
container.innerHTML = devicesWithState.map(device => createDeviceCard(device)).join('');
// Attach event listeners
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
});
} catch (error) {
console.error('Failed to load devices:', error);
document.getElementById('devices-list').innerHTML =
'<div class="loading">Failed to load devices</div>';
}
}
function createDeviceCard(device) {
const state = device.state || {};
const metrics = device.metrics || {};
const settings = device.settings || {};
const isProcessing = state.processing || false;
const status = isProcessing ? 'processing' : 'idle';
return `
<div class="card" data-device-id="${device.id}">
<div class="card-header">
<div class="card-title">${device.name || device.id}</div>
<span class="badge ${status}">${status.toUpperCase()}</span>
</div>
<div class="card-content">
<div class="info-row">
<span class="info-label">URL:</span>
<span class="info-value">${device.url || 'N/A'}</span>
</div>
<div class="info-row">
<span class="info-label">LED Count:</span>
<span class="info-value">${device.led_count || 0}</span>
</div>
<div class="info-row">
<span class="info-label">Display:</span>
<span class="info-value">Display ${settings.display_index !== undefined ? settings.display_index : 0}</span>
</div>
${isProcessing ? `
<div class="metrics-grid">
<div class="metric">
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
<div class="metric-label">Actual FPS</div>
</div>
<div class="metric">
<div class="metric-value">${state.fps_target || 0}</div>
<div class="metric-label">Target FPS</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.frames_processed || 0}</div>
<div class="metric-label">Frames</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.errors_count || 0}</div>
<div class="metric-label">Errors</div>
</div>
</div>
` : ''}
</div>
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-danger" onclick="stopProcessing('${device.id}')">
Stop Processing
</button>
` : `
<button class="btn btn-primary" onclick="startProcessing('${device.id}')">
Start Processing
</button>
`}
<button class="btn btn-secondary" onclick="showSettings('${device.id}')">
Settings
</button>
<button class="btn btn-secondary" onclick="showCalibration('${device.id}')">
Calibrate
</button>
<button class="btn btn-danger" onclick="removeDevice('${device.id}')">
Remove
</button>
</div>
</div>
`;
}
function attachDeviceListeners(deviceId) {
// Add any specific event listeners here if needed
}
// Device actions
async function startProcessing(deviceId) {
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/start`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
showToast('Processing started', 'success');
loadDevices();
} else {
const error = await response.json();
showToast(`Failed to start: ${error.detail}`, 'error');
}
} catch (error) {
console.error('Failed to start processing:', error);
showToast('Failed to start processing', 'error');
}
}
async function stopProcessing(deviceId) {
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/stop`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
showToast('Processing stopped', 'success');
loadDevices();
} else {
const error = await response.json();
showToast(`Failed to stop: ${error.detail}`, 'error');
}
} catch (error) {
console.error('Failed to stop processing:', error);
showToast('Failed to stop processing', 'error');
}
}
async function removeDevice(deviceId) {
if (!confirm('Are you sure you want to remove this device?')) {
return;
}
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'DELETE',
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
showToast('Device removed', 'success');
loadDevices();
} else {
const error = await response.json();
showToast(`Failed to remove: ${error.detail}`, 'error');
}
} catch (error) {
console.error('Failed to remove device:', error);
showToast('Failed to remove device', 'error');
}
}
async function showSettings(deviceId) {
try {
// Fetch current device data
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
if (!response.ok) {
showToast('Failed to load device settings', 'error');
return;
}
const device = await response.json();
// Populate modal
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url;
document.getElementById('settings-device-led-count').value = device.led_count;
// Set brightness (convert from 0.0-1.0 to 0-100)
const brightnessPercent = Math.round((device.settings.brightness || 1.0) * 100);
document.getElementById('settings-device-brightness').value = brightnessPercent;
document.getElementById('brightness-value').textContent = brightnessPercent + '%';
// Show modal
const modal = document.getElementById('device-settings-modal');
modal.style.display = 'flex';
// Focus first input
setTimeout(() => {
document.getElementById('settings-device-name').focus();
}, 100);
} catch (error) {
console.error('Failed to load device settings:', error);
showToast('Failed to load device settings', 'error');
}
}
function closeDeviceSettingsModal() {
const modal = document.getElementById('device-settings-modal');
const error = document.getElementById('settings-error');
modal.style.display = 'none';
error.style.display = 'none';
}
async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').value.trim();
const led_count = parseInt(document.getElementById('settings-device-led-count').value);
const brightnessPercent = parseInt(document.getElementById('settings-device-brightness').value);
const brightness = brightnessPercent / 100.0; // Convert to 0.0-1.0
const error = document.getElementById('settings-error');
// Validation
if (!name || !url || !led_count || led_count < 1) {
error.textContent = 'Please fill in all fields correctly';
error.style.display = 'block';
return;
}
try {
// Update device info (name, url, led_count)
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url, led_count })
});
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
const errorData = await deviceResponse.json();
error.textContent = `Failed to update device: ${errorData.detail}`;
error.style.display = 'block';
return;
}
// Update settings (brightness)
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ brightness })
});
if (settingsResponse.status === 401) {
handle401Error();
return;
}
if (settingsResponse.ok) {
showToast('Device settings updated', 'success');
closeDeviceSettingsModal();
loadDevices();
} else {
const errorData = await settingsResponse.json();
error.textContent = `Failed to update settings: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to save device settings:', err);
error.textContent = 'Failed to save settings';
error.style.display = 'block';
}
}
// Add device form handler
async function handleAddDevice(event) {
event.preventDefault();
const name = document.getElementById('device-name').value;
const url = document.getElementById('device-url').value;
const led_count = parseInt(document.getElementById('device-led-count').value);
try {
const response = await fetch(`${API_BASE}/devices`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ name, url, led_count })
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
showToast('Device added successfully', 'success');
event.target.reset();
loadDevices();
} else {
const error = await response.json();
showToast(`Failed to add device: ${error.detail}`, 'error');
}
} catch (error) {
console.error('Failed to add device:', error);
showToast('Failed to add device', 'error');
}
}
// Auto-refresh
function startAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
}
refreshInterval = setInterval(() => {
loadDevices();
}, 2000); // Refresh every 2 seconds
}
// Toast notifications
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => {
toast.className = 'toast';
}, 3000);
}
// Calibration functions
async function showCalibration(deviceId) {
try {
// Fetch current device data
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
if (!response.ok) {
showToast('Failed to load calibration', 'error');
return;
}
const device = await response.json();
const calibration = device.calibration;
// Store device ID and LED count
document.getElementById('calibration-device-id').value = device.id;
document.getElementById('cal-device-led-count').textContent = device.led_count;
// Set layout
document.getElementById('cal-start-position').value = calibration.start_position;
document.getElementById('cal-layout').value = calibration.layout;
// Set LED counts per edge
const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 };
calibration.segments.forEach(seg => {
edgeCounts[seg.edge] = seg.led_count;
});
document.getElementById('cal-top-leds').value = edgeCounts.top;
document.getElementById('cal-right-leds').value = edgeCounts.right;
document.getElementById('cal-bottom-leds').value = edgeCounts.bottom;
document.getElementById('cal-left-leds').value = edgeCounts.left;
// Update preview
updateCalibrationPreview();
// Show modal
const modal = document.getElementById('calibration-modal');
modal.style.display = 'flex';
} catch (error) {
console.error('Failed to load calibration:', error);
showToast('Failed to load calibration', 'error');
}
}
function closeCalibrationModal() {
const modal = document.getElementById('calibration-modal');
const error = document.getElementById('calibration-error');
modal.style.display = 'none';
error.style.display = 'none';
}
function updateCalibrationPreview() {
// Update edge counts in preview
document.getElementById('preview-top-count').textContent = document.getElementById('cal-top-leds').value;
document.getElementById('preview-right-count').textContent = document.getElementById('cal-right-leds').value;
document.getElementById('preview-bottom-count').textContent = document.getElementById('cal-bottom-leds').value;
document.getElementById('preview-left-count').textContent = document.getElementById('cal-left-leds').value;
// Calculate total
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
parseInt(document.getElementById('cal-right-leds').value || 0) +
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
parseInt(document.getElementById('cal-left-leds').value || 0);
document.getElementById('cal-total-leds').textContent = total;
// Update starting position indicator
const startPos = document.getElementById('cal-start-position').value;
const indicator = document.getElementById('start-indicator');
const positions = {
'bottom_left': { bottom: '10px', left: '10px', top: 'auto', right: 'auto' },
'bottom_right': { bottom: '10px', right: '10px', top: 'auto', left: 'auto' },
'top_left': { top: '10px', left: '10px', bottom: 'auto', right: 'auto' },
'top_right': { top: '10px', right: '10px', bottom: 'auto', left: 'auto' }
};
const pos = positions[startPos];
indicator.style.top = pos.top;
indicator.style.right = pos.right;
indicator.style.bottom = pos.bottom;
indicator.style.left = pos.left;
}
async function testCalibrationEdge(edge) {
const deviceId = document.getElementById('calibration-device-id').value;
const error = document.getElementById('calibration-error');
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ edge, color: [255, 0, 0] }) // Red color
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
showToast(`Testing ${edge} edge (2 seconds)`, 'info');
} else {
const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to test edge:', err);
error.textContent = 'Failed to test edge';
error.style.display = 'block';
}
}
async function saveCalibration() {
const deviceId = document.getElementById('calibration-device-id').value;
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count').textContent);
const error = document.getElementById('calibration-error');
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
const total = topLeds + rightLeds + bottomLeds + leftLeds;
// Validation
if (total !== deviceLedCount) {
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
error.style.display = 'block';
return;
}
// Build calibration config
const startPosition = document.getElementById('cal-start-position').value;
const layout = document.getElementById('cal-layout').value;
// Build segments based on start position and direction
const segments = [];
let ledStart = 0;
const edgeOrder = getEdgeOrder(startPosition, layout);
const edgeCounts = {
top: topLeds,
right: rightLeds,
bottom: bottomLeds,
left: leftLeds
};
edgeOrder.forEach(edge => {
const count = edgeCounts[edge];
if (count > 0) {
segments.push({
edge: edge,
led_start: ledStart,
led_count: count,
reverse: shouldReverse(edge, startPosition, layout)
});
ledStart += count;
}
});
const calibration = {
layout: layout,
start_position: startPosition,
segments: segments
};
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(calibration)
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
showToast('Calibration saved', 'success');
closeCalibrationModal();
loadDevices();
} else {
const errorData = await response.json();
error.textContent = `Failed to save: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to save calibration:', err);
error.textContent = 'Failed to save calibration';
error.style.display = 'block';
}
}
function getEdgeOrder(startPosition, layout) {
const clockwise = ['bottom', 'right', 'top', 'left'];
const counterclockwise = ['bottom', 'left', 'top', 'right'];
const orders = {
'bottom_left_clockwise': clockwise,
'bottom_left_counterclockwise': counterclockwise,
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
'bottom_right_counterclockwise': ['bottom', 'right', 'top', 'left'],
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
'top_left_counterclockwise': ['top', 'left', 'bottom', 'right'],
'top_right_clockwise': ['top', 'left', 'bottom', 'right'],
'top_right_counterclockwise': ['top', 'right', 'bottom', 'left']
};
return orders[`${startPosition}_${layout}`] || clockwise;
}
function shouldReverse(edge, startPosition, layout) {
// Determine if this edge should be reversed based on LED strip direction
const reverseRules = {
'bottom_left_clockwise': { bottom: false, right: false, top: true, left: true },
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
'bottom_right_clockwise': { bottom: true, right: false, top: false, left: true },
'bottom_right_counterclockwise': { bottom: true, right: true, top: false, left: false },
'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
'top_left_counterclockwise': { top: false, right: true, bottom: true, left: false },
'top_right_clockwise': { top: true, right: false, bottom: false, left: true },
'top_right_counterclockwise': { top: true, right: true, bottom: false, left: false }
};
const rules = reverseRules[`${startPosition}_${layout}`];
return rules ? rules[edge] : false;
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});

View File

@@ -0,0 +1,416 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WLED Screen Controller</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<header>
<h1>WLED Screen Controller</h1>
<div class="server-info">
<span id="server-version">Version: Loading...</span>
<span id="server-status" class="status-badge"></span>
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
<span id="theme-icon">🌙</span>
</button>
<span id="auth-status" style="margin-left: 10px; display: none;">
<span id="logged-in-user" style="color: #4CAF50; margin-right: 8px;"></span>
</span>
<button id="login-btn" class="btn btn-primary" onclick="showLogin()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;">
🔑 Login
</button>
<button id="logout-btn" class="btn btn-danger" onclick="logout()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;">
🚪 Logout
</button>
</div>
</header>
<section class="displays-section">
<h2>Available Displays</h2>
<div id="displays-list" class="displays-grid">
<div class="loading">Loading displays...</div>
</div>
</section>
<section class="devices-section">
<h2>WLED Devices</h2>
<div id="devices-list" class="devices-grid">
<div class="loading">Loading devices...</div>
</div>
</section>
<section class="add-device-section">
<h2>Add New Device</h2>
<div class="info-banner" style="margin-bottom: 20px; padding: 12px; background: rgba(33, 150, 243, 0.1); border-left: 4px solid #2196F3; border-radius: 4px;">
<strong>📱 WLED Configuration:</strong> Configure your WLED device (effects, segments, color order, power limits, etc.) using the
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" style="color: #2196F3; text-decoration: underline;">official WLED app</a>.
This controller sends pixel color data and controls brightness per device.
</div>
<form id="add-device-form">
<div class="form-group">
<label for="device-name">Device Name:</label>
<input type="text" id="device-name" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<label for="device-url">WLED URL:</label>
<input type="url" id="device-url" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group">
<label for="device-led-count">LED Count:</label>
<input type="number" id="device-led-count" value="150" min="1" required>
<small class="input-hint">Number of LEDs configured in your WLED device</small>
</div>
<button type="submit" class="btn btn-primary">Add Device</button>
</form>
</section>
</div>
<div id="toast" class="toast"></div>
<!-- Calibration Modal -->
<div id="calibration-modal" class="modal">
<div class="modal-content" style="max-width: 700px;">
<div class="modal-header">
<h2>📐 LED Calibration</h2>
</div>
<div class="modal-body">
<input type="hidden" id="calibration-device-id">
<p style="margin-bottom: 20px; color: var(--text-secondary);">
Configure how your LED strip is mapped to screen edges. Use test buttons to verify each edge lights up correctly.
</p>
<!-- Visual Preview -->
<div style="margin-bottom: 25px;">
<div style="position: relative; width: 400px; height: 250px; margin: 0 auto; background: var(--card-bg); border: 2px solid var(--border-color); border-radius: 8px;">
<!-- Screen representation -->
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 300px; height: 180px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px;">
Screen
</div>
<!-- Edge labels -->
<div style="position: absolute; top: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);">
Top: <span id="preview-top-count">0</span> LEDs
</div>
<div style="position: absolute; right: 5px; top: 50%; transform: translateY(-50%) rotate(90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;">
Right: <span id="preview-right-count">0</span> LEDs
</div>
<div style="position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);">
Bottom: <span id="preview-bottom-count">0</span> LEDs
</div>
<div style="position: absolute; left: 5px; top: 50%; transform: translateY(-50%) rotate(-90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;">
Left: <span id="preview-left-count">0</span> LEDs
</div>
<!-- Starting position indicator -->
<div id="start-indicator" style="position: absolute; bottom: 10px; left: 10px; width: 12px; height: 12px; background: #4CAF50; border-radius: 50%; border: 2px solid white;"></div>
</div>
</div>
<!-- Layout Configuration -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
<div class="form-group">
<label for="cal-start-position">Starting Position:</label>
<select id="cal-start-position" onchange="updateCalibrationPreview()">
<option value="bottom_left">Bottom Left</option>
<option value="bottom_right">Bottom Right</option>
<option value="top_left">Top Left</option>
<option value="top_right">Top Right</option>
</select>
</div>
<div class="form-group">
<label for="cal-layout">Direction:</label>
<select id="cal-layout" onchange="updateCalibrationPreview()">
<option value="clockwise">Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</div>
</div>
<!-- LED Counts per Edge -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
<div class="form-group">
<label for="cal-top-leds">Top LEDs:</label>
<input type="number" id="cal-top-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
<div class="form-group">
<label for="cal-right-leds">Right LEDs:</label>
<input type="number" id="cal-right-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
<div class="form-group">
<label for="cal-bottom-leds">Bottom LEDs:</label>
<input type="number" id="cal-bottom-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
<div class="form-group">
<label for="cal-left-leds">Left LEDs:</label>
<input type="number" id="cal-left-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
</div>
<div style="padding: 10px; background: rgba(255, 193, 7, 0.1); border-left: 4px solid #FFC107; border-radius: 4px; margin-bottom: 20px;">
<strong>Total LEDs:</strong> <span id="cal-total-leds">0</span> / <span id="cal-device-led-count">0</span>
</div>
<!-- Test Buttons -->
<div style="margin-bottom: 15px;">
<p style="font-weight: 600; margin-bottom: 10px;">Test Edges (lights up each edge):</p>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;">
<button class="btn btn-secondary" onclick="testCalibrationEdge('top')" style="font-size: 0.9rem; padding: 8px;">
⬆️ Top
</button>
<button class="btn btn-secondary" onclick="testCalibrationEdge('right')" style="font-size: 0.9rem; padding: 8px;">
➡️ Right
</button>
<button class="btn btn-secondary" onclick="testCalibrationEdge('bottom')" style="font-size: 0.9rem; padding: 8px;">
⬇️ Bottom
</button>
<button class="btn btn-secondary" onclick="testCalibrationEdge('left')" style="font-size: 0.9rem; padding: 8px;">
⬅️ Left
</button>
</div>
</div>
<div id="calibration-error" class="error-message" style="display: none;"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeCalibrationModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveCalibration()">Save Calibration</button>
</div>
</div>
</div>
<!-- Device Settings Modal -->
<div id="device-settings-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>⚙️ Device Settings</h2>
</div>
<div class="modal-body">
<form id="device-settings-form">
<input type="hidden" id="settings-device-id">
<div class="form-group">
<label for="settings-device-name">Device Name:</label>
<input type="text" id="settings-device-name" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<label for="settings-device-url">WLED URL:</label>
<input type="url" id="settings-device-url" placeholder="http://192.168.1.100" required>
<small class="input-hint">IP address or hostname of your WLED device</small>
</div>
<div class="form-group">
<label for="settings-device-led-count">LED Count:</label>
<input type="number" id="settings-device-led-count" min="1" required>
<small class="input-hint">Number of LEDs configured in your WLED device</small>
</div>
<div class="form-group">
<label for="settings-device-brightness">Brightness: <span id="brightness-value">100%</span></label>
<input type="range" id="settings-device-brightness" min="0" max="100" value="100"
oninput="document.getElementById('brightness-value').textContent = this.value + '%'"
style="width: 100%;">
<small class="input-hint">Global brightness for this WLED device (0-100%)</small>
</div>
<div id="settings-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeDeviceSettingsModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveDeviceSettings()">Save Changes</button>
</div>
</div>
</div>
<!-- Login Modal -->
<div id="api-key-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>🔑 Login to WLED Controller</h2>
</div>
<div class="modal-body">
<p class="modal-description">
Please enter your API key to authenticate and access the WLED Screen Controller.
</p>
<div class="form-group">
<label for="api-key-input">API Key:</label>
<div class="password-input-wrapper">
<input
type="password"
id="api-key-input"
placeholder="Enter your API key..."
autocomplete="off"
>
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()">
👁️
</button>
</div>
<small class="input-hint">Your API key will be stored securely in your browser's local storage.</small>
</div>
<div id="api-key-error" class="error-message" style="display: none;"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn">Cancel</button>
<button class="btn btn-primary" onclick="submitApiKey()">Login</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
<script>
// Initialize theme
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
function updateThemeIcon(theme) {
const icon = document.getElementById('theme-icon');
icon.textContent = theme === 'dark' ? '☀️' : '🌙';
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
showToast(`Switched to ${newTheme} theme`, 'info');
}
// Initialize auth state
function updateAuthUI() {
const apiKey = localStorage.getItem('wled_api_key');
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
const authStatus = document.getElementById('auth-status');
const loggedInUser = document.getElementById('logged-in-user');
if (apiKey) {
// Logged in
loginBtn.style.display = 'none';
logoutBtn.style.display = 'inline-block';
authStatus.style.display = 'inline';
// Show masked key
const masked = apiKey.substring(0, 8) + '...';
loggedInUser.textContent = `● Authenticated`;
loggedInUser.title = `API Key: ${masked}`;
} else {
// Logged out
loginBtn.style.display = 'inline-block';
logoutBtn.style.display = 'none';
authStatus.style.display = 'none';
}
}
function showLogin() {
showApiKeyModal('Enter your API key to login and access the controller.');
document.getElementById('modal-cancel-btn').style.display = 'inline-block';
}
function logout() {
if (confirm('Are you sure you want to logout?')) {
localStorage.removeItem('wled_api_key');
apiKey = null;
updateAuthUI();
showToast('Logged out successfully', 'info');
// Clear the UI
document.getElementById('devices-list').innerHTML = '<div class="loading">Please login to view devices</div>';
document.getElementById('displays-list').innerHTML = '<div class="loading">Please login to view displays</div>';
}
}
// Initialize on load
updateAuthUI();
// Modal functions
function togglePasswordVisibility() {
const input = document.getElementById('api-key-input');
const button = document.querySelector('.password-toggle');
if (input.type === 'password') {
input.type = 'text';
button.textContent = '🙈';
} else {
input.type = 'password';
button.textContent = '👁️';
}
}
function showApiKeyModal(message, hideCancel = false) {
const modal = document.getElementById('api-key-modal');
const description = document.querySelector('.modal-description');
const input = document.getElementById('api-key-input');
const error = document.getElementById('api-key-error');
const cancelBtn = document.getElementById('modal-cancel-btn');
if (message) {
description.textContent = message;
}
input.value = '';
input.placeholder = 'Enter your API key...';
error.style.display = 'none';
modal.style.display = 'flex';
// Hide cancel button if this is required login (no existing session)
cancelBtn.style.display = hideCancel ? 'none' : 'inline-block';
setTimeout(() => input.focus(), 100);
}
function closeApiKeyModal() {
const modal = document.getElementById('api-key-modal');
modal.style.display = 'none';
}
function submitApiKey() {
const input = document.getElementById('api-key-input');
const error = document.getElementById('api-key-error');
const key = input.value.trim();
if (!key) {
error.textContent = 'Please enter an API key';
error.style.display = 'block';
return;
}
// Store the key
localStorage.setItem('wled_api_key', key);
apiKey = key;
updateAuthUI();
closeApiKeyModal();
showToast('Logged in successfully!', 'success');
// Reload data
loadServerInfo();
loadDisplays();
loadDevices();
// Start auto-refresh if not already running
if (!refreshInterval) {
startAutoRefresh();
}
}
// Handle Enter key in modal
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('api-key-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
submitApiKey();
}
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,510 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #4CAF50;
--danger-color: #f44336;
--warning-color: #ff9800;
--info-color: #2196F3;
}
/* Dark theme (default) */
[data-theme="dark"] {
--bg-color: #1a1a1a;
--card-bg: #2d2d2d;
--text-color: #e0e0e0;
--border-color: #404040;
}
/* Light theme */
[data-theme="light"] {
--bg-color: #f5f5f5;
--card-bg: #ffffff;
--text-color: #333333;
--border-color: #e0e0e0;
}
/* Default to dark theme */
body {
background: var(--bg-color);
color: var(--text-color);
}
html {
background: var(--bg-color);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 2px solid var(--border-color);
margin-bottom: 30px;
}
h1 {
font-size: 2rem;
color: var(--primary-color);
}
h2 {
margin-bottom: 20px;
color: var(--text-color);
font-size: 1.5rem;
}
.server-info {
display: flex;
align-items: center;
gap: 15px;
}
.status-badge {
font-size: 1.5rem;
animation: pulse 2s infinite;
}
.status-badge.online {
color: var(--primary-color);
}
.status-badge.offline {
color: var(--danger-color);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
section {
margin-bottom: 40px;
}
.displays-grid,
.devices-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.card-title {
font-size: 1.2rem;
font-weight: 600;
}
.badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
.badge.processing {
background: var(--primary-color);
color: white;
}
.badge.idle {
background: var(--warning-color);
color: white;
}
.badge.error {
background: var(--danger-color);
color: white;
}
.card-content {
margin-bottom: 15px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: #999;
}
.info-value {
font-weight: 600;
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: opacity 0.2s;
flex: 1 1 auto;
min-width: 100px;
}
.btn:hover {
opacity: 0.9;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-secondary {
background: var(--border-color);
color: var(--text-color);
}
.display-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 15px;
text-align: center;
}
.display-index {
font-size: 2rem;
font-weight: 700;
color: var(--info-color);
margin-bottom: 10px;
}
.add-device-section {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
color: #999;
font-weight: 500;
}
input[type="text"],
input[type="url"],
input[type="number"],
input[type="password"],
select {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 1rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
transition: border-color 0.2s, box-shadow 0.2s;
}
/* Better password field appearance */
input[type="password"] {
letter-spacing: 0.15em;
}
input:focus,
select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
/* Remove browser autofill styling */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px var(--bg-color) inset;
-webkit-text-fill-color: var(--text-color);
transition: background-color 5000s ease-in-out 0s;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 4px;
color: white;
font-weight: 600;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
}
.toast.show {
opacity: 1;
}
.toast.success {
background: var(--primary-color);
}
.toast.error {
background: var(--danger-color);
}
.toast.info {
background: var(--info-color);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 10px;
}
.metric {
text-align: center;
padding: 10px;
background: var(--bg-color);
border-radius: 4px;
}
.metric-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
}
.metric-label {
font-size: 0.85rem;
color: #999;
margin-top: 5px;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 2000;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
max-width: 500px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
padding: 24px 24px 16px 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--text-color);
}
.modal-body {
padding: 24px;
}
.modal-description {
color: #999;
margin-bottom: 20px;
line-height: 1.6;
}
.password-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.password-input-wrapper input {
flex: 1;
padding-right: 45px;
}
.password-toggle {
position: absolute;
right: 8px;
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 8px;
color: var(--text-color);
opacity: 0.6;
transition: opacity 0.2s;
}
.password-toggle:hover {
opacity: 1;
}
.input-hint {
display: block;
margin-top: 8px;
color: #666;
font-size: 0.85rem;
}
.error-message {
background: rgba(244, 67, 54, 0.1);
border: 1px solid var(--danger-color);
color: var(--danger-color);
padding: 12px;
border-radius: 4px;
margin-top: 15px;
font-size: 0.9rem;
}
.modal-footer {
padding: 16px 24px 24px 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.modal-footer .btn {
min-width: 100px;
}
/* Theme Toggle */
.theme-toggle {
background: var(--card-bg);
border: 1px solid var(--border-color);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 1.2rem;
transition: transform 0.2s;
margin-left: 10px;
}
.theme-toggle:hover {
transform: scale(1.1);
}
@media (max-width: 768px) {
.displays-grid,
.devices-grid {
grid-template-columns: 1fr;
}
header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.modal-content {
width: 95%;
margin: 20px;
}
.modal-footer {
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
}

View File

@@ -0,0 +1,5 @@
"""Storage layer for device and configuration persistence."""
from .device_store import DeviceStore
__all__ = ["DeviceStore"]

View File

@@ -0,0 +1,360 @@
"""Device storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.core.calibration import (
CalibrationConfig,
calibration_from_dict,
calibration_to_dict,
create_default_calibration,
)
from wled_controller.core.processor_manager import ProcessingSettings
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class Device:
"""Represents a WLED device configuration."""
def __init__(
self,
device_id: str,
name: str,
url: str,
led_count: int,
enabled: bool = True,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
"""Initialize device.
Args:
device_id: Unique device identifier
name: Device name
url: WLED device URL
led_count: Number of LEDs
enabled: Whether device is enabled
settings: Processing settings
calibration: Calibration configuration
created_at: Creation timestamp
updated_at: Last update timestamp
"""
self.id = device_id
self.name = name
self.url = url
self.led_count = led_count
self.enabled = enabled
self.settings = settings or ProcessingSettings()
self.calibration = calibration or create_default_calibration(led_count)
self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow()
def to_dict(self) -> dict:
"""Convert device to dictionary.
Returns:
Dictionary representation
"""
return {
"id": self.id,
"name": self.name,
"url": self.url,
"led_count": self.led_count,
"enabled": self.enabled,
"settings": {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
},
"calibration": calibration_to_dict(self.calibration),
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def from_dict(cls, data: dict) -> "Device":
"""Create device from dictionary.
Args:
data: Dictionary with device data
Returns:
Device instance
"""
settings_data = data.get("settings", {})
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
)
calibration_data = data.get("calibration")
calibration = (
calibration_from_dict(calibration_data)
if calibration_data
else create_default_calibration(data["led_count"])
)
return cls(
device_id=data["id"],
name=data["name"],
url=data["url"],
led_count=data["led_count"],
enabled=data.get("enabled", True),
settings=settings,
calibration=calibration,
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)
class DeviceStore:
"""Persistent storage for WLED devices."""
def __init__(self, storage_file: str | Path):
"""Initialize device store.
Args:
storage_file: Path to JSON storage file
"""
self.storage_file = Path(storage_file)
self._devices: Dict[str, Device] = {}
# Ensure directory exists
self.storage_file.parent.mkdir(parents=True, exist_ok=True)
# Load existing devices
self.load()
logger.info(f"Device store initialized with {len(self._devices)} devices")
def load(self):
"""Load devices from storage file."""
if not self.storage_file.exists():
logger.info(f"Storage file does not exist, starting with empty store")
return
try:
with open(self.storage_file, "r") as f:
data = json.load(f)
devices_data = data.get("devices", {})
self._devices = {
device_id: Device.from_dict(device_data)
for device_id, device_data in devices_data.items()
}
logger.info(f"Loaded {len(self._devices)} devices from storage")
except json.JSONDecodeError as e:
logger.error(f"Failed to parse storage file: {e}")
raise
except Exception as e:
logger.error(f"Failed to load devices: {e}")
raise
def save(self):
"""Save devices to storage file."""
try:
data = {
"devices": {
device_id: device.to_dict()
for device_id, device in self._devices.items()
}
}
# Write to temporary file first
temp_file = self.storage_file.with_suffix(".tmp")
with open(temp_file, "w") as f:
json.dump(data, f, indent=2)
# Atomic rename
temp_file.replace(self.storage_file)
logger.debug(f"Saved {len(self._devices)} devices to storage")
except Exception as e:
logger.error(f"Failed to save devices: {e}")
raise
def create_device(
self,
name: str,
url: str,
led_count: int,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
) -> Device:
"""Create a new device.
Args:
name: Device name
url: WLED device URL
led_count: Number of LEDs
settings: Processing settings
calibration: Calibration configuration
Returns:
Created device
Raises:
ValueError: If validation fails
"""
# Generate unique ID
device_id = f"device_{uuid.uuid4().hex[:8]}"
# Create device
device = Device(
device_id=device_id,
name=name,
url=url,
led_count=led_count,
settings=settings,
calibration=calibration,
)
# Store
self._devices[device_id] = device
self.save()
logger.info(f"Created device {device_id}: {name}")
return device
def get_device(self, device_id: str) -> Optional[Device]:
"""Get device by ID.
Args:
device_id: Device identifier
Returns:
Device or None if not found
"""
return self._devices.get(device_id)
def get_all_devices(self) -> List[Device]:
"""Get all devices.
Returns:
List of all devices
"""
return list(self._devices.values())
def update_device(
self,
device_id: str,
name: Optional[str] = None,
url: Optional[str] = None,
led_count: Optional[int] = None,
enabled: Optional[bool] = None,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
) -> Device:
"""Update device.
Args:
device_id: Device identifier
name: New name (optional)
url: New URL (optional)
led_count: New LED count (optional)
enabled: New enabled state (optional)
settings: New settings (optional)
calibration: New calibration (optional)
Returns:
Updated device
Raises:
ValueError: If device not found or validation fails
"""
device = self._devices.get(device_id)
if not device:
raise ValueError(f"Device {device_id} not found")
# Update fields
if name is not None:
device.name = name
if url is not None:
device.url = url
if led_count is not None:
device.led_count = led_count
# Reset calibration if LED count changed
device.calibration = create_default_calibration(led_count)
if enabled is not None:
device.enabled = enabled
if settings is not None:
device.settings = settings
if calibration is not None:
# Validate LED count matches
if calibration.get_total_leds() != device.led_count:
raise ValueError(
f"Calibration LED count ({calibration.get_total_leds()}) "
f"does not match device LED count ({device.led_count})"
)
device.calibration = calibration
device.updated_at = datetime.utcnow()
# Save
self.save()
logger.info(f"Updated device {device_id}")
return device
def delete_device(self, device_id: str):
"""Delete device.
Args:
device_id: Device identifier
Raises:
ValueError: If device not found
"""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
del self._devices[device_id]
self.save()
logger.info(f"Deleted device {device_id}")
def device_exists(self, device_id: str) -> bool:
"""Check if device exists.
Args:
device_id: Device identifier
Returns:
True if device exists
"""
return device_id in self._devices
def count(self) -> int:
"""Get number of devices.
Returns:
Device count
"""
return len(self._devices)
def clear(self):
"""Clear all devices (for testing)."""
self._devices.clear()
self.save()
logger.warning("Cleared all devices from storage")

View File

@@ -0,0 +1,6 @@
"""Utility functions and helpers."""
from .logger import setup_logging, get_logger
from .monitor_names import get_monitor_names, get_monitor_name
__all__ = ["setup_logging", "get_logger", "get_monitor_names", "get_monitor_name"]

View File

@@ -0,0 +1,86 @@
"""Logging configuration and setup."""
import logging
import sys
from pathlib import Path
from logging.handlers import RotatingFileHandler
import structlog
from pythonjsonlogger import jsonlogger
from wled_controller.config import get_config
def setup_logging() -> None:
"""Configure structured logging for the application."""
config = get_config()
# Ensure log directory exists
log_path = Path(config.logging.file)
log_path.parent.mkdir(parents=True, exist_ok=True)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(config.server.log_level)
# Remove existing handlers
root_logger.handlers.clear()
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(config.server.log_level)
# File handler with rotation
file_handler = RotatingFileHandler(
filename=str(log_path),
maxBytes=config.logging.max_size_mb * 1024 * 1024,
backupCount=config.logging.backup_count,
)
file_handler.setLevel(config.server.log_level)
# Configure formatter based on format setting
if config.logging.format == "json":
formatter = jsonlogger.JsonFormatter(
"%(asctime)s %(name)s %(levelname)s %(message)s"
)
console_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)
else:
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
console_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
# Configure structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
structlog.dev.set_exc_info,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer()
if config.logging.format == "json"
else structlog.dev.ConsoleRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET),
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
"""Get a configured logger instance.
Args:
name: Logger name (typically __name__)
Returns:
Configured structlog logger
"""
return structlog.get_logger(name)

View File

@@ -0,0 +1,79 @@
"""Utility functions for retrieving friendly monitor/display names."""
import sys
from typing import Dict, Optional
from wled_controller.utils import get_logger
logger = get_logger(__name__)
def get_monitor_names() -> Dict[int, str]:
"""Get friendly names for connected monitors.
On Windows, attempts to retrieve monitor names from WMI.
On other platforms, returns empty dict (will fall back to generic names).
Returns:
Dictionary mapping display indices to friendly names
"""
if sys.platform != "win32":
logger.debug("Monitor name detection only supported on Windows")
return {}
try:
import wmi
w = wmi.WMI(namespace="wmi")
monitors = w.WmiMonitorID()
monitor_names = {}
for idx, monitor in enumerate(monitors):
try:
# Extract manufacturer name
manufacturer = ""
if monitor.ManufacturerName:
manufacturer = "".join(chr(c) for c in monitor.ManufacturerName if c != 0)
# Extract user-friendly name
user_name = ""
if monitor.UserFriendlyName:
user_name = "".join(chr(c) for c in monitor.UserFriendlyName if c != 0)
# Build friendly name
if user_name:
friendly_name = user_name.strip()
elif manufacturer:
friendly_name = f"{manufacturer.strip()} Monitor"
else:
friendly_name = f"Display {idx}"
monitor_names[idx] = friendly_name
logger.debug(f"Monitor {idx}: {friendly_name}")
except Exception as e:
logger.debug(f"Failed to parse monitor {idx} name: {e}")
monitor_names[idx] = f"Display {idx}"
return monitor_names
except ImportError:
logger.debug("WMI library not available - install with: pip install wmi")
return {}
except Exception as e:
logger.debug(f"Failed to retrieve monitor names via WMI: {e}")
return {}
def get_monitor_name(index: int) -> str:
"""Get friendly name for a specific monitor.
Args:
index: Monitor index (0-based)
Returns:
Friendly monitor name or generic fallback
"""
monitor_names = get_monitor_names()
return monitor_names.get(index, f"Display {index}")

1
server/tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests for WLED Screen Controller."""

49
server/tests/conftest.py Normal file
View File

@@ -0,0 +1,49 @@
"""Pytest configuration and fixtures."""
import pytest
from pathlib import Path
@pytest.fixture
def test_data_dir(tmp_path):
"""Provide a temporary directory for test data."""
return tmp_path / "data"
@pytest.fixture
def test_config_dir(tmp_path):
"""Provide a temporary directory for test configuration."""
return tmp_path / "config"
@pytest.fixture
def sample_calibration():
"""Provide a sample calibration configuration."""
return {
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [
{"edge": "bottom", "led_start": 0, "led_count": 40, "reverse": False},
{"edge": "right", "led_start": 40, "led_count": 30, "reverse": False},
{"edge": "top", "led_start": 70, "led_count": 40, "reverse": True},
{"edge": "left", "led_start": 110, "led_count": 40, "reverse": True},
],
}
@pytest.fixture
def sample_device():
"""Provide a sample device configuration."""
return {
"id": "test_device_001",
"name": "Test WLED Device",
"url": "http://192.168.1.100",
"led_count": 150,
"enabled": True,
"settings": {
"display_index": 0,
"fps": 30,
"border_width": 10,
"brightness": 0.8,
},
}

75
server/tests/test_api.py Normal file
View File

@@ -0,0 +1,75 @@
"""Tests for API endpoints."""
import pytest
from fastapi.testclient import TestClient
from wled_controller.main import app
from wled_controller import __version__
client = TestClient(app)
def test_root_endpoint():
"""Test root endpoint."""
response = client.get("/")
assert response.status_code == 200
data = response.json()
assert data["name"] == "WLED Screen Controller"
assert data["version"] == __version__
assert "/docs" in data["docs"]
def test_health_check():
"""Test health check endpoint."""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert data["version"] == __version__
assert "timestamp" in data
def test_version_endpoint():
"""Test version endpoint."""
response = client.get("/api/v1/version")
assert response.status_code == 200
data = response.json()
assert data["version"] == __version__
assert "python_version" in data
assert data["api_version"] == "v1"
def test_get_displays():
"""Test get displays endpoint."""
response = client.get("/api/v1/config/displays")
assert response.status_code == 200
data = response.json()
assert "displays" in data
assert "count" in data
assert isinstance(data["displays"], list)
assert data["count"] >= 0
# If displays are found, validate structure
if data["count"] > 0:
display = data["displays"][0]
assert "index" in display
assert "name" in display
assert "width" in display
assert "height" in display
assert "is_primary" in display
def test_openapi_docs():
"""Test OpenAPI documentation is available."""
response = client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
assert data["info"]["title"] == "WLED Screen Controller"
assert data["info"]["version"] == __version__
def test_swagger_ui():
"""Test Swagger UI is available."""
response = client.get("/docs")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]

View File

@@ -0,0 +1,281 @@
"""Tests for calibration system."""
import numpy as np
import pytest
from wled_controller.core.calibration import (
CalibrationSegment,
CalibrationConfig,
PixelMapper,
create_default_calibration,
calibration_from_dict,
calibration_to_dict,
)
from wled_controller.core.screen_capture import BorderPixels
def test_calibration_segment():
"""Test calibration segment creation."""
segment = CalibrationSegment(
edge="top",
led_start=0,
led_count=40,
reverse=False,
)
assert segment.edge == "top"
assert segment.led_start == 0
assert segment.led_count == 40
assert segment.reverse is False
def test_calibration_config_validation():
"""Test calibration configuration validation."""
segments = [
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
CalibrationSegment(edge="right", led_start=40, led_count=30),
CalibrationSegment(edge="top", led_start=70, led_count=40),
CalibrationSegment(edge="left", led_start=110, led_count=40),
]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
segments=segments,
)
assert config.validate() is True
assert config.get_total_leds() == 150
def test_calibration_config_duplicate_edges():
"""Test validation fails with duplicate edges."""
segments = [
CalibrationSegment(edge="top", led_start=0, led_count=40),
CalibrationSegment(edge="top", led_start=40, led_count=40), # Duplicate
]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
segments=segments,
)
with pytest.raises(ValueError, match="Duplicate edges"):
config.validate()
def test_calibration_config_overlapping_indices():
"""Test validation fails with overlapping LED indices."""
segments = [
CalibrationSegment(edge="bottom", led_start=0, led_count=50),
CalibrationSegment(edge="right", led_start=40, led_count=30), # Overlaps
]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
segments=segments,
)
with pytest.raises(ValueError, match="overlap"):
config.validate()
def test_calibration_config_invalid_led_count():
"""Test validation fails with invalid LED counts."""
segments = [
CalibrationSegment(edge="top", led_start=0, led_count=0), # Invalid
]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
segments=segments,
)
with pytest.raises(ValueError):
config.validate()
def test_get_segment_for_edge():
"""Test getting segment by edge name."""
segments = [
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
CalibrationSegment(edge="right", led_start=40, led_count=30),
]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
segments=segments,
)
bottom_seg = config.get_segment_for_edge("bottom")
assert bottom_seg is not None
assert bottom_seg.led_count == 40
missing_seg = config.get_segment_for_edge("top")
assert missing_seg is None
def test_pixel_mapper_initialization():
"""Test pixel mapper initialization."""
config = create_default_calibration(150)
mapper = PixelMapper(config, interpolation_mode="average")
assert mapper.calibration == config
assert mapper.interpolation_mode == "average"
def test_pixel_mapper_invalid_mode():
"""Test pixel mapper with invalid interpolation mode."""
config = create_default_calibration(150)
with pytest.raises(ValueError):
PixelMapper(config, interpolation_mode="invalid")
def test_pixel_mapper_map_border_to_leds():
"""Test mapping border pixels to LED colors."""
config = create_default_calibration(40) # 10 per edge
mapper = PixelMapper(config)
# Create test border pixels (all red)
border_pixels = BorderPixels(
top=np.full((10, 100, 3), [255, 0, 0], dtype=np.uint8),
right=np.full((100, 10, 3), [0, 255, 0], dtype=np.uint8),
bottom=np.full((10, 100, 3), [0, 0, 255], dtype=np.uint8),
left=np.full((100, 10, 3), [255, 255, 0], dtype=np.uint8),
)
led_colors = mapper.map_border_to_leds(border_pixels)
assert len(led_colors) == 40
assert all(isinstance(c, tuple) and len(c) == 3 for c in led_colors)
# Verify colors are reasonable (allowing for some rounding)
# Bottom LEDs should be mostly blue
bottom_color = led_colors[0]
assert bottom_color[2] > 200 # Blue channel high
# Top LEDs should be mostly red
top_segment = config.get_segment_for_edge("top")
top_color = led_colors[top_segment.led_start]
assert top_color[0] > 200 # Red channel high
def test_pixel_mapper_test_calibration():
"""Test calibration testing pattern."""
config = create_default_calibration(100)
mapper = PixelMapper(config)
# Test top edge
led_colors = mapper.test_calibration("top", (255, 0, 0))
assert len(led_colors) == 100
# Top edge should be lit (red)
top_segment = config.get_segment_for_edge("top")
top_leds = led_colors[top_segment.led_start:top_segment.led_start + top_segment.led_count]
assert all(color == (255, 0, 0) for color in top_leds)
# Other LEDs should be off
other_leds = led_colors[:top_segment.led_start]
assert all(color == (0, 0, 0) for color in other_leds)
def test_pixel_mapper_test_calibration_invalid_edge():
"""Test calibration testing with invalid edge."""
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
segments=[
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
],
)
mapper = PixelMapper(config)
with pytest.raises(ValueError):
mapper.test_calibration("top", (255, 0, 0)) # Top not in config
def test_create_default_calibration():
"""Test creating default calibration."""
config = create_default_calibration(150)
assert config.layout == "clockwise"
assert config.start_position == "bottom_left"
assert len(config.segments) == 4
assert config.get_total_leds() == 150
# Check all edges are present
edges = {seg.edge for seg in config.segments}
assert edges == {"top", "right", "bottom", "left"}
def test_create_default_calibration_small_count():
"""Test default calibration with small LED count."""
config = create_default_calibration(4)
assert config.get_total_leds() == 4
def test_create_default_calibration_invalid():
"""Test default calibration with invalid LED count."""
with pytest.raises(ValueError):
create_default_calibration(3) # Too few LEDs
def test_calibration_from_dict():
"""Test creating calibration from dictionary."""
data = {
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [
{"edge": "bottom", "led_start": 0, "led_count": 40, "reverse": False},
{"edge": "right", "led_start": 40, "led_count": 30, "reverse": False},
],
}
config = calibration_from_dict(data)
assert config.layout == "clockwise"
assert config.start_position == "bottom_left"
assert len(config.segments) == 2
assert config.get_total_leds() == 70
def test_calibration_from_dict_missing_field():
"""Test calibration from dict with missing field."""
data = {
"layout": "clockwise",
# Missing start_position
"segments": [],
}
with pytest.raises(ValueError):
calibration_from_dict(data)
def test_calibration_to_dict():
"""Test converting calibration to dictionary."""
config = create_default_calibration(100)
data = calibration_to_dict(config)
assert "layout" in data
assert "start_position" in data
assert "segments" in data
assert isinstance(data["segments"], list)
assert len(data["segments"]) == 4
def test_calibration_round_trip():
"""Test converting calibration to dict and back."""
original = create_default_calibration(120)
data = calibration_to_dict(original)
restored = calibration_from_dict(data)
assert restored.layout == original.layout
assert restored.start_position == original.start_position
assert len(restored.segments) == len(original.segments)
assert restored.get_total_leds() == original.get_total_leds()

120
server/tests/test_config.py Normal file
View File

@@ -0,0 +1,120 @@
"""Tests for configuration management."""
import os
from pathlib import Path
import pytest
import yaml
from wled_controller.config import (
Config,
ServerConfig,
ProcessingConfig,
WLEDConfig,
get_config,
reload_config,
)
def test_default_config():
"""Test default configuration values."""
config = Config()
assert config.server.host == "0.0.0.0"
assert config.server.port == 8080
assert config.processing.default_fps == 30
assert config.processing.max_fps == 60
assert config.wled.timeout == 5
def test_load_from_yaml(tmp_path):
"""Test loading configuration from YAML file."""
config_data = {
"server": {"host": "127.0.0.1", "port": 9000},
"processing": {"default_fps": 60, "border_width": 20},
"wled": {"timeout": 10},
}
config_path = tmp_path / "test_config.yaml"
with open(config_path, "w") as f:
yaml.dump(config_data, f)
config = Config.from_yaml(config_path)
assert config.server.host == "127.0.0.1"
assert config.server.port == 9000
assert config.processing.default_fps == 60
assert config.processing.border_width == 20
assert config.wled.timeout == 10
def test_load_from_yaml_file_not_found():
"""Test loading from non-existent YAML file."""
with pytest.raises(FileNotFoundError):
Config.from_yaml("nonexistent.yaml")
def test_environment_variables(monkeypatch):
"""Test configuration from environment variables."""
monkeypatch.setenv("WLED_SERVER__HOST", "192.168.1.1")
monkeypatch.setenv("WLED_SERVER__PORT", "7000")
monkeypatch.setenv("WLED_PROCESSING__DEFAULT_FPS", "45")
config = Config()
assert config.server.host == "192.168.1.1"
assert config.server.port == 7000
assert config.processing.default_fps == 45
def test_server_config():
"""Test server configuration."""
server_config = ServerConfig(host="localhost", port=8000)
assert server_config.host == "localhost"
assert server_config.port == 8000
assert server_config.log_level == "INFO"
def test_processing_config():
"""Test processing configuration."""
proc_config = ProcessingConfig(default_fps=25, max_fps=50)
assert proc_config.default_fps == 25
assert proc_config.max_fps == 50
assert proc_config.interpolation_mode == "average"
def test_wled_config():
"""Test WLED configuration."""
wled_config = WLEDConfig(timeout=10, retry_attempts=5)
assert wled_config.timeout == 10
assert wled_config.retry_attempts == 5
assert wled_config.protocol == "http"
def test_config_validation():
"""Test configuration validation."""
# Test valid interpolation mode
config = Config(
processing=ProcessingConfig(interpolation_mode="median")
)
assert config.processing.interpolation_mode == "median"
# Test invalid interpolation mode
with pytest.raises(ValueError):
ProcessingConfig(interpolation_mode="invalid")
def test_get_config():
"""Test global config getter."""
config = get_config()
assert isinstance(config, Config)
def test_reload_config():
"""Test config reload."""
config1 = get_config()
config2 = reload_config()
assert isinstance(config2, Config)

View File

@@ -0,0 +1,305 @@
"""Tests for device storage."""
import pytest
from pathlib import Path
from wled_controller.storage.device_store import Device, DeviceStore
from wled_controller.core.processor_manager import ProcessingSettings
from wled_controller.core.calibration import create_default_calibration
@pytest.fixture
def temp_storage(tmp_path):
"""Provide temporary storage file."""
return tmp_path / "devices.json"
@pytest.fixture
def device_store(temp_storage):
"""Provide device store instance."""
return DeviceStore(temp_storage)
def test_device_creation():
"""Test creating a device."""
device = Device(
device_id="test_001",
name="Test Device",
url="http://192.168.1.100",
led_count=150,
)
assert device.id == "test_001"
assert device.name == "Test Device"
assert device.url == "http://192.168.1.100"
assert device.led_count == 150
assert device.enabled is True
def test_device_to_dict():
"""Test converting device to dictionary."""
device = Device(
device_id="test_001",
name="Test Device",
url="http://192.168.1.100",
led_count=150,
)
data = device.to_dict()
assert data["id"] == "test_001"
assert data["name"] == "Test Device"
assert data["url"] == "http://192.168.1.100"
assert data["led_count"] == 150
assert "settings" in data
assert "calibration" in data
def test_device_from_dict():
"""Test creating device from dictionary."""
data = {
"id": "test_001",
"name": "Test Device",
"url": "http://192.168.1.100",
"led_count": 150,
"enabled": True,
"settings": {
"display_index": 0,
"fps": 30,
"border_width": 10,
},
}
device = Device.from_dict(data)
assert device.id == "test_001"
assert device.name == "Test Device"
assert device.led_count == 150
def test_device_round_trip():
"""Test converting device to dict and back."""
original = Device(
device_id="test_001",
name="Test Device",
url="http://192.168.1.100",
led_count=150,
)
data = original.to_dict()
restored = Device.from_dict(data)
assert restored.id == original.id
assert restored.name == original.name
assert restored.url == original.url
assert restored.led_count == original.led_count
def test_device_store_init(device_store):
"""Test device store initialization."""
assert device_store is not None
assert device_store.count() == 0
def test_create_device(device_store):
"""Test creating a device in store."""
device = device_store.create_device(
name="Test WLED",
url="http://192.168.1.100",
led_count=150,
)
assert device.id is not None
assert device.name == "Test WLED"
assert device_store.count() == 1
def test_get_device(device_store):
"""Test retrieving a device."""
created = device_store.create_device(
name="Test WLED",
url="http://192.168.1.100",
led_count=150,
)
retrieved = device_store.get_device(created.id)
assert retrieved is not None
assert retrieved.id == created.id
assert retrieved.name == "Test WLED"
def test_get_device_not_found(device_store):
"""Test retrieving non-existent device."""
device = device_store.get_device("nonexistent")
assert device is None
def test_get_all_devices(device_store):
"""Test getting all devices."""
device_store.create_device("Device 1", "http://192.168.1.100", 150)
device_store.create_device("Device 2", "http://192.168.1.101", 200)
devices = device_store.get_all_devices()
assert len(devices) == 2
assert any(d.name == "Device 1" for d in devices)
assert any(d.name == "Device 2" for d in devices)
def test_update_device(device_store):
"""Test updating a device."""
device = device_store.create_device(
name="Test WLED",
url="http://192.168.1.100",
led_count=150,
)
updated = device_store.update_device(
device.id,
name="Updated WLED",
enabled=False,
)
assert updated.name == "Updated WLED"
assert updated.enabled is False
def test_update_device_settings(device_store):
"""Test updating device settings."""
device = device_store.create_device(
name="Test WLED",
url="http://192.168.1.100",
led_count=150,
)
new_settings = ProcessingSettings(fps=60, border_width=20)
updated = device_store.update_device(
device.id,
settings=new_settings,
)
assert updated.settings.fps == 60
assert updated.settings.border_width == 20
def test_update_device_calibration(device_store):
"""Test updating device calibration."""
device = device_store.create_device(
name="Test WLED",
url="http://192.168.1.100",
led_count=150,
)
new_calibration = create_default_calibration(150)
updated = device_store.update_device(
device.id,
calibration=new_calibration,
)
assert updated.calibration is not None
def test_update_device_not_found(device_store):
"""Test updating non-existent device."""
with pytest.raises(ValueError, match="not found"):
device_store.update_device("nonexistent", name="New Name")
def test_delete_device(device_store):
"""Test deleting a device."""
device = device_store.create_device(
name="Test WLED",
url="http://192.168.1.100",
led_count=150,
)
device_store.delete_device(device.id)
assert device_store.count() == 0
assert device_store.get_device(device.id) is None
def test_delete_device_not_found(device_store):
"""Test deleting non-existent device."""
with pytest.raises(ValueError, match="not found"):
device_store.delete_device("nonexistent")
def test_device_exists(device_store):
"""Test checking if device exists."""
device = device_store.create_device(
name="Test WLED",
url="http://192.168.1.100",
led_count=150,
)
assert device_store.device_exists(device.id) is True
assert device_store.device_exists("nonexistent") is False
def test_persistence(temp_storage):
"""Test device persistence across store instances."""
# Create store and add device
store1 = DeviceStore(temp_storage)
device = store1.create_device(
name="Test WLED",
url="http://192.168.1.100",
led_count=150,
)
device_id = device.id
# Create new store instance (loads from file)
store2 = DeviceStore(temp_storage)
# Verify device persisted
loaded_device = store2.get_device(device_id)
assert loaded_device is not None
assert loaded_device.name == "Test WLED"
assert loaded_device.led_count == 150
def test_clear(device_store):
"""Test clearing all devices."""
device_store.create_device("Device 1", "http://192.168.1.100", 150)
device_store.create_device("Device 2", "http://192.168.1.101", 200)
assert device_store.count() == 2
device_store.clear()
assert device_store.count() == 0
def test_update_led_count_resets_calibration(device_store):
"""Test that updating LED count resets calibration."""
device = device_store.create_device(
name="Test WLED",
url="http://192.168.1.100",
led_count=150,
)
original_calibration = device.calibration
# Update LED count
updated = device_store.update_device(device.id, led_count=200)
# Calibration should be reset for new LED count
assert updated.calibration.get_total_leds() == 200
assert updated.calibration != original_calibration
def test_update_calibration_led_count_mismatch(device_store):
"""Test updating calibration with mismatched LED count fails."""
device = device_store.create_device(
name="Test WLED",
url="http://192.168.1.100",
led_count=150,
)
wrong_calibration = create_default_calibration(100)
with pytest.raises(ValueError, match="does not match"):
device_store.update_device(device.id, calibration=wrong_calibration)

View File

@@ -0,0 +1,254 @@
"""Tests for processor manager."""
import asyncio
import pytest
import respx
from httpx import Response
from wled_controller.core.processor_manager import (
ProcessorManager,
ProcessingSettings,
)
from wled_controller.core.calibration import create_default_calibration
@pytest.fixture
def wled_url():
"""Provide test WLED device URL."""
return "http://192.168.1.100"
@pytest.fixture
def mock_wled_responses():
"""Provide mock WLED API responses."""
return {
"info": {
"name": "Test WLED",
"ver": "0.14.0",
"leds": {"count": 150},
"brand": "WLED",
"product": "FOSS",
"mac": "AA:BB:CC:DD:EE:FF",
"ip": "192.168.1.100",
},
"state": {"on": True, "bri": 255},
}
@pytest.fixture
def processor_manager():
"""Provide processor manager instance."""
return ProcessorManager()
def test_processor_manager_init():
"""Test processor manager initialization."""
manager = ProcessorManager()
assert manager is not None
assert manager.get_all_devices() == []
def test_add_device(processor_manager):
"""Test adding a device."""
processor_manager.add_device(
device_id="test_device",
device_url="http://192.168.1.100",
led_count=150,
)
devices = processor_manager.get_all_devices()
assert "test_device" in devices
def test_add_device_duplicate(processor_manager):
"""Test adding duplicate device fails."""
processor_manager.add_device(
device_id="test_device",
device_url="http://192.168.1.100",
led_count=150,
)
with pytest.raises(ValueError, match="already exists"):
processor_manager.add_device(
device_id="test_device",
device_url="http://192.168.1.100",
led_count=150,
)
def test_remove_device(processor_manager):
"""Test removing a device."""
processor_manager.add_device(
device_id="test_device",
device_url="http://192.168.1.100",
led_count=150,
)
processor_manager.remove_device("test_device")
assert "test_device" not in processor_manager.get_all_devices()
def test_remove_device_not_found(processor_manager):
"""Test removing non-existent device fails."""
with pytest.raises(ValueError, match="not found"):
processor_manager.remove_device("nonexistent")
def test_update_settings(processor_manager):
"""Test updating device settings."""
processor_manager.add_device(
device_id="test_device",
device_url="http://192.168.1.100",
led_count=150,
)
new_settings = ProcessingSettings(
display_index=1,
fps=60,
border_width=20,
)
processor_manager.update_settings("test_device", new_settings)
# Verify settings updated
state = processor_manager.get_state("test_device")
assert state["fps_target"] == 60
def test_update_calibration(processor_manager):
"""Test updating device calibration."""
processor_manager.add_device(
device_id="test_device",
device_url="http://192.168.1.100",
led_count=150,
)
new_calibration = create_default_calibration(150)
processor_manager.update_calibration("test_device", new_calibration)
def test_update_calibration_led_count_mismatch(processor_manager):
"""Test updating calibration with mismatched LED count fails."""
processor_manager.add_device(
device_id="test_device",
device_url="http://192.168.1.100",
led_count=150,
)
wrong_calibration = create_default_calibration(100) # Wrong count
with pytest.raises(ValueError, match="does not match"):
processor_manager.update_calibration("test_device", wrong_calibration)
@pytest.mark.asyncio
@respx.mock
async def test_start_processing(processor_manager, wled_url, mock_wled_responses):
"""Test starting processing."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(200, json=mock_wled_responses["info"])
)
respx.post(f"{wled_url}/json/state").mock(
return_value=Response(200, json={"success": True})
)
processor_manager.add_device(
device_id="test_device",
device_url=wled_url,
led_count=150,
settings=ProcessingSettings(fps=5), # Low FPS for testing
)
await processor_manager.start_processing("test_device")
assert processor_manager.is_processing("test_device") is True
# Let it process a few frames
await asyncio.sleep(0.5)
# Stop processing
await processor_manager.stop_processing("test_device")
assert processor_manager.is_processing("test_device") is False
@pytest.mark.asyncio
async def test_start_processing_already_running(processor_manager):
"""Test starting processing when already running fails."""
# This test would need mocked WLED responses
# Skipping actual connection for simplicity
pass
@pytest.mark.asyncio
async def test_stop_processing_not_running(processor_manager):
"""Test stopping processing when not running."""
processor_manager.add_device(
device_id="test_device",
device_url="http://192.168.1.100",
led_count=150,
)
# Should not raise error
await processor_manager.stop_processing("test_device")
def test_get_state(processor_manager):
"""Test getting device state."""
processor_manager.add_device(
device_id="test_device",
device_url="http://192.168.1.100",
led_count=150,
settings=ProcessingSettings(fps=30, display_index=0),
)
state = processor_manager.get_state("test_device")
assert state["device_id"] == "test_device"
assert state["processing"] is False
assert state["fps_target"] == 30
assert state["display_index"] == 0
def test_get_state_not_found(processor_manager):
"""Test getting state for non-existent device."""
with pytest.raises(ValueError, match="not found"):
processor_manager.get_state("nonexistent")
def test_get_metrics(processor_manager):
"""Test getting device metrics."""
processor_manager.add_device(
device_id="test_device",
device_url="http://192.168.1.100",
led_count=150,
)
metrics = processor_manager.get_metrics("test_device")
assert metrics["device_id"] == "test_device"
assert metrics["processing"] is False
assert metrics["frames_processed"] == 0
assert metrics["errors_count"] == 0
@pytest.mark.asyncio
async def test_stop_all(processor_manager):
"""Test stopping all processors."""
processor_manager.add_device(
device_id="test_device1",
device_url="http://192.168.1.100",
led_count=150,
)
processor_manager.add_device(
device_id="test_device2",
device_url="http://192.168.1.101",
led_count=150,
)
await processor_manager.stop_all()
assert processor_manager.is_processing("test_device1") is False
assert processor_manager.is_processing("test_device2") is False

View File

@@ -0,0 +1,223 @@
"""Tests for screen capture functionality."""
import numpy as np
import pytest
from wled_controller.core.screen_capture import (
get_available_displays,
capture_display,
extract_border_pixels,
get_edge_segments,
calculate_average_color,
calculate_median_color,
calculate_dominant_color,
ScreenCapture,
)
def test_get_available_displays():
"""Test getting available displays."""
displays = get_available_displays()
assert isinstance(displays, list)
assert len(displays) >= 1 # At least one display should be available
# Check first display structure
display = displays[0]
assert hasattr(display, "index")
assert hasattr(display, "name")
assert hasattr(display, "width")
assert hasattr(display, "height")
assert display.width > 0
assert display.height > 0
def test_capture_display():
"""Test capturing a display."""
# Capture the first display
capture = capture_display(0)
assert isinstance(capture, ScreenCapture)
assert capture.image is not None
assert capture.width > 0
assert capture.height > 0
assert capture.display_index == 0
assert isinstance(capture.image, np.ndarray)
assert capture.image.shape == (capture.height, capture.width, 3)
def test_capture_display_invalid_index():
"""Test capturing with invalid display index."""
with pytest.raises(ValueError):
capture_display(999) # Invalid display index
def test_extract_border_pixels():
"""Test extracting border pixels."""
# Create a test screen capture
test_image = np.random.randint(0, 256, (100, 200, 3), dtype=np.uint8)
capture = ScreenCapture(
image=test_image,
width=200,
height=100,
display_index=0
)
border_width = 10
borders = extract_border_pixels(capture, border_width)
# Check border shapes
assert borders.top.shape == (border_width, 200, 3)
assert borders.bottom.shape == (border_width, 200, 3)
assert borders.left.shape == (100, border_width, 3)
assert borders.right.shape == (100, border_width, 3)
def test_extract_border_pixels_invalid_width():
"""Test extracting borders with invalid width."""
test_image = np.random.randint(0, 256, (100, 200, 3), dtype=np.uint8)
capture = ScreenCapture(
image=test_image,
width=200,
height=100,
display_index=0
)
# Border width too small
with pytest.raises(ValueError):
extract_border_pixels(capture, 0)
# Border width too large
with pytest.raises(ValueError):
extract_border_pixels(capture, 50)
def test_get_edge_segments():
"""Test dividing edge into segments."""
# Create test edge pixels (horizontal edge)
edge_pixels = np.random.randint(0, 256, (10, 100, 3), dtype=np.uint8)
segments = get_edge_segments(edge_pixels, 10, "top")
assert len(segments) == 10
# Each segment should have width of approximately 10
for segment in segments:
assert segment.shape[0] == 10 # Height stays same
assert 8 <= segment.shape[1] <= 12 # Width varies slightly
assert segment.shape[2] == 3 # RGB
def test_get_edge_segments_vertical():
"""Test dividing vertical edge into segments."""
# Create test edge pixels (vertical edge)
edge_pixels = np.random.randint(0, 256, (100, 10, 3), dtype=np.uint8)
segments = get_edge_segments(edge_pixels, 10, "left")
assert len(segments) == 10
# Each segment should have height of approximately 10
for segment in segments:
assert 8 <= segment.shape[0] <= 12 # Height varies slightly
assert segment.shape[1] == 10 # Width stays same
assert segment.shape[2] == 3 # RGB
def test_get_edge_segments_invalid():
"""Test edge segments with invalid parameters."""
edge_pixels = np.random.randint(0, 256, (10, 100, 3), dtype=np.uint8)
with pytest.raises(ValueError):
get_edge_segments(edge_pixels, 0, "top")
with pytest.raises(ValueError):
get_edge_segments(edge_pixels, 200, "top") # More segments than pixels
def test_calculate_average_color():
"""Test calculating average color."""
# Create uniform color region
pixels = np.full((10, 10, 3), [100, 150, 200], dtype=np.uint8)
color = calculate_average_color(pixels)
assert color == (100, 150, 200)
def test_calculate_average_color_mixed():
"""Test average color with mixed colors."""
# Create region with two colors
pixels = np.zeros((10, 10, 3), dtype=np.uint8)
pixels[:5, :, :] = [255, 0, 0] # Top half red
pixels[5:, :, :] = [0, 0, 255] # Bottom half blue
color = calculate_average_color(pixels)
# Should be roughly purple (average of red and blue)
assert 120 <= color[0] <= 135 # R
assert 0 <= color[1] <= 10 # G
assert 120 <= color[2] <= 135 # B
def test_calculate_median_color():
"""Test calculating median color."""
# Create region with outliers
pixels = np.full((10, 10, 3), [100, 100, 100], dtype=np.uint8)
pixels[0, 0, :] = [255, 255, 255] # One bright outlier
color = calculate_median_color(pixels)
# Median should be close to 100, not affected by outlier
assert 95 <= color[0] <= 105
assert 95 <= color[1] <= 105
assert 95 <= color[2] <= 105
def test_calculate_dominant_color():
"""Test calculating dominant color."""
# Create region with mostly one color
pixels = np.full((20, 20, 3), [100, 150, 200], dtype=np.uint8)
# Add some noise
pixels[:2, :2, :] = [50, 75, 100]
color = calculate_dominant_color(pixels)
# Dominant color should be close to the main color
assert 90 <= color[0] <= 110
assert 140 <= color[1] <= 160
assert 190 <= color[2] <= 210
def test_calculate_color_empty_pixels():
"""Test color calculation with empty pixel array."""
empty_pixels = np.array([]).reshape(0, 0, 3)
assert calculate_average_color(empty_pixels) == (0, 0, 0)
assert calculate_median_color(empty_pixels) == (0, 0, 0)
assert calculate_dominant_color(empty_pixels) == (0, 0, 0)
def test_end_to_end_screen_capture():
"""Test complete screen capture workflow."""
# Get available displays
displays = get_available_displays()
assert len(displays) > 0
# Capture first display
capture = capture_display(0)
assert capture is not None
# Extract borders
borders = extract_border_pixels(capture, 10)
assert borders.top is not None
assert borders.bottom is not None
assert borders.left is not None
assert borders.right is not None
# Get segments for top edge
top_segments = get_edge_segments(borders.top, 10, "top")
assert len(top_segments) == 10
# Calculate color for first segment
color = calculate_average_color(top_segments[0])
assert len(color) == 3
assert all(0 <= c <= 255 for c in color)

View File

@@ -0,0 +1,253 @@
"""Tests for WLED client."""
import pytest
import respx
from httpx import Response
from wled_controller.core.wled_client import WLEDClient, WLEDInfo
@pytest.fixture
def wled_url():
"""Provide test WLED device URL."""
return "http://192.168.1.100"
@pytest.fixture
def mock_wled_info():
"""Provide mock WLED info response."""
return {
"name": "Test WLED",
"ver": "0.14.0",
"leds": {"count": 150},
"brand": "WLED",
"product": "FOSS",
"mac": "AA:BB:CC:DD:EE:FF",
"ip": "192.168.1.100",
}
@pytest.fixture
def mock_wled_state():
"""Provide mock WLED state response."""
return {
"on": True,
"bri": 255,
"seg": [{"id": 0, "on": True}],
}
@pytest.mark.asyncio
@respx.mock
async def test_wled_client_connect(wled_url, mock_wled_info):
"""Test connecting to WLED device."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(200, json=mock_wled_info)
)
client = WLEDClient(wled_url)
success = await client.connect()
assert success is True
assert client.is_connected is True
await client.close()
@pytest.mark.asyncio
@respx.mock
async def test_wled_client_connect_failure(wled_url):
"""Test connection failure handling."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(500, text="Internal Server Error")
)
client = WLEDClient(wled_url, retry_attempts=1)
with pytest.raises(RuntimeError):
await client.connect()
assert client.is_connected is False
@pytest.mark.asyncio
@respx.mock
async def test_get_info(wled_url, mock_wled_info):
"""Test getting device info."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(200, json=mock_wled_info)
)
async with WLEDClient(wled_url) as client:
info = await client.get_info()
assert isinstance(info, WLEDInfo)
assert info.name == "Test WLED"
assert info.version == "0.14.0"
assert info.led_count == 150
assert info.mac == "AA:BB:CC:DD:EE:FF"
@pytest.mark.asyncio
@respx.mock
async def test_get_state(wled_url, mock_wled_info, mock_wled_state):
"""Test getting device state."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(200, json=mock_wled_info)
)
respx.get(f"{wled_url}/json/state").mock(
return_value=Response(200, json=mock_wled_state)
)
async with WLEDClient(wled_url) as client:
state = await client.get_state()
assert state["on"] is True
assert state["bri"] == 255
@pytest.mark.asyncio
@respx.mock
async def test_send_pixels(wled_url, mock_wled_info):
"""Test sending pixel data."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(200, json=mock_wled_info)
)
respx.post(f"{wled_url}/json/state").mock(
return_value=Response(200, json={"success": True})
)
async with WLEDClient(wled_url) as client:
pixels = [
(255, 0, 0), # Red
(0, 255, 0), # Green
(0, 0, 255), # Blue
]
success = await client.send_pixels(pixels, brightness=200)
assert success is True
@pytest.mark.asyncio
@respx.mock
async def test_send_pixels_invalid_values(wled_url, mock_wled_info):
"""Test sending invalid pixel values."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(200, json=mock_wled_info)
)
async with WLEDClient(wled_url) as client:
# Invalid RGB value
with pytest.raises(ValueError):
await client.send_pixels([(300, 0, 0)])
# Invalid brightness
with pytest.raises(ValueError):
await client.send_pixels([(255, 0, 0)], brightness=300)
# Empty pixels list
with pytest.raises(ValueError):
await client.send_pixels([])
@pytest.mark.asyncio
@respx.mock
async def test_set_power(wled_url, mock_wled_info):
"""Test turning device on/off."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(200, json=mock_wled_info)
)
respx.post(f"{wled_url}/json/state").mock(
return_value=Response(200, json={"success": True})
)
async with WLEDClient(wled_url) as client:
# Turn on
success = await client.set_power(True)
assert success is True
# Turn off
success = await client.set_power(False)
assert success is True
@pytest.mark.asyncio
@respx.mock
async def test_set_brightness(wled_url, mock_wled_info):
"""Test setting brightness."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(200, json=mock_wled_info)
)
respx.post(f"{wled_url}/json/state").mock(
return_value=Response(200, json={"success": True})
)
async with WLEDClient(wled_url) as client:
success = await client.set_brightness(128)
assert success is True
# Invalid brightness
with pytest.raises(ValueError):
await client.set_brightness(300)
@pytest.mark.asyncio
@respx.mock
async def test_test_connection(wled_url, mock_wled_info):
"""Test connection testing."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(200, json=mock_wled_info)
)
async with WLEDClient(wled_url) as client:
success = await client.test_connection()
assert success is True
@pytest.mark.asyncio
@respx.mock
async def test_retry_logic(wled_url, mock_wled_info):
"""Test retry logic on failures."""
# Mock to fail twice, then succeed
call_count = 0
def mock_response(request):
nonlocal call_count
call_count += 1
if call_count < 3:
return Response(500, text="Error")
return Response(200, json=mock_wled_info)
respx.get(f"{wled_url}/json/info").mock(side_effect=mock_response)
client = WLEDClient(wled_url, retry_attempts=3, retry_delay=0.1)
success = await client.connect()
assert success is True
assert call_count == 3 # Failed 2 times, succeeded on 3rd
await client.close()
@pytest.mark.asyncio
@respx.mock
async def test_context_manager(wled_url, mock_wled_info):
"""Test async context manager usage."""
respx.get(f"{wled_url}/json/info").mock(
return_value=Response(200, json=mock_wled_info)
)
async with WLEDClient(wled_url) as client:
assert client.is_connected is True
# Client should be closed after context
assert client.is_connected is False
@pytest.mark.asyncio
async def test_request_without_connection(wled_url):
"""Test making request without connecting first."""
client = WLEDClient(wled_url)
with pytest.raises(RuntimeError):
await client.get_state()