commit d471a40234a89633c6695c740b38f7e9decb4c6b Author: alexei.dolgolyov Date: Fri Feb 6 16:38:27 2026 +0300 Initial commit: WLED Screen Controller with FastAPI server and Home Assistant integration 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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..6df4e03 --- /dev/null +++ b/.github/workflows/validate.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbdbe80 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..96422e6 --- /dev/null +++ b/INSTALLATION.md @@ -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) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2ceb045 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..002956a --- /dev/null +++ b/README.md @@ -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) diff --git a/custom_components/wled_screen_controller/README.md b/custom_components/wled_screen_controller/README.md new file mode 100644 index 0000000..647f9e5 --- /dev/null +++ b/custom_components/wled_screen_controller/README.md @@ -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) diff --git a/custom_components/wled_screen_controller/__init__.py b/custom_components/wled_screen_controller/__init__.py new file mode 100644 index 0000000..b4eb1bd --- /dev/null +++ b/custom_components/wled_screen_controller/__init__.py @@ -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) diff --git a/custom_components/wled_screen_controller/config_flow.py b/custom_components/wled_screen_controller/config_flow.py new file mode 100644 index 0000000..b4c803e --- /dev/null +++ b/custom_components/wled_screen_controller/config_flow.py @@ -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, + ) diff --git a/custom_components/wled_screen_controller/const.py b/custom_components/wled_screen_controller/const.py new file mode 100644 index 0000000..917a2fc --- /dev/null +++ b/custom_components/wled_screen_controller/const.py @@ -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" diff --git a/custom_components/wled_screen_controller/coordinator.py b/custom_components/wled_screen_controller/coordinator.py new file mode 100644 index 0000000..5a51625 --- /dev/null +++ b/custom_components/wled_screen_controller/coordinator.py @@ -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() diff --git a/custom_components/wled_screen_controller/manifest.json b/custom_components/wled_screen_controller/manifest.json new file mode 100644 index 0000000..1adaf31 --- /dev/null +++ b/custom_components/wled_screen_controller/manifest.json @@ -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" +} diff --git a/custom_components/wled_screen_controller/select.py b/custom_components/wled_screen_controller/select.py new file mode 100644 index 0000000..4fe1907 --- /dev/null +++ b/custom_components/wled_screen_controller/select.py @@ -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 diff --git a/custom_components/wled_screen_controller/sensor.py b/custom_components/wled_screen_controller/sensor.py new file mode 100644 index 0000000..18c70a2 --- /dev/null +++ b/custom_components/wled_screen_controller/sensor.py @@ -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) diff --git a/custom_components/wled_screen_controller/strings.json b/custom_components/wled_screen_controller/strings.json new file mode 100644 index 0000000..aaae5bf --- /dev/null +++ b/custom_components/wled_screen_controller/strings.json @@ -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" + } + } +} diff --git a/custom_components/wled_screen_controller/switch.py b/custom_components/wled_screen_controller/switch.py new file mode 100644 index 0000000..ff70aee --- /dev/null +++ b/custom_components/wled_screen_controller/switch.py @@ -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 diff --git a/custom_components/wled_screen_controller/translations/en.json b/custom_components/wled_screen_controller/translations/en.json new file mode 100644 index 0000000..aaae5bf --- /dev/null +++ b/custom_components/wled_screen_controller/translations/en.json @@ -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" + } + } +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..da52fb3 --- /dev/null +++ b/docs/API.md @@ -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 diff --git a/docs/CALIBRATION.md b/docs/CALIBRATION.md new file mode 100644 index 0000000..ac40eda --- /dev/null +++ b/docs/CALIBRATION.md @@ -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! diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..81530d6 --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "WLED Screen Controller", + "render_readme": true, + "country": ["US"], + "homeassistant": "2023.1.0" +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..c9e4cdf --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,39 @@ +FROM python:3.11-slim + +LABEL maintainer="Alexei Dolgolyov " +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"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..384fd67 --- /dev/null +++ b/server/README.md @@ -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) diff --git a/server/config/default_config.yaml b/server/config/default_config.yaml new file mode 100644 index 0000000..25ae512 --- /dev/null +++ b/server/config/default_config.yaml @@ -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 diff --git a/server/config/test_config.yaml b/server/config/test_config.yaml new file mode 100644 index 0000000..750b953 --- /dev/null +++ b/server/config/test_config.yaml @@ -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 diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..65a3f63 --- /dev/null +++ b/server/docker-compose.yml @@ -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 diff --git a/server/docs/AUTHENTICATION.md b/server/docs/AUTHENTICATION.md new file mode 100644 index 0000000..f8404d2 --- /dev/null +++ b/server/docs/AUTHENTICATION.md @@ -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 diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 0000000..12e5e75 --- /dev/null +++ b/server/pyproject.toml @@ -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" diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..930f90d --- /dev/null +++ b/server/requirements.txt @@ -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 diff --git a/server/src/wled_controller/__init__.py b/server/src/wled_controller/__init__.py new file mode 100644 index 0000000..13a4c41 --- /dev/null +++ b/server/src/wled_controller/__init__.py @@ -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" diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py new file mode 100644 index 0000000..86766cc --- /dev/null +++ b/server/src/wled_controller/api/__init__.py @@ -0,0 +1,5 @@ +"""API routes and schemas.""" + +from .routes import router + +__all__ = ["router"] diff --git a/server/src/wled_controller/api/auth.py b/server/src/wled_controller/api/auth.py new file mode 100644 index 0000000..d6e0fd4 --- /dev/null +++ b/server/src/wled_controller/api/auth.py @@ -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)] diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py new file mode 100644 index 0000000..5015d44 --- /dev/null +++ b/server/src/wled_controller/api/routes.py @@ -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)) diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py new file mode 100644 index 0000000..7a58b66 --- /dev/null +++ b/server/src/wled_controller/api/schemas.py @@ -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") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py new file mode 100644 index 0000000..f28127d --- /dev/null +++ b/server/src/wled_controller/config.py @@ -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 diff --git a/server/src/wled_controller/core/__init__.py b/server/src/wled_controller/core/__init__.py new file mode 100644 index 0000000..6172e72 --- /dev/null +++ b/server/src/wled_controller/core/__init__.py @@ -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", +] diff --git a/server/src/wled_controller/core/calibration.py b/server/src/wled_controller/core/calibration.py new file mode 100644 index 0000000..d61498f --- /dev/null +++ b/server/src/wled_controller/core/calibration.py @@ -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 + ], + } diff --git a/server/src/wled_controller/core/pixel_processor.py b/server/src/wled_controller/core/pixel_processor.py new file mode 100644 index 0000000..6e89275 --- /dev/null +++ b/server/src/wled_controller/core/pixel_processor.py @@ -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 diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py new file mode 100644 index 0000000..33ad1cd --- /dev/null +++ b/server/src/wled_controller/core/processor_manager.py @@ -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") diff --git a/server/src/wled_controller/core/screen_capture.py b/server/src/wled_controller/core/screen_capture.py new file mode 100644 index 0000000..66af6f6 --- /dev/null +++ b/server/src/wled_controller/core/screen_capture.py @@ -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) diff --git a/server/src/wled_controller/core/wled_client.py b/server/src/wled_controller/core/wled_client.py new file mode 100644 index 0000000..3e2c5e6 --- /dev/null +++ b/server/src/wled_controller/core/wled_client.py @@ -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 diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py new file mode 100644 index 0000000..69b4d72 --- /dev/null +++ b/server/src/wled_controller/main.py @@ -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, + ) diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js new file mode 100644 index 0000000..fb1b424 --- /dev/null +++ b/server/src/wled_controller/static/app.js @@ -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 = '
No displays available
'; + return; + } + + container.innerHTML = data.displays.map(display => ` +
+
${display.name}
+
+ Resolution: + ${display.width} × ${display.height} +
+
+ Position: + ${display.x}, ${display.y} +
+
+ `).join(''); + } catch (error) { + console.error('Failed to load displays:', error); + document.getElementById('displays-list').innerHTML = + '
Failed to load displays
'; + } +} + +// 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 = '
No devices attached
'; + 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 = + '
Failed to load devices
'; + } +} + +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 ` +
+
+
${device.name || device.id}
+ ${status.toUpperCase()} +
+
+
+ URL: + ${device.url || 'N/A'} +
+
+ LED Count: + ${device.led_count || 0} +
+
+ Display: + Display ${settings.display_index !== undefined ? settings.display_index : 0} +
+ ${isProcessing ? ` +
+
+
${state.fps_actual?.toFixed(1) || '0.0'}
+
Actual FPS
+
+
+
${state.fps_target || 0}
+
Target FPS
+
+
+
${metrics.frames_processed || 0}
+
Frames
+
+
+
${metrics.errors_count || 0}
+
Errors
+
+
+ ` : ''} +
+
+ ${isProcessing ? ` + + ` : ` + + `} + + + +
+
+ `; +} + +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); + } +}); diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html new file mode 100644 index 0000000..efa0767 --- /dev/null +++ b/server/src/wled_controller/static/index.html @@ -0,0 +1,416 @@ + + + + + + WLED Screen Controller + + + +
+
+

WLED Screen Controller

+
+ Version: Loading... + + + + + +
+
+ +
+

Available Displays

+
+
Loading displays...
+
+
+ +
+

WLED Devices

+
+
Loading devices...
+
+
+ +
+

Add New Device

+
+ 📱 WLED Configuration: Configure your WLED device (effects, segments, color order, power limits, etc.) using the + official WLED app. + This controller sends pixel color data and controls brightness per device. +
+
+
+ + +
+
+ + +
+
+ + + Number of LEDs configured in your WLED device +
+ +
+
+
+ +
+ + + + + + + + + + + + + + diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css new file mode 100644 index 0000000..f90375c --- /dev/null +++ b/server/src/wled_controller/static/style.css @@ -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%; + } +} diff --git a/server/src/wled_controller/storage/__init__.py b/server/src/wled_controller/storage/__init__.py new file mode 100644 index 0000000..8d4b313 --- /dev/null +++ b/server/src/wled_controller/storage/__init__.py @@ -0,0 +1,5 @@ +"""Storage layer for device and configuration persistence.""" + +from .device_store import DeviceStore + +__all__ = ["DeviceStore"] diff --git a/server/src/wled_controller/storage/device_store.py b/server/src/wled_controller/storage/device_store.py new file mode 100644 index 0000000..0b28356 --- /dev/null +++ b/server/src/wled_controller/storage/device_store.py @@ -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") diff --git a/server/src/wled_controller/utils/__init__.py b/server/src/wled_controller/utils/__init__.py new file mode 100644 index 0000000..4e58a9b --- /dev/null +++ b/server/src/wled_controller/utils/__init__.py @@ -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"] diff --git a/server/src/wled_controller/utils/logger.py b/server/src/wled_controller/utils/logger.py new file mode 100644 index 0000000..64d52be --- /dev/null +++ b/server/src/wled_controller/utils/logger.py @@ -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) diff --git a/server/src/wled_controller/utils/monitor_names.py b/server/src/wled_controller/utils/monitor_names.py new file mode 100644 index 0000000..b8039f6 --- /dev/null +++ b/server/src/wled_controller/utils/monitor_names.py @@ -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}") diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..a906d6f --- /dev/null +++ b/server/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for WLED Screen Controller.""" diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..c17a4cb --- /dev/null +++ b/server/tests/conftest.py @@ -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, + }, + } diff --git a/server/tests/test_api.py b/server/tests/test_api.py new file mode 100644 index 0000000..2501039 --- /dev/null +++ b/server/tests/test_api.py @@ -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"] diff --git a/server/tests/test_calibration.py b/server/tests/test_calibration.py new file mode 100644 index 0000000..0341cf0 --- /dev/null +++ b/server/tests/test_calibration.py @@ -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() diff --git a/server/tests/test_config.py b/server/tests/test_config.py new file mode 100644 index 0000000..823f45d --- /dev/null +++ b/server/tests/test_config.py @@ -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) diff --git a/server/tests/test_device_store.py b/server/tests/test_device_store.py new file mode 100644 index 0000000..0534b69 --- /dev/null +++ b/server/tests/test_device_store.py @@ -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) diff --git a/server/tests/test_processor_manager.py b/server/tests/test_processor_manager.py new file mode 100644 index 0000000..d2f4f8d --- /dev/null +++ b/server/tests/test_processor_manager.py @@ -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 diff --git a/server/tests/test_screen_capture.py b/server/tests/test_screen_capture.py new file mode 100644 index 0000000..a76d16b --- /dev/null +++ b/server/tests/test_screen_capture.py @@ -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) diff --git a/server/tests/test_wled_client.py b/server/tests/test_wled_client.py new file mode 100644 index 0000000..62fa958 --- /dev/null +++ b/server/tests/test_wled_client.py @@ -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()