Initial commit: WLED Screen Controller with FastAPI server and Home Assistant integration
Some checks failed
Validate / validate (push) Failing after 1m6s
Some checks failed
Validate / validate (push) Failing after 1m6s
This is a complete WLED ambient lighting controller that captures screen border pixels and sends them to WLED devices for immersive ambient lighting effects. ## Server Features: - FastAPI-based REST API with 17+ endpoints - Real-time screen capture with multi-monitor support - Advanced LED calibration system with visual GUI - API key authentication with labeled tokens - Per-device brightness control (0-100%) - Configurable FPS (1-60), border width, and color correction - Persistent device storage (JSON-based) - Comprehensive Web UI with dark/light themes - Docker support with docker-compose - Windows monitor name detection via WMI (shows "LG ULTRAWIDE" etc.) ## Web UI Features: - Device management (add, configure, remove WLED devices) - Real-time status monitoring with FPS metrics - Settings modal for device configuration - Visual calibration GUI with edge testing - Brightness slider per device - Display selection with friendly monitor names - Token-based authentication with login/logout - Responsive button layout ## Calibration System: - Support for any LED strip layout (clockwise/counterclockwise) - 4 starting position options (corners) - Per-edge LED count configuration - Visual preview with starting position indicator - Test buttons to light up individual edges - Smart LED ordering based on start position and direction ## Home Assistant Integration: - Custom HACS integration - Switch entities for processing control - Sensor entities for status and FPS - Select entities for display selection - Config flow for easy setup - Auto-discovery of devices from server ## Technical Stack: - Python 3.11+ - FastAPI + uvicorn - mss (screen capture) - httpx (async WLED client) - Pydantic (validation) - WMI (Windows monitor detection) - Structlog (logging) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
17
.github/workflows/validate.yml
vendored
Normal file
17
.github/workflows/validate.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v3"
|
||||
- name: HACS validation
|
||||
uses: "hacs/action@main"
|
||||
with:
|
||||
category: "integration"
|
||||
72
.gitignore
vendored
Normal file
72
.gitignore
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
.claude/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
*.log.*
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.json.bak
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# Home Assistant
|
||||
homeassistant/.storage/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
305
INSTALLATION.md
Normal file
305
INSTALLATION.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Installation Guide
|
||||
|
||||
Complete installation guide for WLED Screen Controller server and Home Assistant integration.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Server Installation](#server-installation)
|
||||
2. [Home Assistant Integration](#home-assistant-integration)
|
||||
3. [Quick Start](#quick-start)
|
||||
|
||||
---
|
||||
|
||||
## Server Installation
|
||||
|
||||
### Option 1: Python (Development/Testing)
|
||||
|
||||
**Requirements:**
|
||||
- Python 3.11 or higher
|
||||
- Windows, Linux, or macOS
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/wled-screen-controller.git
|
||||
cd wled-screen-controller/server
|
||||
```
|
||||
|
||||
2. **Create virtual environment:**
|
||||
```bash
|
||||
python -m venv venv
|
||||
|
||||
# Windows
|
||||
venv\Scripts\activate
|
||||
|
||||
# Linux/Mac
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
3. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Configure (optional):**
|
||||
Edit `config/default_config.yaml` to customize settings.
|
||||
|
||||
5. **Run the server:**
|
||||
```bash
|
||||
# Set PYTHONPATH
|
||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
set PYTHONPATH=%CD%\src # Windows
|
||||
|
||||
# Start server
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
6. **Verify:**
|
||||
Open http://localhost:8080/docs in your browser.
|
||||
|
||||
### Option 2: Docker (Recommended for Production)
|
||||
|
||||
**Requirements:**
|
||||
- Docker
|
||||
- Docker Compose
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/wled-screen-controller.git
|
||||
cd wled-screen-controller/server
|
||||
```
|
||||
|
||||
2. **Start with Docker Compose:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. **View logs:**
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
4. **Verify:**
|
||||
Open http://localhost:8080/docs in your browser.
|
||||
|
||||
### Option 3: Docker (Manual Build)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
docker build -t wled-screen-controller .
|
||||
|
||||
docker run -d \
|
||||
--name wled-controller \
|
||||
-p 8080:8080 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
--network host \
|
||||
wled-screen-controller
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Home Assistant Integration
|
||||
|
||||
### Option 1: HACS (Recommended)
|
||||
|
||||
1. **Install HACS** if not already installed:
|
||||
- Follow instructions at https://hacs.xyz/docs/setup/download
|
||||
|
||||
2. **Add Custom Repository:**
|
||||
- Open HACS in Home Assistant
|
||||
- Click the three dots menu → Custom repositories
|
||||
- Add URL: `https://github.com/yourusername/wled-screen-controller`
|
||||
- Category: Integration
|
||||
- Click Add
|
||||
|
||||
3. **Install Integration:**
|
||||
- In HACS, search for "WLED Screen Controller"
|
||||
- Click Download
|
||||
- Restart Home Assistant
|
||||
|
||||
4. **Configure Integration:**
|
||||
- Go to Settings → Devices & Services
|
||||
- Click "+ Add Integration"
|
||||
- Search for "WLED Screen Controller"
|
||||
- Enter your server URL (e.g., `http://192.168.1.100:8080`)
|
||||
- Click Submit
|
||||
|
||||
### Option 2: Manual Installation
|
||||
|
||||
1. **Download Integration:**
|
||||
```bash
|
||||
cd /config # Your Home Assistant config directory
|
||||
mkdir -p custom_components
|
||||
```
|
||||
|
||||
2. **Copy Files:**
|
||||
Copy the `custom_components/wled_screen_controller` folder to your Home Assistant `custom_components` directory.
|
||||
|
||||
3. **Restart Home Assistant**
|
||||
|
||||
4. **Configure Integration:**
|
||||
- Go to Settings → Devices & Services
|
||||
- Click "+ Add Integration"
|
||||
- Search for "WLED Screen Controller"
|
||||
- Enter your server URL
|
||||
- Click Submit
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start the Server
|
||||
|
||||
```bash
|
||||
cd wled-screen-controller/server
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. Attach Your WLED Device
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/devices \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Living Room TV",
|
||||
"url": "http://192.168.1.100",
|
||||
"led_count": 150
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. Configure in Home Assistant
|
||||
|
||||
1. Add the integration (see above)
|
||||
2. Your WLED devices will appear automatically
|
||||
3. Use the switch to turn processing on/off
|
||||
4. Use the select to choose display
|
||||
5. Monitor FPS and status via sensors
|
||||
|
||||
### 4. Start Processing
|
||||
|
||||
Either via API:
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
|
||||
```
|
||||
|
||||
Or via Home Assistant:
|
||||
- Turn on the "{Device Name} Processing" switch
|
||||
|
||||
### 5. Enjoy Ambient Lighting!
|
||||
|
||||
Your WLED strip should now sync with your screen content!
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Won't Start
|
||||
|
||||
**Check Python version:**
|
||||
```bash
|
||||
python --version # Should be 3.11+
|
||||
```
|
||||
|
||||
**Check dependencies:**
|
||||
```bash
|
||||
pip list | grep fastapi
|
||||
```
|
||||
|
||||
**Check logs:**
|
||||
```bash
|
||||
# Docker
|
||||
docker-compose logs -f
|
||||
|
||||
# Python
|
||||
tail -f logs/wled_controller.log
|
||||
```
|
||||
|
||||
### Home Assistant Integration Not Appearing
|
||||
|
||||
1. Check HACS installation
|
||||
2. Clear browser cache
|
||||
3. Restart Home Assistant
|
||||
4. Check Home Assistant logs:
|
||||
- Settings → System → Logs
|
||||
- Search for "wled_screen_controller"
|
||||
|
||||
### Can't Connect to Server from Home Assistant
|
||||
|
||||
1. Verify server is running:
|
||||
```bash
|
||||
curl http://YOUR_SERVER_IP:8080/health
|
||||
```
|
||||
|
||||
2. Check firewall rules
|
||||
3. Ensure Home Assistant can reach server IP
|
||||
4. Try http:// not https://
|
||||
|
||||
### WLED Device Not Responding
|
||||
|
||||
1. Check WLED device is powered on
|
||||
2. Verify IP address is correct
|
||||
3. Test WLED directly:
|
||||
```bash
|
||||
curl http://YOUR_WLED_IP/json/info
|
||||
```
|
||||
|
||||
4. Check network connectivity
|
||||
|
||||
### Low FPS / Performance Issues
|
||||
|
||||
1. Reduce target FPS (Settings → Devices)
|
||||
2. Reduce `border_width` in settings
|
||||
3. Check CPU usage on server
|
||||
4. Consider reducing LED count
|
||||
|
||||
---
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Server Environment Variables
|
||||
|
||||
```bash
|
||||
# Docker .env file
|
||||
WLED_SERVER__HOST=0.0.0.0
|
||||
WLED_SERVER__PORT=8080
|
||||
WLED_SERVER__LOG_LEVEL=INFO
|
||||
WLED_PROCESSING__DEFAULT_FPS=30
|
||||
WLED_PROCESSING__BORDER_WIDTH=10
|
||||
```
|
||||
|
||||
### Home Assistant Automation Example
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Auto Start WLED on TV On"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: media_player.living_room_tv
|
||||
to: "on"
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.living_room_tv_processing
|
||||
|
||||
- alias: "Auto Stop WLED on TV Off"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: media_player.living_room_tv
|
||||
to: "off"
|
||||
action:
|
||||
- service: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.living_room_tv_processing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [API Documentation](docs/API.md)
|
||||
- [Calibration Guide](docs/CALIBRATION.md)
|
||||
- [GitHub Issues](https://github.com/yourusername/wled-screen-controller/issues)
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Alexei Dolgolyov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
194
README.md
Normal file
194
README.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# WLED Screen Controller
|
||||
|
||||
Ambient lighting controller that synchronizes WLED devices with your screen content for an immersive viewing experience.
|
||||
|
||||
## Overview
|
||||
|
||||
This project consists of two components:
|
||||
|
||||
1. **Python Server** - Captures screen border pixels and sends color data to WLED devices via REST API
|
||||
2. **Home Assistant Integration** - Controls and monitors the server from Home Assistant OS
|
||||
|
||||
## Features
|
||||
|
||||
- 🖥️ **Multi-Monitor Support** - Select which display to capture
|
||||
- ⚡ **Configurable FPS** - Adjust update rate (1-60 FPS)
|
||||
- 🎨 **Smart Calibration** - Map screen edges to LED positions
|
||||
- 🔌 **REST API** - Full control via HTTP endpoints
|
||||
- 🏠 **Home Assistant Integration** - Native HAOS support with entities
|
||||
- 🐳 **Docker Support** - Easy deployment with Docker Compose
|
||||
- 📊 **Real-time Metrics** - Monitor FPS, status, and performance
|
||||
|
||||
## Requirements
|
||||
|
||||
### Server
|
||||
- Python 3.11 or higher
|
||||
- Windows, Linux, or macOS
|
||||
- WLED device on the same network
|
||||
|
||||
### Home Assistant Integration
|
||||
- Home Assistant OS 2023.1 or higher
|
||||
- Running WLED Screen Controller server
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Server Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/wled-screen-controller.git
|
||||
cd wled-screen-controller/server
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Run the server**
|
||||
```bash
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
4. **Access the API**
|
||||
- API: http://localhost:8080
|
||||
- Interactive docs: http://localhost:8080/docs
|
||||
|
||||
### Docker Installation
|
||||
|
||||
```bash
|
||||
cd server
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `server/config/default_config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
|
||||
processing:
|
||||
default_fps: 30
|
||||
border_width: 10
|
||||
|
||||
wled:
|
||||
timeout: 5
|
||||
retry_attempts: 3
|
||||
```
|
||||
|
||||
## API Usage
|
||||
|
||||
### Attach a WLED Device
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/devices \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Living Room TV",
|
||||
"url": "http://192.168.1.100",
|
||||
"led_count": 150
|
||||
}'
|
||||
```
|
||||
|
||||
### Start Processing
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
|
||||
```
|
||||
|
||||
### Get Status
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/devices/{device_id}/state
|
||||
```
|
||||
|
||||
See [API Documentation](docs/API.md) for complete API reference.
|
||||
|
||||
## Calibration
|
||||
|
||||
The calibration system maps screen border pixels to LED positions. See [Calibration Guide](docs/CALIBRATION.md) for details.
|
||||
|
||||
Example calibration:
|
||||
```json
|
||||
{
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [
|
||||
{"edge": "bottom", "led_start": 0, "led_count": 40},
|
||||
{"edge": "right", "led_start": 40, "led_count": 30},
|
||||
{"edge": "top", "led_start": 70, "led_count": 40},
|
||||
{"edge": "left", "led_start": 110, "led_count": 40}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Home Assistant Integration
|
||||
|
||||
1. Copy `homeassistant/custom_components/wled_screen_controller` to your Home Assistant `custom_components` folder
|
||||
2. Restart Home Assistant
|
||||
3. Go to Settings → Integrations → Add Integration
|
||||
4. Search for "WLED Screen Controller"
|
||||
5. Enter your server URL
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
wled-screen-controller/
|
||||
├── server/ # Python FastAPI server
|
||||
│ ├── src/wled_controller/ # Main application code
|
||||
│ ├── tests/ # Unit and integration tests
|
||||
│ ├── config/ # Configuration files
|
||||
│ └── requirements.txt # Python dependencies
|
||||
├── homeassistant/ # Home Assistant integration
|
||||
│ └── custom_components/
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Screen capture fails
|
||||
- **Windows**: Ensure Python has screen capture permissions
|
||||
- **Linux**: Install X11 dependencies: `apt-get install libxcb1 libxcb-randr0`
|
||||
- **macOS**: Grant screen recording permission in System Preferences
|
||||
|
||||
### WLED not responding
|
||||
- Verify WLED device is on the same network
|
||||
- Check firewall settings
|
||||
- Test connection: `curl http://YOUR_WLED_IP/json/info`
|
||||
|
||||
### Low FPS
|
||||
- Reduce `border_width` in configuration
|
||||
- Lower target FPS
|
||||
- Check network latency to WLED device
|
||||
- Reduce LED count
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! Please open an issue or pull request.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [WLED](https://github.com/Aircoookie/WLED) - Amazing LED control software
|
||||
- [FastAPI](https://fastapi.tiangolo.com/) - Modern Python web framework
|
||||
- [mss](https://python-mss.readthedocs.io/) - Fast screen capture library
|
||||
|
||||
## Support
|
||||
|
||||
- GitHub Issues: [Report a bug](https://github.com/yourusername/wled-screen-controller/issues)
|
||||
- Discussions: [Ask a question](https://github.com/yourusername/wled-screen-controller/discussions)
|
||||
214
custom_components/wled_screen_controller/README.md
Normal file
214
custom_components/wled_screen_controller/README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# WLED Screen Controller - Home Assistant Integration
|
||||
|
||||
Native Home Assistant integration for WLED Screen Controller with full HACS support.
|
||||
|
||||
## Overview
|
||||
|
||||
This integration connects Home Assistant to the WLED Screen Controller server, providing:
|
||||
|
||||
- 🎛️ **Switch Entities** - Turn processing on/off per device
|
||||
- 📊 **Sensor Entities** - Monitor FPS, status, and frame count
|
||||
- 🖥️ **Select Entities** - Choose which display to capture
|
||||
- 🔄 **Auto-Discovery** - Devices appear automatically
|
||||
- 📦 **HACS Compatible** - Install directly from HACS
|
||||
- ⚙️ **Config Flow** - Easy setup through UI
|
||||
|
||||
## Installation
|
||||
|
||||
### Method 1: HACS (Recommended)
|
||||
|
||||
1. **Install HACS** if you haven't already:
|
||||
- Visit https://hacs.xyz/docs/setup/download
|
||||
|
||||
2. **Add Custom Repository:**
|
||||
- Open HACS in Home Assistant
|
||||
- Click the menu (⋮) → Custom repositories
|
||||
- Add URL: `https://github.com/yourusername/wled-screen-controller`
|
||||
- Category: **Integration**
|
||||
- Click **Add**
|
||||
|
||||
3. **Install Integration:**
|
||||
- In HACS, search for "WLED Screen Controller"
|
||||
- Click **Download**
|
||||
- Restart Home Assistant
|
||||
|
||||
4. **Configure:**
|
||||
- Go to Settings → Devices & Services
|
||||
- Click **+ Add Integration**
|
||||
- Search for "WLED Screen Controller"
|
||||
- Enter your server URL (e.g., `http://192.168.1.100:8080`)
|
||||
- Click **Submit**
|
||||
|
||||
### Method 2: Manual Installation
|
||||
|
||||
1. **Download:**
|
||||
```bash
|
||||
cd /config # Your Home Assistant config directory
|
||||
mkdir -p custom_components
|
||||
```
|
||||
|
||||
2. **Copy Files:**
|
||||
Copy the entire `custom_components/wled_screen_controller` folder to your Home Assistant `custom_components/` directory.
|
||||
|
||||
3. **Restart Home Assistant**
|
||||
|
||||
4. **Configure:**
|
||||
- Settings → Devices & Services → Add Integration
|
||||
- Search for "WLED Screen Controller"
|
||||
|
||||
## Configuration
|
||||
|
||||
### Initial Setup
|
||||
|
||||
When adding the integration, you'll be prompted for:
|
||||
|
||||
- **Name**: Friendly name for the integration (default: "WLED Screen Controller")
|
||||
- **Server URL**: URL of your WLED Screen Controller server (e.g., `http://192.168.1.100:8080`)
|
||||
|
||||
The integration will automatically:
|
||||
- Verify connection to the server
|
||||
- Discover all configured WLED devices
|
||||
- Create entities for each device
|
||||
|
||||
### Entities Created
|
||||
|
||||
For each WLED device, the following entities are created:
|
||||
|
||||
#### Switch Entities
|
||||
|
||||
**`switch.{device_name}_processing`**
|
||||
- Controls processing on/off for the device
|
||||
- Attributes:
|
||||
- `device_id`: Internal device ID
|
||||
- `fps_target`: Target FPS
|
||||
- `fps_actual`: Current FPS
|
||||
- `display_index`: Active display
|
||||
- `frames_processed`: Total frames
|
||||
- `errors_count`: Error count
|
||||
- `uptime_seconds`: Processing uptime
|
||||
|
||||
#### Sensor Entities
|
||||
|
||||
**`sensor.{device_name}_fps`**
|
||||
- Current FPS value
|
||||
- Unit: FPS
|
||||
- Attributes:
|
||||
- `target_fps`: Target FPS setting
|
||||
|
||||
**`sensor.{device_name}_status`**
|
||||
- Processing status
|
||||
- States: `processing`, `idle`, `unavailable`, `unknown`
|
||||
|
||||
**`sensor.{device_name}_frames_processed`**
|
||||
- Total frames processed counter
|
||||
- Continuously increasing while processing
|
||||
|
||||
#### Select Entities
|
||||
|
||||
**`select.{device_name}_display`**
|
||||
- Select which display to capture
|
||||
- Options: `Display 0`, `Display 1`, etc.
|
||||
- Changes take effect immediately
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Automation
|
||||
|
||||
Turn on processing when TV turns on:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Auto Start WLED with TV"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: media_player.living_room_tv
|
||||
to: "on"
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.living_room_wled_processing
|
||||
|
||||
- alias: "Auto Stop WLED with TV"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: media_player.living_room_tv
|
||||
to: "off"
|
||||
action:
|
||||
- service: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.living_room_wled_processing
|
||||
```
|
||||
|
||||
### Lovelace UI Examples
|
||||
|
||||
#### Simple Card
|
||||
|
||||
```yaml
|
||||
type: entities
|
||||
title: WLED Screen Controller
|
||||
entities:
|
||||
- entity: switch.living_room_wled_processing
|
||||
- entity: sensor.living_room_wled_fps
|
||||
- entity: sensor.living_room_wled_status
|
||||
- entity: select.living_room_wled_display
|
||||
```
|
||||
|
||||
#### Advanced Card
|
||||
|
||||
```yaml
|
||||
type: vertical-stack
|
||||
cards:
|
||||
- type: entity
|
||||
entity: switch.living_room_wled_processing
|
||||
name: Ambient Lighting
|
||||
icon: mdi:television-ambient-light
|
||||
|
||||
- type: conditional
|
||||
conditions:
|
||||
- entity: switch.living_room_wled_processing
|
||||
state: "on"
|
||||
card:
|
||||
type: entities
|
||||
entities:
|
||||
- entity: sensor.living_room_wled_fps
|
||||
name: Current FPS
|
||||
- entity: sensor.living_room_wled_frames_processed
|
||||
name: Frames Processed
|
||||
- entity: select.living_room_wled_display
|
||||
name: Display Selection
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Integration Not Appearing
|
||||
|
||||
1. Check HACS installation
|
||||
2. Clear browser cache
|
||||
3. Restart Home Assistant
|
||||
4. Check logs: Settings → System → Logs
|
||||
|
||||
### Connection Errors
|
||||
|
||||
1. Verify server is running:
|
||||
```bash
|
||||
curl http://YOUR_SERVER_IP:8080/health
|
||||
```
|
||||
|
||||
2. Check firewall settings
|
||||
3. Ensure Home Assistant can reach server
|
||||
4. Try http:// not https://
|
||||
|
||||
### Entities Not Updating
|
||||
|
||||
1. Check coordinator logs
|
||||
2. Verify server has devices
|
||||
3. Restart integration
|
||||
|
||||
## Support
|
||||
|
||||
- 📖 [Full Documentation](../../INSTALLATION.md)
|
||||
- 🐛 [Report Issues](https://github.com/yourusername/wled-screen-controller/issues)
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [../../LICENSE](../../LICENSE)
|
||||
100
custom_components/wled_screen_controller/__init__.py
Normal file
100
custom_components/wled_screen_controller/__init__.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""The WLED Screen Controller integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, CONF_SERVER_URL, DEFAULT_SCAN_INTERVAL
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.SWITCH,
|
||||
Platform.SENSOR,
|
||||
Platform.SELECT,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up WLED Screen Controller from a config entry."""
|
||||
server_url = entry.data[CONF_SERVER_URL]
|
||||
server_name = entry.data.get(CONF_NAME, "WLED Screen Controller")
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
coordinator = WLEDScreenControllerCoordinator(
|
||||
hass,
|
||||
session,
|
||||
server_url,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
)
|
||||
|
||||
# Fetch initial data
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Create hub device (the server PC)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Parse URL for hub identifier
|
||||
parsed_url = urlparse(server_url)
|
||||
hub_identifier = f"{parsed_url.hostname}:{parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)}"
|
||||
|
||||
hub_device = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, hub_identifier)},
|
||||
name=server_name,
|
||||
manufacturer="WLED Screen Controller",
|
||||
model="Server",
|
||||
sw_version=coordinator.server_version,
|
||||
configuration_url=server_url,
|
||||
)
|
||||
|
||||
# Create device entries for each WLED device
|
||||
if coordinator.data and "devices" in coordinator.data:
|
||||
for device_id, device_data in coordinator.data["devices"].items():
|
||||
device_info = device_data["info"]
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=device_info["name"],
|
||||
manufacturer="WLED",
|
||||
model="Screen Ambient Lighting",
|
||||
sw_version=f"{device_info.get('led_count', 0)} LEDs",
|
||||
via_device=(DOMAIN, hub_identifier), # Link to hub
|
||||
configuration_url=device_info.get("url"),
|
||||
)
|
||||
|
||||
# Store coordinator and hub info
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
"coordinator": coordinator,
|
||||
"hub_device_id": hub_device.id,
|
||||
}
|
||||
|
||||
# Setup platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry."""
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_setup_entry(hass, entry)
|
||||
111
custom_components/wled_screen_controller/config_flow.py
Normal file
111
custom_components/wled_screen_controller/config_flow.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Config flow for WLED Screen Controller integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, CONF_SERVER_URL, DEFAULT_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default="WLED Screen Controller"): str,
|
||||
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
"""Normalize URL to ensure port is an integer."""
|
||||
parsed = urlparse(url)
|
||||
|
||||
# If port is specified, ensure it's an integer
|
||||
if parsed.port is not None:
|
||||
# Reconstruct URL with integer port
|
||||
netloc = parsed.hostname or "localhost"
|
||||
port = int(parsed.port) # Cast to int to avoid float
|
||||
if port != (443 if parsed.scheme == "https" else 80):
|
||||
netloc = f"{netloc}:{port}"
|
||||
|
||||
parsed = parsed._replace(netloc=netloc)
|
||||
|
||||
return urlunparse(parsed)
|
||||
|
||||
|
||||
async def validate_server_connection(
|
||||
hass: HomeAssistant, server_url: str
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the server URL by checking the health endpoint."""
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
try:
|
||||
async with session.get(
|
||||
f"{server_url}/health",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return {
|
||||
"version": data.get("version", "unknown"),
|
||||
"status": data.get("status", "unknown"),
|
||||
}
|
||||
raise ConnectionError(f"Server returned status {response.status}")
|
||||
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConnectionError(f"Cannot connect to server: {err}")
|
||||
except Exception as err:
|
||||
raise ConnectionError(f"Unexpected error: {err}")
|
||||
|
||||
|
||||
class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for WLED Screen Controller."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
|
||||
|
||||
try:
|
||||
info = await validate_server_connection(self.hass, server_url)
|
||||
|
||||
# Set unique ID based on server URL
|
||||
await self.async_set_unique_id(server_url)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME],
|
||||
data={
|
||||
CONF_SERVER_URL: server_url,
|
||||
"version": info["version"],
|
||||
},
|
||||
)
|
||||
|
||||
except ConnectionError as err:
|
||||
_LOGGER.error("Connection error: %s", err)
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception: %s", err)
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
23
custom_components/wled_screen_controller/const.py
Normal file
23
custom_components/wled_screen_controller/const.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Constants for the WLED Screen Controller integration."""
|
||||
|
||||
DOMAIN = "wled_screen_controller"
|
||||
|
||||
# Configuration
|
||||
CONF_SERVER_URL = "server_url"
|
||||
|
||||
# Default values
|
||||
DEFAULT_SCAN_INTERVAL = 10 # seconds
|
||||
DEFAULT_TIMEOUT = 10 # seconds
|
||||
|
||||
# Attributes
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
ATTR_FPS_ACTUAL = "fps_actual"
|
||||
ATTR_FPS_TARGET = "fps_target"
|
||||
ATTR_DISPLAY_INDEX = "display_index"
|
||||
ATTR_FRAMES_PROCESSED = "frames_processed"
|
||||
ATTR_ERRORS_COUNT = "errors_count"
|
||||
ATTR_UPTIME = "uptime_seconds"
|
||||
|
||||
# Services
|
||||
SERVICE_START_PROCESSING = "start_processing"
|
||||
SERVICE_STOP_PROCESSING = "stop_processing"
|
||||
179
custom_components/wled_screen_controller/coordinator.py
Normal file
179
custom_components/wled_screen_controller/coordinator.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Data update coordinator for WLED Screen Controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, DEFAULT_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
"""Class to manage fetching WLED Screen Controller data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
session: aiohttp.ClientSession,
|
||||
server_url: str,
|
||||
update_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.server_url = server_url
|
||||
self.session = session
|
||||
self.server_version = "unknown"
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
async with asyncio.timeout(DEFAULT_TIMEOUT):
|
||||
# Fetch server version on first update
|
||||
if self.server_version == "unknown":
|
||||
await self._fetch_server_version()
|
||||
|
||||
# Fetch devices list
|
||||
devices = await self._fetch_devices()
|
||||
|
||||
# Fetch state for each device
|
||||
devices_data = {}
|
||||
for device in devices:
|
||||
device_id = device["id"]
|
||||
try:
|
||||
state = await self._fetch_device_state(device_id)
|
||||
metrics = await self._fetch_device_metrics(device_id)
|
||||
|
||||
devices_data[device_id] = {
|
||||
"info": device,
|
||||
"state": state,
|
||||
"metrics": metrics,
|
||||
}
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch data for device %s: %s", device_id, err
|
||||
)
|
||||
# Include device info even if state fetch fails
|
||||
devices_data[device_id] = {
|
||||
"info": device,
|
||||
"state": None,
|
||||
"metrics": None,
|
||||
}
|
||||
|
||||
# Fetch available displays
|
||||
displays = await self._fetch_displays()
|
||||
|
||||
return {
|
||||
"devices": devices_data,
|
||||
"displays": displays,
|
||||
}
|
||||
|
||||
except asyncio.TimeoutError as err:
|
||||
raise UpdateFailed(f"Timeout fetching data: {err}") from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
async def _fetch_server_version(self) -> None:
|
||||
"""Fetch server version from health endpoint."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/health",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
self.server_version = data.get("version", "unknown")
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to fetch server version: %s", err)
|
||||
self.server_version = "unknown"
|
||||
|
||||
async def _fetch_devices(self) -> list[dict[str, Any]]:
|
||||
"""Fetch devices list."""
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
return data.get("devices", [])
|
||||
|
||||
async def _fetch_device_state(self, device_id: str) -> dict[str, Any]:
|
||||
"""Fetch device processing state."""
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/state",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
|
||||
async def _fetch_device_metrics(self, device_id: str) -> dict[str, Any]:
|
||||
"""Fetch device metrics."""
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/metrics",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
|
||||
async def _fetch_displays(self) -> list[dict[str, Any]]:
|
||||
"""Fetch available displays."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/config/displays",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
return data.get("displays", [])
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to fetch displays: %s", err)
|
||||
return []
|
||||
|
||||
async def start_processing(self, device_id: str) -> None:
|
||||
"""Start processing for a device."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/start",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Refresh data immediately
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def stop_processing(self, device_id: str) -> None:
|
||||
"""Stop processing for a device."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/stop",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Refresh data immediately
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def update_settings(
|
||||
self, device_id: str, settings: dict[str, Any]
|
||||
) -> None:
|
||||
"""Update device settings."""
|
||||
async with self.session.put(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/settings",
|
||||
json=settings,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Refresh data immediately
|
||||
await self.async_request_refresh()
|
||||
12
custom_components/wled_screen_controller/manifest.json
Normal file
12
custom_components/wled_screen_controller/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "wled_screen_controller",
|
||||
"name": "WLED Screen Controller",
|
||||
"codeowners": ["@alexeidolgolyov"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/yourusername/wled-screen-controller",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/yourusername/wled-screen-controller/issues",
|
||||
"requirements": ["aiohttp>=3.9.0"],
|
||||
"version": "0.1.0"
|
||||
}
|
||||
117
custom_components/wled_screen_controller/select.py
Normal file
117
custom_components/wled_screen_controller/select.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Select platform for WLED Screen Controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up WLED Screen Controller select entities."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data["coordinator"]
|
||||
|
||||
entities = []
|
||||
if coordinator.data and "devices" in coordinator.data:
|
||||
for device_id, device_data in coordinator.data["devices"].items():
|
||||
device_info = device_data["info"]
|
||||
entities.append(
|
||||
WLEDScreenControllerDisplaySelect(
|
||||
coordinator, device_id, device_info, entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class WLEDScreenControllerDisplaySelect(CoordinatorEntity, SelectEntity):
|
||||
"""Display selection for WLED Screen Controller."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_icon = "mdi:monitor-multiple"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
device_id: str,
|
||||
device_info: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the select."""
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self._device_info = device_info
|
||||
self._entry_id = entry_id
|
||||
|
||||
self._attr_unique_id = f"{device_id}_display"
|
||||
self._attr_name = "Display"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device_id)},
|
||||
}
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return available display options."""
|
||||
if not self.coordinator.data or "displays" not in self.coordinator.data:
|
||||
return ["Display 0"]
|
||||
|
||||
displays = self.coordinator.data["displays"]
|
||||
return [f"Display {d['index']}" for d in displays]
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return current display."""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data or not device_data.get("state"):
|
||||
return None
|
||||
|
||||
display_index = device_data["state"].get("display_index", 0)
|
||||
return f"Display {display_index}"
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected display."""
|
||||
try:
|
||||
# Extract display index from option (e.g., "Display 1" -> 1)
|
||||
display_index = int(option.split()[-1])
|
||||
|
||||
# Get current settings
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data:
|
||||
return
|
||||
|
||||
info = device_data["info"]
|
||||
settings = info.get("settings", {})
|
||||
|
||||
# Update settings with new display index
|
||||
updated_settings = {
|
||||
"display_index": display_index,
|
||||
"fps": settings.get("fps", 30),
|
||||
"border_width": settings.get("border_width", 10),
|
||||
}
|
||||
|
||||
await self.coordinator.update_settings(self._device_id, updated_settings)
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.error("Failed to update display: %s", err)
|
||||
raise
|
||||
205
custom_components/wled_screen_controller/sensor.py
Normal file
205
custom_components/wled_screen_controller/sensor.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Sensor platform for WLED Screen Controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up WLED Screen Controller sensors."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data["coordinator"]
|
||||
|
||||
entities = []
|
||||
if coordinator.data and "devices" in coordinator.data:
|
||||
for device_id, device_data in coordinator.data["devices"].items():
|
||||
device_info = device_data["info"]
|
||||
|
||||
# FPS sensor
|
||||
entities.append(
|
||||
WLEDScreenControllerFPSSensor(
|
||||
coordinator, device_id, device_info, entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
# Status sensor
|
||||
entities.append(
|
||||
WLEDScreenControllerStatusSensor(
|
||||
coordinator, device_id, device_info, entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
# Frames processed sensor
|
||||
entities.append(
|
||||
WLEDScreenControllerFramesSensor(
|
||||
coordinator, device_id, device_info, entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class WLEDScreenControllerFPSSensor(CoordinatorEntity, SensorEntity):
|
||||
"""FPS sensor for WLED Screen Controller."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = "FPS"
|
||||
_attr_icon = "mdi:speedometer"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
device_id: str,
|
||||
device_info: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self._device_info = device_info
|
||||
self._entry_id = entry_id
|
||||
|
||||
self._attr_unique_id = f"{device_id}_fps"
|
||||
self._attr_name = "FPS"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device_id)},
|
||||
}
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the FPS value."""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data or not device_data.get("state"):
|
||||
return None
|
||||
|
||||
return device_data["state"].get("fps_actual")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional attributes."""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data or not device_data.get("state"):
|
||||
return {}
|
||||
|
||||
return {
|
||||
"target_fps": device_data["state"].get("fps_target"),
|
||||
}
|
||||
|
||||
|
||||
class WLEDScreenControllerStatusSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Status sensor for WLED Screen Controller."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_icon = "mdi:information-outline"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
device_id: str,
|
||||
device_info: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self._device_info = device_info
|
||||
self._entry_id = entry_id
|
||||
|
||||
self._attr_unique_id = f"{device_id}_status"
|
||||
self._attr_name = "Status"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device_id)},
|
||||
}
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
"""Return the status."""
|
||||
if not self.coordinator.data:
|
||||
return "unknown"
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data:
|
||||
return "unavailable"
|
||||
|
||||
if device_data.get("state") and device_data["state"].get("processing"):
|
||||
return "processing"
|
||||
|
||||
return "idle"
|
||||
|
||||
|
||||
class WLEDScreenControllerFramesSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Frames processed sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
_attr_icon = "mdi:counter"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
device_id: str,
|
||||
device_info: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self._device_info = device_info
|
||||
self._entry_id = entry_id
|
||||
|
||||
self._attr_unique_id = f"{device_id}_frames"
|
||||
self._attr_name = "Frames Processed"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device_id)},
|
||||
}
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return frames processed."""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data or not device_data.get("metrics"):
|
||||
return None
|
||||
|
||||
return device_data["metrics"].get("frames_processed", 0)
|
||||
21
custom_components/wled_screen_controller/strings.json
Normal file
21
custom_components/wled_screen_controller/strings.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up WLED Screen Controller",
|
||||
"description": "Enter the URL of your WLED Screen Controller server",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"server_url": "Server URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to server. Check the URL and ensure the server is running.",
|
||||
"unknown": "Unexpected error occurred"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This server is already configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
133
custom_components/wled_screen_controller/switch.py
Normal file
133
custom_components/wled_screen_controller/switch.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Switch platform for WLED Screen Controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, ATTR_DEVICE_ID
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up WLED Screen Controller switches."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data["coordinator"]
|
||||
|
||||
entities = []
|
||||
if coordinator.data and "devices" in coordinator.data:
|
||||
for device_id, device_data in coordinator.data["devices"].items():
|
||||
entities.append(
|
||||
WLEDScreenControllerSwitch(
|
||||
coordinator, device_id, device_data["info"], entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class WLEDScreenControllerSwitch(CoordinatorEntity, SwitchEntity):
|
||||
"""Representation of a WLED Screen Controller processing switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
device_id: str,
|
||||
device_info: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self._device_info = device_info
|
||||
self._entry_id = entry_id
|
||||
|
||||
self._attr_unique_id = f"{device_id}_processing"
|
||||
self._attr_name = "Processing"
|
||||
self._attr_icon = "mdi:television-ambient-light"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device_id)},
|
||||
}
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if processing is active."""
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data or not device_data.get("state"):
|
||||
return False
|
||||
|
||||
return device_data["state"].get("processing", False)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
return device_data is not None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional state attributes."""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
device_data = self.coordinator.data["devices"].get(self._device_id)
|
||||
if not device_data:
|
||||
return {}
|
||||
|
||||
state = device_data.get("state", {})
|
||||
metrics = device_data.get("metrics", {})
|
||||
|
||||
attrs = {
|
||||
ATTR_DEVICE_ID: self._device_id,
|
||||
}
|
||||
|
||||
if state:
|
||||
attrs["fps_target"] = state.get("fps_target")
|
||||
attrs["fps_actual"] = state.get("fps_actual")
|
||||
attrs["display_index"] = state.get("display_index")
|
||||
|
||||
if metrics:
|
||||
attrs["frames_processed"] = metrics.get("frames_processed")
|
||||
attrs["errors_count"] = metrics.get("errors_count")
|
||||
attrs["uptime_seconds"] = metrics.get("uptime_seconds")
|
||||
|
||||
return attrs
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on processing."""
|
||||
try:
|
||||
await self.coordinator.start_processing(self._device_id)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Failed to start processing: %s", err)
|
||||
raise
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off processing."""
|
||||
try:
|
||||
await self.coordinator.stop_processing(self._device_id)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Failed to stop processing: %s", err)
|
||||
raise
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up WLED Screen Controller",
|
||||
"description": "Enter the URL of your WLED Screen Controller server",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"server_url": "Server URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to server. Check the URL and ensure the server is running.",
|
||||
"unknown": "Unexpected error occurred"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This server is already configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
341
docs/API.md
Normal file
341
docs/API.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# WLED Screen Controller API Documentation
|
||||
|
||||
Complete REST API reference for the WLED Screen Controller server.
|
||||
|
||||
**Base URL:** `http://localhost:8080`
|
||||
**API Version:** v1
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Health & Info](#health--info)
|
||||
- [Device Management](#device-management)
|
||||
- [Processing Control](#processing-control)
|
||||
- [Settings Management](#settings-management)
|
||||
- [Calibration](#calibration)
|
||||
- [Metrics](#metrics)
|
||||
|
||||
---
|
||||
|
||||
## Health & Info
|
||||
|
||||
### GET /health
|
||||
|
||||
Health check endpoint.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2026-02-06T12:00:00Z",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/version
|
||||
|
||||
Get version information.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"python_version": "3.11.0",
|
||||
"api_version": "v1"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/config/displays
|
||||
|
||||
List available displays for screen capture.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"displays": [
|
||||
{
|
||||
"index": 0,
|
||||
"name": "Display 1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"is_primary": true
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Device Management
|
||||
|
||||
### POST /api/v1/devices
|
||||
|
||||
Create and attach a new WLED device.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Living Room TV",
|
||||
"url": "http://192.168.1.100",
|
||||
"led_count": 150
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"id": "device_abc123",
|
||||
"name": "Living Room TV",
|
||||
"url": "http://192.168.1.100",
|
||||
"led_count": 150,
|
||||
"enabled": true,
|
||||
"status": "disconnected",
|
||||
"settings": {
|
||||
"display_index": 0,
|
||||
"fps": 30,
|
||||
"border_width": 10
|
||||
},
|
||||
"calibration": {
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [...]
|
||||
},
|
||||
"created_at": "2026-02-06T12:00:00Z",
|
||||
"updated_at": "2026-02-06T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/devices
|
||||
|
||||
List all attached devices.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"devices": [...],
|
||||
"count": 2
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/devices/{device_id}
|
||||
|
||||
Get device details.
|
||||
|
||||
**Response:** Same as POST response
|
||||
|
||||
### PUT /api/v1/devices/{device_id}
|
||||
|
||||
Update device information.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/v1/devices/{device_id}
|
||||
|
||||
Delete/detach a device.
|
||||
|
||||
**Response:** `204 No Content`
|
||||
|
||||
---
|
||||
|
||||
## Processing Control
|
||||
|
||||
### POST /api/v1/devices/{device_id}/start
|
||||
|
||||
Start screen processing for a device.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "started",
|
||||
"device_id": "device_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/v1/devices/{device_id}/stop
|
||||
|
||||
Stop screen processing.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "stopped",
|
||||
"device_id": "device_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/devices/{device_id}/state
|
||||
|
||||
Get current processing state.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"device_id": "device_abc123",
|
||||
"processing": true,
|
||||
"fps_actual": 29.8,
|
||||
"fps_target": 30,
|
||||
"display_index": 0,
|
||||
"last_update": "2026-02-06T12:00:00Z",
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings Management
|
||||
|
||||
### GET /api/v1/devices/{device_id}/settings
|
||||
|
||||
Get processing settings.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"display_index": 0,
|
||||
"fps": 30,
|
||||
"border_width": 10,
|
||||
"color_correction": {
|
||||
"gamma": 2.2,
|
||||
"saturation": 1.0,
|
||||
"brightness": 1.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /api/v1/devices/{device_id}/settings
|
||||
|
||||
Update processing settings.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"display_index": 1,
|
||||
"fps": 60,
|
||||
"border_width": 15,
|
||||
"color_correction": {
|
||||
"gamma": 2.4,
|
||||
"saturation": 1.2,
|
||||
"brightness": 0.8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Calibration
|
||||
|
||||
### GET /api/v1/devices/{device_id}/calibration
|
||||
|
||||
Get calibration configuration.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [
|
||||
{
|
||||
"edge": "bottom",
|
||||
"led_start": 0,
|
||||
"led_count": 40,
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"edge": "right",
|
||||
"led_start": 40,
|
||||
"led_count": 30,
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"edge": "top",
|
||||
"led_start": 70,
|
||||
"led_count": 40,
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"edge": "left",
|
||||
"led_start": 110,
|
||||
"led_count": 40,
|
||||
"reverse": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /api/v1/devices/{device_id}/calibration
|
||||
|
||||
Update calibration.
|
||||
|
||||
**Request:** Same as GET response
|
||||
|
||||
### POST /api/v1/devices/{device_id}/calibration/test
|
||||
|
||||
Test calibration by lighting up specific edge.
|
||||
|
||||
**Query Parameters:**
|
||||
- `edge`: Edge to test (top, right, bottom, left)
|
||||
- `color`: RGB color array (e.g., [255, 0, 0])
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
### GET /api/v1/devices/{device_id}/metrics
|
||||
|
||||
Get detailed processing metrics.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"device_id": "device_abc123",
|
||||
"processing": true,
|
||||
"fps_actual": 29.8,
|
||||
"fps_target": 30,
|
||||
"uptime_seconds": 3600.5,
|
||||
"frames_processed": 107415,
|
||||
"errors_count": 2,
|
||||
"last_error": null,
|
||||
"last_update": "2026-02-06T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints may return error responses in this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "ErrorType",
|
||||
"message": "Human-readable error message",
|
||||
"detail": {...},
|
||||
"timestamp": "2026-02-06T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Common HTTP Status Codes:**
|
||||
- `200 OK` - Success
|
||||
- `201 Created` - Resource created
|
||||
- `204 No Content` - Success with no response body
|
||||
- `400 Bad Request` - Invalid request
|
||||
- `404 Not Found` - Resource not found
|
||||
- `500 Internal Server Error` - Server error
|
||||
|
||||
---
|
||||
|
||||
## Interactive Documentation
|
||||
|
||||
The server provides interactive API documentation:
|
||||
|
||||
- **Swagger UI:** http://localhost:8080/docs
|
||||
- **ReDoc:** http://localhost:8080/redoc
|
||||
- **OpenAPI JSON:** http://localhost:8080/openapi.json
|
||||
277
docs/CALIBRATION.md
Normal file
277
docs/CALIBRATION.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Calibration Guide
|
||||
|
||||
This guide explains how to calibrate your WLED strip to match your screen layout.
|
||||
|
||||
## Overview
|
||||
|
||||
Calibration maps screen border pixels to LED positions on your WLED strip. Proper calibration ensures that the colors on your LEDs accurately reflect what's on your screen edges.
|
||||
|
||||
## Understanding LED Layout
|
||||
|
||||
### Physical Setup
|
||||
|
||||
Most WLED ambient lighting setups have LEDs arranged around a TV/monitor:
|
||||
|
||||
```
|
||||
TOP (40 LEDs)
|
||||
┌─────────────────┐
|
||||
│ │
|
||||
LEFT│ │RIGHT
|
||||
(40)│ │(30)
|
||||
│ │
|
||||
└─────────────────┘
|
||||
BOTTOM (40 LEDs)
|
||||
```
|
||||
|
||||
### LED Numbering
|
||||
|
||||
WLED strips are numbered sequentially. You need to know:
|
||||
|
||||
1. **Starting Position:** Where is LED #0?
|
||||
2. **Direction:** Clockwise or counterclockwise?
|
||||
3. **LEDs per Edge:** How many LEDs on each side?
|
||||
|
||||
## Default Calibration
|
||||
|
||||
When you attach a device, a default calibration is created:
|
||||
|
||||
- **Layout:** Clockwise
|
||||
- **Start Position:** Bottom-left corner
|
||||
- **LED Distribution:** Evenly distributed across 4 edges
|
||||
|
||||
### Example (150 LEDs):
|
||||
|
||||
```json
|
||||
{
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [
|
||||
{"edge": "bottom", "led_start": 0, "led_count": 38},
|
||||
{"edge": "right", "led_start": 38, "led_count": 37},
|
||||
{"edge": "top", "led_start": 75, "led_count": 38, "reverse": true},
|
||||
{"edge": "left", "led_start": 113, "led_count": 37, "reverse": true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Calibration
|
||||
|
||||
### Step 1: Identify Your LED Layout
|
||||
|
||||
1. Turn on your WLED device
|
||||
2. Note which LED is #0 (first LED)
|
||||
3. Observe the direction LEDs are numbered
|
||||
4. Count LEDs on each edge
|
||||
|
||||
### Step 2: Create Calibration Config
|
||||
|
||||
Create a calibration configuration matching your setup:
|
||||
|
||||
```json
|
||||
{
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [
|
||||
{
|
||||
"edge": "bottom",
|
||||
"led_start": 0,
|
||||
"led_count": 50,
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"edge": "right",
|
||||
"led_start": 50,
|
||||
"led_count": 30,
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"edge": "top",
|
||||
"led_start": 80,
|
||||
"led_count": 50,
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"edge": "left",
|
||||
"led_start": 130,
|
||||
"led_count": 30,
|
||||
"reverse": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Apply Calibration
|
||||
|
||||
Update via API:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:8080/api/v1/devices/{device_id}/calibration \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @calibration.json
|
||||
```
|
||||
|
||||
### Step 4: Test Calibration
|
||||
|
||||
Test each edge to verify:
|
||||
|
||||
```bash
|
||||
# Test top edge (should light up top LEDs)
|
||||
curl -X POST "http://localhost:8080/api/v1/devices/{device_id}/calibration/test?edge=top&color=[255,0,0]"
|
||||
|
||||
# Test right edge
|
||||
curl -X POST "http://localhost:8080/api/v1/devices/{device_id}/calibration/test?edge=right&color=[0,255,0]"
|
||||
|
||||
# Test bottom edge
|
||||
curl -X POST "http://localhost:8080/api/v1/devices/{device_id}/calibration/test?edge=bottom&color=[0,0,255]"
|
||||
|
||||
# Test left edge
|
||||
curl -X POST "http://localhost:8080/api/v1/devices/{device_id}/calibration/test?edge=left&color=[255,255,0]"
|
||||
```
|
||||
|
||||
## Calibration Parameters
|
||||
|
||||
### Layout
|
||||
|
||||
- `clockwise`: LEDs numbered in clockwise direction
|
||||
- `counterclockwise`: LEDs numbered counter-clockwise
|
||||
|
||||
### Start Position
|
||||
|
||||
Where LED #0 is located:
|
||||
|
||||
- `top_left`
|
||||
- `top_right`
|
||||
- `bottom_left` (most common)
|
||||
- `bottom_right`
|
||||
|
||||
### Segments
|
||||
|
||||
Each segment defines one edge of the screen:
|
||||
|
||||
- `edge`: Which screen edge (`top`, `right`, `bottom`, `left`)
|
||||
- `led_start`: First LED index for this edge
|
||||
- `led_count`: Number of LEDs on this edge
|
||||
- `reverse`: Whether to reverse LED order for this edge
|
||||
|
||||
### Reverse Flag
|
||||
|
||||
The `reverse` flag is used when LEDs go the opposite direction from screen pixels:
|
||||
|
||||
- **Top edge:** Usually reversed (LEDs go right-to-left)
|
||||
- **Bottom edge:** Usually not reversed (LEDs go left-to-right)
|
||||
- **Left edge:** Usually reversed (LEDs go bottom-to-top)
|
||||
- **Right edge:** Usually not reversed (LEDs go top-to-bottom)
|
||||
|
||||
## Common Layouts
|
||||
|
||||
### Standard Clockwise (Bottom-Left Start)
|
||||
|
||||
```json
|
||||
{
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [
|
||||
{"edge": "bottom", "led_start": 0, "led_count": 40, "reverse": false},
|
||||
{"edge": "right", "led_start": 40, "led_count": 30, "reverse": false},
|
||||
{"edge": "top", "led_start": 70, "led_count": 40, "reverse": true},
|
||||
{"edge": "left", "led_start": 110, "led_count": 40, "reverse": true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Counter-Clockwise (Top-Left Start)
|
||||
|
||||
```json
|
||||
{
|
||||
"layout": "counterclockwise",
|
||||
"start_position": "top_left",
|
||||
"segments": [
|
||||
{"edge": "top", "led_start": 0, "led_count": 50, "reverse": false},
|
||||
{"edge": "left", "led_start": 50, "led_count": 30, "reverse": false},
|
||||
{"edge": "bottom", "led_start": 80, "led_count": 50, "reverse": true},
|
||||
{"edge": "right", "led_start": 130, "led_count": 30, "reverse": true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Three-Sided Setup (No Top Edge)
|
||||
|
||||
```json
|
||||
{
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [
|
||||
{"edge": "bottom", "led_start": 0, "led_count": 50, "reverse": false},
|
||||
{"edge": "right", "led_start": 50, "led_count": 40, "reverse": false},
|
||||
{"edge": "left", "led_start": 90, "led_count": 40, "reverse": true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Colors Don't Match
|
||||
|
||||
**Problem:** LED colors don't match screen content.
|
||||
|
||||
**Solutions:**
|
||||
1. Verify LED start indices don't overlap
|
||||
2. Check reverse flags for each edge
|
||||
3. Test each edge individually
|
||||
4. Verify total LED count matches device
|
||||
|
||||
### LEDs Light Up Wrong Edge
|
||||
|
||||
**Problem:** Top edge lights up when bottom should.
|
||||
|
||||
**Solutions:**
|
||||
1. Check `led_start` values for each segment
|
||||
2. Verify `layout` (clockwise vs counterclockwise)
|
||||
3. Confirm `start_position` matches your physical setup
|
||||
|
||||
### Corner LEDs Wrong
|
||||
|
||||
**Problem:** Corner LEDs show wrong colors.
|
||||
|
||||
**Solutions:**
|
||||
1. Adjust LED counts per edge
|
||||
2. Ensure segments don't overlap
|
||||
3. Check if corner LEDs should be in adjacent segment
|
||||
|
||||
### Some LEDs Don't Light Up
|
||||
|
||||
**Problem:** Part of the strip stays dark.
|
||||
|
||||
**Solutions:**
|
||||
1. Verify total LEDs in calibration matches device
|
||||
2. Check for gaps in LED indices
|
||||
3. Ensure all edges are defined if LEDs exist there
|
||||
|
||||
## Validation
|
||||
|
||||
The calibration system automatically validates:
|
||||
|
||||
- No duplicate edges
|
||||
- No overlapping LED indices
|
||||
- All LED counts are positive
|
||||
- All start indices are non-negative
|
||||
|
||||
If validation fails, you'll receive an error message explaining the issue.
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Start Simple:** Use default calibration first, then customize
|
||||
2. **Test Often:** Use test endpoint after each change
|
||||
3. **Document Your Setup:** Save your working calibration
|
||||
4. **Physical Labels:** Label your LED strip to remember layout
|
||||
5. **Photos Help:** Take photos of your setup with LED numbers visible
|
||||
|
||||
## Example Workflow
|
||||
|
||||
1. Install WLED strip around TV
|
||||
2. Note LED #0 position
|
||||
3. Create device in API (gets default calibration)
|
||||
4. Test default calibration
|
||||
5. Adjust based on test results
|
||||
6. Save final calibration
|
||||
7. Start processing and enjoy!
|
||||
6
hacs.json
Normal file
6
hacs.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "WLED Screen Controller",
|
||||
"render_readme": true,
|
||||
"country": ["US"],
|
||||
"homeassistant": "2023.1.0"
|
||||
}
|
||||
39
server/Dockerfile
Normal file
39
server/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="Alexei Dolgolyov <dolgolyov.alexei@gmail.com>"
|
||||
LABEL description="WLED Screen Controller - Ambient lighting based on screen content"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for screen capture
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libxcb1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-shm0 \
|
||||
libxcb-xfixes0 \
|
||||
libxcb-shape0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY src/ ./src/
|
||||
COPY config/ ./config/
|
||||
|
||||
# Create directories for data and logs
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
|
||||
# Expose API port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8080/health', timeout=5.0)" || exit 1
|
||||
|
||||
# Set Python path
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "wled_controller.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
191
server/README.md
Normal file
191
server/README.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# WLED Screen Controller - Server
|
||||
|
||||
High-performance FastAPI server that captures screen content and controls WLED devices for ambient lighting.
|
||||
|
||||
## Overview
|
||||
|
||||
The server component provides:
|
||||
- 🎯 **Real-time Screen Capture** - Multi-monitor support with configurable FPS
|
||||
- 🎨 **Advanced Processing** - Border pixel extraction with color correction
|
||||
- 🔧 **Flexible Calibration** - Map screen edges to any LED layout
|
||||
- 🌐 **REST API** - Complete control via 17 REST endpoints
|
||||
- 💾 **Persistent Storage** - JSON-based device and configuration management
|
||||
- 📊 **Metrics & Monitoring** - Real-time FPS, status, and performance data
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Docker (Recommended)
|
||||
|
||||
```bash
|
||||
# Start server
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop server
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
Server runs on: **http://localhost:8080**
|
||||
|
||||
### Option 2: Python
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
|
||||
# Activate
|
||||
source venv/bin/activate # Linux/Mac
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Set PYTHONPATH
|
||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
set PYTHONPATH=%CD%\src # Windows
|
||||
|
||||
# Run server
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Requirements
|
||||
- **Python 3.11+** (for Python installation)
|
||||
- **Docker & Docker Compose** (for Docker installation)
|
||||
- **WLED device** on your network
|
||||
|
||||
See [../INSTALLATION.md](../INSTALLATION.md) for comprehensive installation guide.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Configuration File
|
||||
|
||||
Edit `config/default_config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
log_level: "INFO"
|
||||
|
||||
processing:
|
||||
default_fps: 30 # Target frames per second
|
||||
max_fps: 60 # Maximum allowed FPS
|
||||
border_width: 10 # Pixels to sample from edge
|
||||
|
||||
wled:
|
||||
timeout: 5 # Connection timeout (seconds)
|
||||
retry_attempts: 3 # Number of retries
|
||||
|
||||
storage:
|
||||
devices_file: "data/devices.json"
|
||||
|
||||
logging:
|
||||
format: "json"
|
||||
file: "logs/wled_controller.log"
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Server configuration
|
||||
export WLED_SERVER__HOST="0.0.0.0"
|
||||
export WLED_SERVER__PORT=8080
|
||||
export WLED_SERVER__LOG_LEVEL="INFO"
|
||||
|
||||
# Processing configuration
|
||||
export WLED_PROCESSING__DEFAULT_FPS=30
|
||||
export WLED_PROCESSING__BORDER_WIDTH=10
|
||||
|
||||
# WLED configuration
|
||||
export WLED_WLED__TIMEOUT=5
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### WLED Device Setup
|
||||
|
||||
**Important**: Configure your WLED device using the official WLED web interface before connecting it to this controller:
|
||||
|
||||
1. **Access WLED Interface**: Open `http://[wled-ip]` in your browser
|
||||
2. **Configure Device Settings**:
|
||||
- Set LED count and type
|
||||
- Configure brightness, color order, and power limits
|
||||
- Set up segments if needed
|
||||
- Configure effects and presets
|
||||
|
||||
**This controller only sends pixel color data** - it does not manage WLED settings like brightness, effects, or segments. All WLED device configuration should be done through the official WLED interface.
|
||||
|
||||
### API Documentation
|
||||
|
||||
- **Web UI**: http://localhost:8080 (recommended for device management)
|
||||
- **Swagger UI**: http://localhost:8080/docs
|
||||
- **ReDoc**: http://localhost:8080/redoc
|
||||
|
||||
### Quick Example
|
||||
|
||||
```bash
|
||||
# 1. Add device
|
||||
curl -X POST http://localhost:8080/api/v1/devices \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Living Room","url":"http://192.168.1.100","led_count":150}'
|
||||
|
||||
# 2. Start processing
|
||||
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
|
||||
|
||||
# 3. Check status
|
||||
curl http://localhost:8080/api/v1/devices/{device_id}/state
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=wled_controller --cov-report=html
|
||||
|
||||
# Run specific test
|
||||
pytest tests/test_screen_capture.py -v
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/wled_controller/
|
||||
├── main.py # FastAPI application
|
||||
├── config.py # Configuration
|
||||
├── api/ # API routes
|
||||
├── core/ # Core functionality
|
||||
│ ├── screen_capture.py
|
||||
│ ├── wled_client.py
|
||||
│ ├── calibration.py
|
||||
│ └── processor_manager.py
|
||||
├── storage/ # Data persistence
|
||||
└── utils/ # Utilities
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black src/ tests/
|
||||
|
||||
# Lint code
|
||||
ruff check src/ tests/
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT - see [../LICENSE](../LICENSE)
|
||||
|
||||
## Support
|
||||
|
||||
- 📖 [Full Documentation](../docs/)
|
||||
- 🐛 [Issues](https://github.com/yourusername/wled-screen-controller/issues)
|
||||
42
server/config/default_config.yaml
Normal file
42
server/config/default_config.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
log_level: "INFO"
|
||||
cors_origins:
|
||||
- "*"
|
||||
|
||||
auth:
|
||||
# API keys are REQUIRED - authentication is always enforced
|
||||
# Format: label: "api-key"
|
||||
api_keys:
|
||||
# Generate secure keys: openssl rand -hex 32
|
||||
# IMPORTANT: Add at least one key before starting the server
|
||||
# home_assistant: "your-secure-api-key-1"
|
||||
# web_dashboard: "your-secure-api-key-2"
|
||||
# monitoring_script: "your-secure-api-key-3"
|
||||
|
||||
processing:
|
||||
default_fps: 30
|
||||
max_fps: 60
|
||||
min_fps: 1
|
||||
border_width: 10 # pixels to sample from screen edge
|
||||
interpolation_mode: "average" # average, median, dominant
|
||||
|
||||
screen_capture:
|
||||
buffer_size: 2 # Number of frames to buffer
|
||||
|
||||
wled:
|
||||
timeout: 5 # seconds
|
||||
retry_attempts: 3
|
||||
retry_delay: 1 # seconds
|
||||
protocol: "http" # http or https
|
||||
max_brightness: 255
|
||||
|
||||
storage:
|
||||
devices_file: "data/devices.json"
|
||||
|
||||
logging:
|
||||
format: "json" # json or text
|
||||
file: "logs/wled_controller.log"
|
||||
max_size_mb: 100
|
||||
backup_count: 5
|
||||
38
server/config/test_config.yaml
Normal file
38
server/config/test_config.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
server:
|
||||
host: "127.0.0.1" # localhost only for testing
|
||||
port: 8080
|
||||
log_level: "DEBUG" # Verbose logging for testing
|
||||
cors_origins:
|
||||
- "*"
|
||||
|
||||
auth:
|
||||
# Test API keys - DO NOT use in production!
|
||||
api_keys:
|
||||
test_client: "eb8a89cfd33ab067751fd0e38f74ddf7ac3d75ff012fbab35a616c45c12e0c8d"
|
||||
web_dashboard: "4b958666d32b368a89781da040a615283541418753d610858d6eb5411296dcb6"
|
||||
|
||||
processing:
|
||||
default_fps: 30
|
||||
max_fps: 60
|
||||
min_fps: 1
|
||||
border_width: 10
|
||||
interpolation_mode: "average"
|
||||
|
||||
screen_capture:
|
||||
buffer_size: 2
|
||||
|
||||
wled:
|
||||
timeout: 5
|
||||
retry_attempts: 3
|
||||
retry_delay: 1
|
||||
protocol: "http"
|
||||
max_brightness: 255
|
||||
|
||||
storage:
|
||||
devices_file: "data/test_devices.json"
|
||||
|
||||
logging:
|
||||
format: "text" # Easier to read during testing
|
||||
file: "logs/wled_test.log"
|
||||
max_size_mb: 10
|
||||
backup_count: 2
|
||||
45
server/docker-compose.yml
Normal file
45
server/docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
wled-controller:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: wled-screen-controller
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
volumes:
|
||||
# Persist device data
|
||||
- ./data:/app/data
|
||||
# Persist logs
|
||||
- ./logs:/app/logs
|
||||
# Mount configuration (optional override)
|
||||
- ./config:/app/config
|
||||
# Required for screen capture on Linux
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix:ro
|
||||
|
||||
environment:
|
||||
# Server configuration
|
||||
- WLED_SERVER__HOST=0.0.0.0
|
||||
- WLED_SERVER__PORT=8080
|
||||
- WLED_SERVER__LOG_LEVEL=INFO
|
||||
|
||||
# Display for X11 (Linux only)
|
||||
- DISPLAY=${DISPLAY:-:0}
|
||||
|
||||
# Processing defaults
|
||||
- WLED_PROCESSING__DEFAULT_FPS=30
|
||||
- WLED_PROCESSING__BORDER_WIDTH=10
|
||||
|
||||
# Use host network for screen capture access
|
||||
# network_mode: host # Uncomment for Linux screen capture
|
||||
|
||||
networks:
|
||||
- wled-network
|
||||
|
||||
networks:
|
||||
wled-network:
|
||||
driver: bridge
|
||||
237
server/docs/AUTHENTICATION.md
Normal file
237
server/docs/AUTHENTICATION.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# API Authentication Guide
|
||||
|
||||
WLED Screen Controller **requires** API key authentication for all API endpoints. This ensures your server is secure and all access is properly authenticated and audited.
|
||||
|
||||
## Configuration
|
||||
|
||||
Authentication is configured in `config/default_config.yaml`. **API keys are mandatory** - the server will not start without at least one configured key.
|
||||
|
||||
### Configure API Keys
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
enabled: true
|
||||
api_keys:
|
||||
home_assistant: "your-secure-api-key-1"
|
||||
web_dashboard: "your-secure-api-key-2"
|
||||
monitoring_script: "your-secure-api-key-3"
|
||||
```
|
||||
|
||||
**Format:** API keys are defined as `label: "key"` pairs where:
|
||||
- **Label**: Identifier for the client (e.g., `home_assistant`, `web_ui`, `admin`)
|
||||
- **Key**: The actual API key (generate strong, random keys)
|
||||
|
||||
**Critical Requirements:**
|
||||
- ⚠️ **At least one API key must be configured** - server will not start without keys
|
||||
- Generate strong, random API keys (use `openssl rand -hex 32`)
|
||||
- Never commit API keys to version control
|
||||
- Use environment variables or secure secret management for production
|
||||
- Each client/service gets its own labeled API key for audit trails
|
||||
- Labels appear in server logs to track which client made requests
|
||||
|
||||
### Server Startup Validation
|
||||
|
||||
If no API keys are configured, the server will fail to start with this error:
|
||||
|
||||
```
|
||||
CRITICAL: No API keys configured!
|
||||
Authentication is REQUIRED for all API requests.
|
||||
Please add API keys to your configuration:
|
||||
1. Generate keys: openssl rand -hex 32
|
||||
2. Add to config/default_config.yaml under auth.api_keys
|
||||
3. Format: label: "your-generated-key"
|
||||
```
|
||||
|
||||
## Using API Keys
|
||||
|
||||
### Web UI
|
||||
|
||||
The web dashboard automatically handles authentication:
|
||||
|
||||
1. When you first load the UI with auth enabled, you'll be prompted for an API key
|
||||
2. Enter your API key - it's stored in browser localStorage
|
||||
3. Click the 🔑 **API Key** button in the header to update or remove the key
|
||||
|
||||
### REST API Clients
|
||||
|
||||
Include the API key in the `Authorization` header:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your-api-key-here" \
|
||||
http://localhost:8080/api/v1/devices
|
||||
```
|
||||
|
||||
### Home Assistant Integration
|
||||
|
||||
The integration will prompt for an API key during setup if authentication is enabled. You can also configure it in `configuration.yaml`:
|
||||
|
||||
```yaml
|
||||
wled_screen_controller:
|
||||
server_url: "http://192.168.1.100:8080"
|
||||
api_key: "your-api-key-here" # Optional, only if auth is enabled
|
||||
```
|
||||
|
||||
### Python Client Example
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = "your-api-key-here"
|
||||
BASE_URL = "http://localhost:8080/api/v1"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# List devices
|
||||
response = requests.get(f"{BASE_URL}/devices", headers=headers)
|
||||
devices = response.json()
|
||||
|
||||
# Start processing
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/devices/device_001/start",
|
||||
headers=headers
|
||||
)
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
**Missing API Key:**
|
||||
```json
|
||||
{
|
||||
"detail": "Missing API key"
|
||||
}
|
||||
```
|
||||
|
||||
**Invalid API Key:**
|
||||
```json
|
||||
{
|
||||
"detail": "Invalid API key"
|
||||
}
|
||||
```
|
||||
|
||||
### 500 Internal Server Error
|
||||
|
||||
**Auth enabled but no keys configured:**
|
||||
```json
|
||||
{
|
||||
"detail": "Server authentication not configured"
|
||||
}
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Public Endpoints (No Auth Required)
|
||||
- `GET /` - Web UI dashboard (static files)
|
||||
- `GET /health` - Health check (for monitoring/health checks)
|
||||
- `GET /docs` - API documentation
|
||||
- `GET /redoc` - Alternative API docs
|
||||
- `/static/*` - Static files (CSS, JS, images)
|
||||
|
||||
### Protected Endpoints (Auth ALWAYS Required)
|
||||
All `/api/v1/*` endpoints **require authentication**:
|
||||
- Device management (`/api/v1/devices/*`)
|
||||
- Processing control (`/api/v1/devices/*/start`, `/api/v1/devices/*/stop`)
|
||||
- Settings and calibration (`/api/v1/devices/*/settings`, `/api/v1/devices/*/calibration`)
|
||||
- Metrics (`/api/v1/devices/*/metrics`)
|
||||
- Display configuration (`/api/v1/config/displays`)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Development
|
||||
```yaml
|
||||
auth:
|
||||
enabled: false # Disabled for local development
|
||||
```
|
||||
|
||||
### Production
|
||||
```yaml
|
||||
auth:
|
||||
enabled: true
|
||||
api_keys:
|
||||
- "${WLED_API_KEY_1}" # Use environment variables
|
||||
- "${WLED_API_KEY_2}"
|
||||
```
|
||||
|
||||
Set environment variables:
|
||||
```bash
|
||||
export WLED_API_KEY_1="$(openssl rand -hex 32)"
|
||||
export WLED_API_KEY_2="$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
### Docker
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
wled-controller:
|
||||
environment:
|
||||
- WLED_AUTH__ENABLED=true
|
||||
- WLED_AUTH__API_KEYS__0=your-key-here
|
||||
```
|
||||
|
||||
Or use Docker secrets for better security.
|
||||
|
||||
## Generating Secure API Keys
|
||||
|
||||
### OpenSSL
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
# Output: 64-character hex string
|
||||
```
|
||||
|
||||
### Python
|
||||
```python
|
||||
import secrets
|
||||
print(secrets.token_hex(32))
|
||||
```
|
||||
|
||||
### Node.js
|
||||
```javascript
|
||||
require('crypto').randomBytes(32).toString('hex')
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
When authentication is enabled, the server logs:
|
||||
- Auth status on startup
|
||||
- Invalid API key attempts (with truncated key for security)
|
||||
- Number of configured keys
|
||||
|
||||
Example startup logs:
|
||||
```
|
||||
INFO: API authentication: ENABLED (2 keys configured)
|
||||
WARNING: Authentication is enabled - API requests require valid API key
|
||||
```
|
||||
|
||||
Invalid attempts:
|
||||
```
|
||||
WARNING: Invalid API key attempt: 1234abcd...
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **HTTPS**: Use HTTPS in production to protect API keys in transit
|
||||
2. **Key Rotation**: Periodically rotate API keys
|
||||
3. **Monitoring**: Monitor logs for invalid key attempts
|
||||
4. **Least Privilege**: Use separate keys for different clients
|
||||
5. **Storage**: Never log or display full API keys
|
||||
6. **Rate Limiting**: Consider adding rate limiting for production (not implemented yet)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Web UI shows "API Key Required" repeatedly
|
||||
- Verify the key is correct
|
||||
- Check browser console for errors
|
||||
- Clear localStorage and re-enter key
|
||||
|
||||
### Home Assistant can't connect
|
||||
- Ensure API key is configured in integration settings
|
||||
- Check server logs for authentication errors
|
||||
- Verify `auth.enabled = true` in server config
|
||||
|
||||
### "Server authentication not configured" error
|
||||
- You enabled auth but didn't add any API keys
|
||||
- Add at least one key to `auth.api_keys` array
|
||||
72
server/pyproject.toml
Normal file
72
server/pyproject.toml
Normal file
@@ -0,0 +1,72 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "wled-screen-controller"
|
||||
version = "0.1.0"
|
||||
description = "WLED ambient lighting controller based on screen content"
|
||||
authors = [
|
||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = {text = "MIT"}
|
||||
keywords = ["wled", "ambient-lighting", "screen-capture", "home-automation"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"httpx>=0.27.2",
|
||||
"mss>=9.0.2",
|
||||
"Pillow>=10.4.0",
|
||||
"numpy>=2.1.3",
|
||||
"pydantic>=2.9.2",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"PyYAML>=6.0.2",
|
||||
"structlog>=24.4.0",
|
||||
"python-json-logger>=3.1.0",
|
||||
"python-dateutil>=2.9.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.3",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=6.0.0",
|
||||
"respx>=0.21.1",
|
||||
"black>=24.0.0",
|
||||
"ruff>=0.6.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/wled-screen-controller"
|
||||
Repository = "https://github.com/yourusername/wled-screen-controller"
|
||||
Issues = "https://github.com/yourusername/wled-screen-controller/issues"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "-v --cov=wled_controller --cov-report=html --cov-report=term"
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py311']
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
34
server/requirements.txt
Normal file
34
server/requirements.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
# Web Framework
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
python-multipart==0.0.12
|
||||
|
||||
# HTTP Client
|
||||
httpx==0.27.2
|
||||
|
||||
# Screen Capture
|
||||
mss==9.0.2
|
||||
Pillow==10.4.0
|
||||
numpy==2.1.3
|
||||
|
||||
# Configuration
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.6.0
|
||||
PyYAML==6.0.2
|
||||
|
||||
# Logging
|
||||
structlog==24.4.0
|
||||
python-json-logger==3.1.0
|
||||
|
||||
# Utilities
|
||||
python-dateutil==2.9.0
|
||||
|
||||
# Windows-specific (optional for friendly monitor names)
|
||||
wmi==1.5.1; sys_platform == 'win32'
|
||||
|
||||
# Testing
|
||||
pytest==8.3.3
|
||||
pytest-asyncio==0.24.0
|
||||
pytest-cov==6.0.0
|
||||
httpx==0.27.2
|
||||
respx==0.21.1
|
||||
5
server/src/wled_controller/__init__.py
Normal file
5
server/src/wled_controller/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""WLED Screen Controller - Ambient lighting based on screen content."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "Alexei Dolgolyov"
|
||||
__email__ = "dolgolyov.alexei@gmail.com"
|
||||
5
server/src/wled_controller/api/__init__.py
Normal file
5
server/src/wled_controller/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""API routes and schemas."""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
77
server/src/wled_controller/api/auth.py
Normal file
77
server/src/wled_controller/api/auth.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Authentication module for API key validation."""
|
||||
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Security, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Security scheme for Bearer token
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def verify_api_key(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)]
|
||||
) -> str:
|
||||
"""Verify API key from Authorization header.
|
||||
|
||||
Args:
|
||||
credentials: HTTP authorization credentials
|
||||
|
||||
Returns:
|
||||
Label/identifier of the authenticated client
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication is required but invalid
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
# Check if credentials are provided
|
||||
if not credentials:
|
||||
logger.warning("Request missing Authorization header")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing API key - authentication is required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Extract token
|
||||
token = credentials.credentials
|
||||
|
||||
# Verify against configured API keys
|
||||
if not config.auth.api_keys:
|
||||
logger.error("No API keys configured - server misconfiguration")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Server authentication not configured properly",
|
||||
)
|
||||
|
||||
# Find matching key and return its label using constant-time comparison
|
||||
authenticated_as = None
|
||||
for label, api_key in config.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated_as = label
|
||||
break
|
||||
|
||||
if not authenticated_as:
|
||||
logger.warning(f"Invalid API key attempt: {token[:8]}...")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Log successful authentication
|
||||
logger.debug(f"Authenticated as: {authenticated_as}")
|
||||
|
||||
return authenticated_as
|
||||
|
||||
|
||||
# Dependency for protected routes
|
||||
# Returns the label/identifier of the authenticated client
|
||||
AuthRequired = Annotated[str, Depends(verify_api_key)]
|
||||
598
server/src/wled_controller/api/routes.py
Normal file
598
server/src/wled_controller/api/routes.py
Normal file
@@ -0,0 +1,598 @@
|
||||
"""API routes and endpoints."""
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.schemas import (
|
||||
HealthResponse,
|
||||
VersionResponse,
|
||||
DisplayListResponse,
|
||||
DisplayInfo,
|
||||
DeviceCreate,
|
||||
DeviceUpdate,
|
||||
DeviceResponse,
|
||||
DeviceListResponse,
|
||||
ProcessingSettings as ProcessingSettingsSchema,
|
||||
Calibration as CalibrationSchema,
|
||||
ProcessingState,
|
||||
MetricsResponse,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
|
||||
from wled_controller.core.calibration import (
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
)
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.utils import get_logger, get_monitor_names
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Global instances (initialized in main.py)
|
||||
_device_store: DeviceStore | None = None
|
||||
_processor_manager: ProcessorManager | None = None
|
||||
|
||||
|
||||
def get_device_store() -> DeviceStore:
|
||||
"""Get device store dependency."""
|
||||
if _device_store is None:
|
||||
raise RuntimeError("Device store not initialized")
|
||||
return _device_store
|
||||
|
||||
|
||||
def get_processor_manager() -> ProcessorManager:
|
||||
"""Get processor manager dependency."""
|
||||
if _processor_manager is None:
|
||||
raise RuntimeError("Processor manager not initialized")
|
||||
return _processor_manager
|
||||
|
||||
|
||||
def init_dependencies(device_store: DeviceStore, processor_manager: ProcessorManager):
|
||||
"""Initialize global dependencies."""
|
||||
global _device_store, _processor_manager
|
||||
_device_store = device_store
|
||||
_processor_manager = processor_manager
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse, tags=["Health"])
|
||||
async def health_check():
|
||||
"""Check service health status.
|
||||
|
||||
Returns basic health information including status, version, and timestamp.
|
||||
"""
|
||||
logger.info("Health check requested")
|
||||
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
timestamp=datetime.utcnow(),
|
||||
version=__version__,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/version", response_model=VersionResponse, tags=["Info"])
|
||||
async def get_version():
|
||||
"""Get version information.
|
||||
|
||||
Returns application version, Python version, and API version.
|
||||
"""
|
||||
logger.info("Version info requested")
|
||||
|
||||
return VersionResponse(
|
||||
version=__version__,
|
||||
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||||
api_version="v1",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
|
||||
async def get_displays(_: AuthRequired):
|
||||
"""Get list of available displays.
|
||||
|
||||
Returns information about all available monitors/displays that can be captured.
|
||||
"""
|
||||
logger.info("Listing available displays")
|
||||
|
||||
try:
|
||||
# Import here to avoid issues if mss is not installed yet
|
||||
import mss
|
||||
|
||||
# Get friendly monitor names (Windows only, falls back to generic names)
|
||||
monitor_names = get_monitor_names()
|
||||
|
||||
with mss.mss() as sct:
|
||||
displays = []
|
||||
|
||||
# Skip the first monitor (it's the combined virtual screen on multi-monitor setups)
|
||||
for idx, monitor in enumerate(sct.monitors[1:], start=0):
|
||||
# Use friendly name from WMI if available, otherwise generic name
|
||||
friendly_name = monitor_names.get(idx, f"Display {idx}")
|
||||
|
||||
display_info = DisplayInfo(
|
||||
index=idx,
|
||||
name=friendly_name,
|
||||
width=monitor["width"],
|
||||
height=monitor["height"],
|
||||
x=monitor["left"],
|
||||
y=monitor["top"],
|
||||
is_primary=(idx == 0),
|
||||
)
|
||||
displays.append(display_info)
|
||||
|
||||
logger.info(f"Found {len(displays)} displays")
|
||||
|
||||
return DisplayListResponse(
|
||||
displays=displays,
|
||||
count=len(displays),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get displays: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve display information: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ===== DEVICE MANAGEMENT ENDPOINTS =====
|
||||
|
||||
@router.post("/api/v1/devices", response_model=DeviceResponse, tags=["Devices"], status_code=201)
|
||||
async def create_device(
|
||||
device_data: DeviceCreate,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Create and attach a new WLED device."""
|
||||
try:
|
||||
logger.info(f"Creating device: {device_data.name}")
|
||||
|
||||
# Create device in storage
|
||||
device = store.create_device(
|
||||
name=device_data.name,
|
||||
url=device_data.url,
|
||||
led_count=device_data.led_count,
|
||||
)
|
||||
|
||||
# Add to processor manager
|
||||
manager.add_device(
|
||||
device_id=device.id,
|
||||
device_url=device.url,
|
||||
led_count=device.led_count,
|
||||
settings=device.settings,
|
||||
calibration=device.calibration,
|
||||
)
|
||||
|
||||
return DeviceResponse(
|
||||
id=device.id,
|
||||
name=device.name,
|
||||
url=device.url,
|
||||
led_count=device.led_count,
|
||||
enabled=device.enabled,
|
||||
status="disconnected",
|
||||
settings=ProcessingSettingsSchema(
|
||||
display_index=device.settings.display_index,
|
||||
fps=device.settings.fps,
|
||||
border_width=device.settings.border_width,
|
||||
brightness=device.settings.brightness,
|
||||
),
|
||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create device: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
|
||||
async def list_devices(
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""List all attached WLED devices."""
|
||||
try:
|
||||
devices = store.get_all_devices()
|
||||
|
||||
device_responses = [
|
||||
DeviceResponse(
|
||||
id=device.id,
|
||||
name=device.name,
|
||||
url=device.url,
|
||||
led_count=device.led_count,
|
||||
enabled=device.enabled,
|
||||
status="disconnected",
|
||||
settings=ProcessingSettingsSchema(
|
||||
display_index=device.settings.display_index,
|
||||
fps=device.settings.fps,
|
||||
border_width=device.settings.border_width,
|
||||
),
|
||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
for device in devices
|
||||
]
|
||||
|
||||
return DeviceListResponse(devices=device_responses, count=len(device_responses))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list devices: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
|
||||
async def get_device(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get device details by ID."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
# Determine status
|
||||
status = "connected" if manager.is_processing(device_id) else "disconnected"
|
||||
|
||||
return DeviceResponse(
|
||||
id=device.id,
|
||||
name=device.name,
|
||||
url=device.url,
|
||||
led_count=device.led_count,
|
||||
enabled=device.enabled,
|
||||
status=status,
|
||||
settings=ProcessingSettingsSchema(
|
||||
display_index=device.settings.display_index,
|
||||
fps=device.settings.fps,
|
||||
border_width=device.settings.border_width,
|
||||
),
|
||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
|
||||
async def update_device(
|
||||
device_id: str,
|
||||
update_data: DeviceUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Update device information."""
|
||||
try:
|
||||
device = store.update_device(
|
||||
device_id=device_id,
|
||||
name=update_data.name,
|
||||
url=update_data.url,
|
||||
led_count=update_data.led_count,
|
||||
enabled=update_data.enabled,
|
||||
)
|
||||
|
||||
return DeviceResponse(
|
||||
id=device.id,
|
||||
name=device.name,
|
||||
url=device.url,
|
||||
led_count=device.led_count,
|
||||
enabled=device.enabled,
|
||||
status="disconnected",
|
||||
settings=ProcessingSettingsSchema(
|
||||
display_index=device.settings.display_index,
|
||||
fps=device.settings.fps,
|
||||
border_width=device.settings.border_width,
|
||||
brightness=device.settings.brightness,
|
||||
),
|
||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update device: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"])
|
||||
async def delete_device(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Delete/detach a device."""
|
||||
try:
|
||||
# Stop processing if running
|
||||
if manager.is_processing(device_id):
|
||||
await manager.stop_processing(device_id)
|
||||
|
||||
# Remove from manager
|
||||
manager.remove_device(device_id)
|
||||
|
||||
# Delete from storage
|
||||
store.delete_device(device_id)
|
||||
|
||||
logger.info(f"Deleted device {device_id}")
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete device: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== PROCESSING CONTROL ENDPOINTS =====
|
||||
|
||||
@router.post("/api/v1/devices/{device_id}/start", tags=["Processing"])
|
||||
async def start_processing(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Start screen processing for a device."""
|
||||
try:
|
||||
# Verify device exists
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
await manager.start_processing(device_id)
|
||||
|
||||
logger.info(f"Started processing for device {device_id}")
|
||||
return {"status": "started", "device_id": device_id}
|
||||
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start processing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/devices/{device_id}/stop", tags=["Processing"])
|
||||
async def stop_processing(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Stop screen processing for a device."""
|
||||
try:
|
||||
await manager.stop_processing(device_id)
|
||||
|
||||
logger.info(f"Stopped processing for device {device_id}")
|
||||
return {"status": "stopped", "device_id": device_id}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop processing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}/state", response_model=ProcessingState, tags=["Processing"])
|
||||
async def get_processing_state(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get current processing state for a device."""
|
||||
try:
|
||||
state = manager.get_state(device_id)
|
||||
return ProcessingState(**state)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get state: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== SETTINGS ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
|
||||
async def get_settings(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Get processing settings for a device."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
return ProcessingSettingsSchema(
|
||||
display_index=device.settings.display_index,
|
||||
fps=device.settings.fps,
|
||||
border_width=device.settings.border_width,
|
||||
brightness=device.settings.brightness,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
|
||||
async def update_settings(
|
||||
device_id: str,
|
||||
settings: ProcessingSettingsSchema,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Update processing settings for a device."""
|
||||
try:
|
||||
# Create ProcessingSettings from schema
|
||||
new_settings = ProcessingSettings(
|
||||
display_index=settings.display_index,
|
||||
fps=settings.fps,
|
||||
border_width=settings.border_width,
|
||||
brightness=settings.color_correction.brightness if settings.color_correction else 1.0,
|
||||
gamma=settings.color_correction.gamma if settings.color_correction else 2.2,
|
||||
saturation=settings.color_correction.saturation if settings.color_correction else 1.0,
|
||||
)
|
||||
|
||||
# Update in storage
|
||||
device = store.update_device(device_id, settings=new_settings)
|
||||
|
||||
# Update in manager if device exists
|
||||
try:
|
||||
manager.update_settings(device_id, new_settings)
|
||||
except ValueError:
|
||||
# Device not in manager yet, that's ok
|
||||
pass
|
||||
|
||||
return ProcessingSettingsSchema(
|
||||
display_index=device.settings.display_index,
|
||||
fps=device.settings.fps,
|
||||
border_width=device.settings.border_width,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update settings: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== CALIBRATION ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
||||
async def get_calibration(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Get calibration configuration for a device."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
return CalibrationSchema(**calibration_to_dict(device.calibration))
|
||||
|
||||
|
||||
@router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
||||
async def update_calibration(
|
||||
device_id: str,
|
||||
calibration_data: CalibrationSchema,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Update calibration configuration for a device."""
|
||||
try:
|
||||
# Convert schema to CalibrationConfig
|
||||
calibration_dict = calibration_data.model_dump()
|
||||
calibration = calibration_from_dict(calibration_dict)
|
||||
|
||||
# Update in storage
|
||||
device = store.update_device(device_id, calibration=calibration)
|
||||
|
||||
# Update in manager if device exists
|
||||
try:
|
||||
manager.update_calibration(device_id, calibration)
|
||||
except ValueError:
|
||||
# Device not in manager yet, that's ok
|
||||
pass
|
||||
|
||||
return CalibrationSchema(**calibration_to_dict(device.calibration))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update calibration: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/devices/{device_id}/calibration/test", tags=["Calibration"])
|
||||
async def test_calibration(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
edge: str = "top",
|
||||
color: List[int] = [255, 0, 0],
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Test calibration by lighting up specific edge.
|
||||
|
||||
Useful for verifying LED positions match screen edges.
|
||||
"""
|
||||
try:
|
||||
# Get device
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
# Find the segment for this edge
|
||||
segment = None
|
||||
for seg in device.calibration.segments:
|
||||
if seg.edge == edge:
|
||||
segment = seg
|
||||
break
|
||||
|
||||
if not segment:
|
||||
raise HTTPException(status_code=400, detail=f"No LEDs configured for {edge} edge")
|
||||
|
||||
# Create pixel array - all black except for the test edge
|
||||
pixels = [(0, 0, 0)] * device.led_count
|
||||
|
||||
# Light up the test edge
|
||||
r, g, b = color if len(color) == 3 else [255, 0, 0]
|
||||
for i in range(segment.led_start, segment.led_start + segment.led_count):
|
||||
if i < device.led_count:
|
||||
pixels[i] = (r, g, b)
|
||||
|
||||
# Send to WLED
|
||||
from wled_controller.core.wled_client import WLEDClient
|
||||
import asyncio
|
||||
|
||||
async with WLEDClient(device.url) as wled:
|
||||
# Light up the edge
|
||||
await wled.send_pixels(pixels)
|
||||
|
||||
# Wait 2 seconds
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Turn off
|
||||
pixels_off = [(0, 0, 0)] * device.led_count
|
||||
await wled.send_pixels(pixels_off)
|
||||
|
||||
logger.info(f"Calibration test completed for edge '{edge}' on device {device_id}")
|
||||
|
||||
return {
|
||||
"status": "test_completed",
|
||||
"device_id": device_id,
|
||||
"edge": edge,
|
||||
"led_count": segment.led_count,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test calibration: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== METRICS ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}/metrics", response_model=MetricsResponse, tags=["Metrics"])
|
||||
async def get_metrics(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get processing metrics for a device."""
|
||||
try:
|
||||
metrics = manager.get_metrics(device_id)
|
||||
return MetricsResponse(**metrics)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get metrics: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
175
server/src/wled_controller/api/schemas.py
Normal file
175
server/src/wled_controller/api/schemas.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Pydantic schemas for API request and response models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
|
||||
# Health and Version Schemas
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response."""
|
||||
|
||||
status: Literal["healthy", "unhealthy"] = Field(description="Service health status")
|
||||
timestamp: datetime = Field(description="Current server time")
|
||||
version: str = Field(description="Application version")
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
"""Version information response."""
|
||||
|
||||
version: str = Field(description="Application version")
|
||||
python_version: str = Field(description="Python version")
|
||||
api_version: str = Field(description="API version")
|
||||
|
||||
|
||||
# Display Schemas
|
||||
|
||||
class DisplayInfo(BaseModel):
|
||||
"""Display/monitor information."""
|
||||
|
||||
index: int = Field(description="Display index")
|
||||
name: str = Field(description="Display name")
|
||||
width: int = Field(description="Display width in pixels")
|
||||
height: int = Field(description="Display height in pixels")
|
||||
x: int = Field(description="Display X position")
|
||||
y: int = Field(description="Display Y position")
|
||||
is_primary: bool = Field(default=False, description="Whether this is the primary display")
|
||||
|
||||
|
||||
class DisplayListResponse(BaseModel):
|
||||
"""List of available displays."""
|
||||
|
||||
displays: List[DisplayInfo] = Field(description="Available displays")
|
||||
count: int = Field(description="Number of displays")
|
||||
|
||||
|
||||
# Device Schemas
|
||||
|
||||
class DeviceCreate(BaseModel):
|
||||
"""Request to create/attach a WLED device."""
|
||||
|
||||
name: str = Field(description="Device name", min_length=1, max_length=100)
|
||||
url: str = Field(description="WLED device URL (e.g., http://192.168.1.100)")
|
||||
led_count: int = Field(description="Total number of LEDs", gt=0, le=10000)
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
"""Request to update device information."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
|
||||
url: Optional[str] = Field(None, description="WLED device URL")
|
||||
led_count: Optional[int] = Field(None, description="Total number of LEDs", gt=0, le=10000)
|
||||
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
|
||||
|
||||
|
||||
class ColorCorrection(BaseModel):
|
||||
"""Color correction settings."""
|
||||
|
||||
gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0)
|
||||
saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0)
|
||||
brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class ProcessingSettings(BaseModel):
|
||||
"""Processing settings for a device."""
|
||||
|
||||
display_index: int = Field(default=0, description="Display to capture", ge=0)
|
||||
fps: int = Field(default=30, description="Target frames per second", ge=1, le=60)
|
||||
border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100)
|
||||
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
|
||||
color_correction: Optional[ColorCorrection] = Field(
|
||||
default_factory=ColorCorrection,
|
||||
description="Color correction settings"
|
||||
)
|
||||
|
||||
|
||||
class CalibrationSegment(BaseModel):
|
||||
"""Calibration segment for LED mapping."""
|
||||
|
||||
edge: Literal["top", "right", "bottom", "left"] = Field(description="Screen edge")
|
||||
led_start: int = Field(description="Starting LED index", ge=0)
|
||||
led_count: int = Field(description="Number of LEDs on this edge", gt=0)
|
||||
reverse: bool = Field(default=False, description="Reverse LED order on this edge")
|
||||
|
||||
|
||||
class Calibration(BaseModel):
|
||||
"""Calibration configuration for pixel-to-LED mapping."""
|
||||
|
||||
layout: Literal["clockwise", "counterclockwise"] = Field(
|
||||
default="clockwise",
|
||||
description="LED strip layout direction"
|
||||
)
|
||||
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
|
||||
default="bottom_left",
|
||||
description="Position of LED index 0"
|
||||
)
|
||||
segments: List[CalibrationSegment] = Field(
|
||||
description="LED segments for each screen edge",
|
||||
min_length=1,
|
||||
max_length=4
|
||||
)
|
||||
|
||||
|
||||
class DeviceResponse(BaseModel):
|
||||
"""Device information response."""
|
||||
|
||||
id: str = Field(description="Device ID")
|
||||
name: str = Field(description="Device name")
|
||||
url: str = Field(description="WLED device URL")
|
||||
led_count: int = Field(description="Total number of LEDs")
|
||||
enabled: bool = Field(description="Whether device is enabled")
|
||||
status: Literal["connected", "disconnected", "error"] = Field(
|
||||
description="Connection status"
|
||||
)
|
||||
settings: ProcessingSettings = Field(description="Processing settings")
|
||||
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class DeviceListResponse(BaseModel):
|
||||
"""List of devices response."""
|
||||
|
||||
devices: List[DeviceResponse] = Field(description="List of devices")
|
||||
count: int = Field(description="Number of devices")
|
||||
|
||||
|
||||
# Processing State Schemas
|
||||
|
||||
class ProcessingState(BaseModel):
|
||||
"""Processing state for a device."""
|
||||
|
||||
device_id: str = Field(description="Device ID")
|
||||
processing: bool = Field(description="Whether processing is active")
|
||||
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
|
||||
fps_target: int = Field(description="Target FPS")
|
||||
display_index: int = Field(description="Current display index")
|
||||
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
||||
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
||||
|
||||
|
||||
class MetricsResponse(BaseModel):
|
||||
"""Device metrics response."""
|
||||
|
||||
device_id: str = Field(description="Device ID")
|
||||
processing: bool = Field(description="Whether processing is active")
|
||||
fps_actual: Optional[float] = Field(None, description="Actual FPS")
|
||||
fps_target: int = Field(description="Target FPS")
|
||||
uptime_seconds: float = Field(description="Processing uptime in seconds")
|
||||
frames_processed: int = Field(description="Total frames processed")
|
||||
errors_count: int = Field(description="Total error count")
|
||||
last_error: Optional[str] = Field(None, description="Last error message")
|
||||
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
|
||||
|
||||
|
||||
# Error Schemas
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response."""
|
||||
|
||||
error: str = Field(description="Error type")
|
||||
message: str = Field(description="Error message")
|
||||
detail: Optional[Dict] = Field(None, description="Additional error details")
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp")
|
||||
154
server/src/wled_controller/config.py
Normal file
154
server/src/wled_controller/config.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Configuration management for WLED Screen Controller."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class ServerConfig(BaseSettings):
|
||||
"""Server configuration."""
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8080
|
||||
log_level: str = "INFO"
|
||||
cors_origins: List[str] = ["*"]
|
||||
|
||||
|
||||
class AuthConfig(BaseSettings):
|
||||
"""Authentication configuration."""
|
||||
|
||||
api_keys: dict[str, str] = {} # label: key mapping (required for security)
|
||||
|
||||
|
||||
class ProcessingConfig(BaseSettings):
|
||||
"""Processing configuration."""
|
||||
|
||||
default_fps: int = 30
|
||||
max_fps: int = 60
|
||||
min_fps: int = 1
|
||||
border_width: int = 10
|
||||
interpolation_mode: Literal["average", "median", "dominant"] = "average"
|
||||
|
||||
|
||||
class ScreenCaptureConfig(BaseSettings):
|
||||
"""Screen capture configuration."""
|
||||
|
||||
buffer_size: int = 2
|
||||
|
||||
|
||||
class WLEDConfig(BaseSettings):
|
||||
"""WLED client configuration."""
|
||||
|
||||
timeout: int = 5
|
||||
retry_attempts: int = 3
|
||||
retry_delay: int = 1
|
||||
protocol: Literal["http", "https"] = "http"
|
||||
max_brightness: int = 255
|
||||
|
||||
|
||||
class StorageConfig(BaseSettings):
|
||||
"""Storage configuration."""
|
||||
|
||||
devices_file: str = "data/devices.json"
|
||||
|
||||
|
||||
class LoggingConfig(BaseSettings):
|
||||
"""Logging configuration."""
|
||||
|
||||
format: Literal["json", "text"] = "json"
|
||||
file: str = "logs/wled_controller.log"
|
||||
max_size_mb: int = 100
|
||||
backup_count: int = 5
|
||||
|
||||
|
||||
class Config(BaseSettings):
|
||||
"""Main application configuration."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="WLED_",
|
||||
env_nested_delimiter="__",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
||||
processing: ProcessingConfig = Field(default_factory=ProcessingConfig)
|
||||
screen_capture: ScreenCaptureConfig = Field(default_factory=ScreenCaptureConfig)
|
||||
wled: WLEDConfig = Field(default_factory=WLEDConfig)
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, config_path: str | Path) -> "Config":
|
||||
"""Load configuration from YAML file.
|
||||
|
||||
Args:
|
||||
config_path: Path to YAML configuration file
|
||||
|
||||
Returns:
|
||||
Config instance
|
||||
"""
|
||||
config_path = Path(config_path)
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
||||
|
||||
with open(config_path, "r") as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
|
||||
return cls(**config_data)
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "Config":
|
||||
"""Load configuration from default locations.
|
||||
|
||||
Tries to load from:
|
||||
1. Environment variable WLED_CONFIG_PATH
|
||||
2. ./config/default_config.yaml
|
||||
3. Default values
|
||||
|
||||
Returns:
|
||||
Config instance
|
||||
"""
|
||||
config_path = os.getenv("WLED_CONFIG_PATH")
|
||||
|
||||
if config_path:
|
||||
return cls.from_yaml(config_path)
|
||||
|
||||
# Try default location
|
||||
default_path = Path("config/default_config.yaml")
|
||||
if default_path.exists():
|
||||
return cls.from_yaml(default_path)
|
||||
|
||||
# Use defaults
|
||||
return cls()
|
||||
|
||||
|
||||
# Global configuration instance
|
||||
config: Config | None = None
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""Get global configuration instance.
|
||||
|
||||
Returns:
|
||||
Config instance
|
||||
"""
|
||||
global config
|
||||
if config is None:
|
||||
config = Config.load()
|
||||
return config
|
||||
|
||||
|
||||
def reload_config() -> Config:
|
||||
"""Reload configuration from file.
|
||||
|
||||
Returns:
|
||||
New Config instance
|
||||
"""
|
||||
global config
|
||||
config = Config.load()
|
||||
return config
|
||||
17
server/src/wled_controller/core/__init__.py
Normal file
17
server/src/wled_controller/core/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Core functionality for screen capture and WLED control."""
|
||||
|
||||
from .screen_capture import (
|
||||
get_available_displays,
|
||||
capture_display,
|
||||
extract_border_pixels,
|
||||
ScreenCapture,
|
||||
BorderPixels,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_available_displays",
|
||||
"capture_display",
|
||||
"extract_border_pixels",
|
||||
"ScreenCapture",
|
||||
"BorderPixels",
|
||||
]
|
||||
344
server/src/wled_controller/core/calibration.py
Normal file
344
server/src/wled_controller/core/calibration.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""Calibration system for mapping screen pixels to LED positions."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Literal, Tuple
|
||||
|
||||
from wled_controller.core.screen_capture import (
|
||||
BorderPixels,
|
||||
get_edge_segments,
|
||||
calculate_average_color,
|
||||
calculate_median_color,
|
||||
calculate_dominant_color,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalibrationSegment:
|
||||
"""Configuration for one segment of the LED strip."""
|
||||
|
||||
edge: Literal["top", "right", "bottom", "left"]
|
||||
led_start: int
|
||||
led_count: int
|
||||
reverse: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalibrationConfig:
|
||||
"""Complete calibration configuration."""
|
||||
|
||||
layout: Literal["clockwise", "counterclockwise"]
|
||||
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
|
||||
segments: List[CalibrationSegment]
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate calibration configuration.
|
||||
|
||||
Returns:
|
||||
True if configuration is valid
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is invalid
|
||||
"""
|
||||
if not self.segments:
|
||||
raise ValueError("Calibration must have at least one segment")
|
||||
|
||||
# Check for duplicate edges
|
||||
edges = [seg.edge for seg in self.segments]
|
||||
if len(edges) != len(set(edges)):
|
||||
raise ValueError("Duplicate edges in calibration segments")
|
||||
|
||||
# Validate LED indices don't overlap
|
||||
led_ranges = []
|
||||
for seg in self.segments:
|
||||
led_range = range(seg.led_start, seg.led_start + seg.led_count)
|
||||
led_ranges.append(led_range)
|
||||
|
||||
# Check for overlaps
|
||||
for i, range1 in enumerate(led_ranges):
|
||||
for j, range2 in enumerate(led_ranges):
|
||||
if i != j:
|
||||
overlap = set(range1) & set(range2)
|
||||
if overlap:
|
||||
raise ValueError(
|
||||
f"LED indices overlap between segments {i} and {j}: {overlap}"
|
||||
)
|
||||
|
||||
# Validate LED counts are positive
|
||||
for seg in self.segments:
|
||||
if seg.led_count <= 0:
|
||||
raise ValueError(f"LED count must be positive, got {seg.led_count}")
|
||||
if seg.led_start < 0:
|
||||
raise ValueError(f"LED start must be non-negative, got {seg.led_start}")
|
||||
|
||||
return True
|
||||
|
||||
def get_total_leds(self) -> int:
|
||||
"""Get total number of LEDs across all segments."""
|
||||
return sum(seg.led_count for seg in self.segments)
|
||||
|
||||
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
|
||||
"""Get segment configuration for a specific edge."""
|
||||
for seg in self.segments:
|
||||
if seg.edge == edge:
|
||||
return seg
|
||||
return None
|
||||
|
||||
|
||||
class PixelMapper:
|
||||
"""Maps screen border pixels to LED colors based on calibration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
calibration: CalibrationConfig,
|
||||
interpolation_mode: Literal["average", "median", "dominant"] = "average",
|
||||
):
|
||||
"""Initialize pixel mapper.
|
||||
|
||||
Args:
|
||||
calibration: Calibration configuration
|
||||
interpolation_mode: Color calculation mode
|
||||
"""
|
||||
self.calibration = calibration
|
||||
self.interpolation_mode = interpolation_mode
|
||||
|
||||
# Validate calibration
|
||||
self.calibration.validate()
|
||||
|
||||
# Select color calculation function
|
||||
if interpolation_mode == "average":
|
||||
self._calc_color = calculate_average_color
|
||||
elif interpolation_mode == "median":
|
||||
self._calc_color = calculate_median_color
|
||||
elif interpolation_mode == "dominant":
|
||||
self._calc_color = calculate_dominant_color
|
||||
else:
|
||||
raise ValueError(f"Invalid interpolation mode: {interpolation_mode}")
|
||||
|
||||
logger.info(
|
||||
f"Initialized pixel mapper with {self.calibration.get_total_leds()} LEDs "
|
||||
f"using {interpolation_mode} interpolation"
|
||||
)
|
||||
|
||||
def map_border_to_leds(
|
||||
self,
|
||||
border_pixels: BorderPixels
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
"""Map screen border pixels to LED colors.
|
||||
|
||||
Args:
|
||||
border_pixels: Extracted border pixels from screen
|
||||
|
||||
Returns:
|
||||
List of (R, G, B) tuples for each LED
|
||||
|
||||
Raises:
|
||||
ValueError: If border pixels don't match calibration
|
||||
"""
|
||||
total_leds = self.calibration.get_total_leds()
|
||||
led_colors = [(0, 0, 0)] * total_leds
|
||||
|
||||
# Process each edge
|
||||
for edge_name in ["top", "right", "bottom", "left"]:
|
||||
segment = self.calibration.get_segment_for_edge(edge_name)
|
||||
|
||||
if not segment:
|
||||
# This edge is not configured
|
||||
continue
|
||||
|
||||
# Get pixels for this edge
|
||||
if edge_name == "top":
|
||||
edge_pixels = border_pixels.top
|
||||
elif edge_name == "right":
|
||||
edge_pixels = border_pixels.right
|
||||
elif edge_name == "bottom":
|
||||
edge_pixels = border_pixels.bottom
|
||||
else: # left
|
||||
edge_pixels = border_pixels.left
|
||||
|
||||
# Divide edge into segments matching LED count
|
||||
try:
|
||||
pixel_segments = get_edge_segments(
|
||||
edge_pixels,
|
||||
segment.led_count,
|
||||
edge_name
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error(f"Failed to segment {edge_name} edge: {e}")
|
||||
raise
|
||||
|
||||
# Calculate LED indices for this segment
|
||||
led_indices = list(range(segment.led_start, segment.led_start + segment.led_count))
|
||||
|
||||
# Reverse if needed
|
||||
if segment.reverse:
|
||||
led_indices = list(reversed(led_indices))
|
||||
|
||||
# Map pixel segments to LEDs
|
||||
for led_idx, pixel_segment in zip(led_indices, pixel_segments):
|
||||
color = self._calc_color(pixel_segment)
|
||||
led_colors[led_idx] = color
|
||||
|
||||
logger.debug(f"Mapped border pixels to {total_leds} LED colors")
|
||||
return led_colors
|
||||
|
||||
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
|
||||
"""Generate test pattern to light up specific edge.
|
||||
|
||||
Useful for verifying calibration configuration.
|
||||
|
||||
Args:
|
||||
edge: Edge to light up (top, right, bottom, left)
|
||||
color: RGB color to use
|
||||
|
||||
Returns:
|
||||
List of LED colors with only the specified edge lit
|
||||
|
||||
Raises:
|
||||
ValueError: If edge is not in calibration
|
||||
"""
|
||||
segment = self.calibration.get_segment_for_edge(edge)
|
||||
if not segment:
|
||||
raise ValueError(f"Edge '{edge}' not found in calibration")
|
||||
|
||||
total_leds = self.calibration.get_total_leds()
|
||||
led_colors = [(0, 0, 0)] * total_leds
|
||||
|
||||
# Light up the specified edge
|
||||
led_indices = range(segment.led_start, segment.led_start + segment.led_count)
|
||||
for led_idx in led_indices:
|
||||
led_colors[led_idx] = color
|
||||
|
||||
logger.info(f"Generated test pattern for {edge} edge with color {color}")
|
||||
return led_colors
|
||||
|
||||
|
||||
def create_default_calibration(led_count: int) -> CalibrationConfig:
|
||||
"""Create a default calibration for a rectangular screen.
|
||||
|
||||
Assumes LEDs are evenly distributed around the screen edges in clockwise order
|
||||
starting from bottom-left.
|
||||
|
||||
Args:
|
||||
led_count: Total number of LEDs
|
||||
|
||||
Returns:
|
||||
Default calibration configuration
|
||||
"""
|
||||
if led_count < 4:
|
||||
raise ValueError("Need at least 4 LEDs for default calibration")
|
||||
|
||||
# Distribute LEDs evenly across 4 edges
|
||||
leds_per_edge = led_count // 4
|
||||
remainder = led_count % 4
|
||||
|
||||
# Distribute remainder to longer edges (bottom and top)
|
||||
bottom_count = leds_per_edge + (1 if remainder > 0 else 0)
|
||||
right_count = leds_per_edge
|
||||
top_count = leds_per_edge + (1 if remainder > 1 else 0)
|
||||
left_count = leds_per_edge + (1 if remainder > 2 else 0)
|
||||
|
||||
segments = [
|
||||
CalibrationSegment(
|
||||
edge="bottom",
|
||||
led_start=0,
|
||||
led_count=bottom_count,
|
||||
reverse=False,
|
||||
),
|
||||
CalibrationSegment(
|
||||
edge="right",
|
||||
led_start=bottom_count,
|
||||
led_count=right_count,
|
||||
reverse=False,
|
||||
),
|
||||
CalibrationSegment(
|
||||
edge="top",
|
||||
led_start=bottom_count + right_count,
|
||||
led_count=top_count,
|
||||
reverse=True,
|
||||
),
|
||||
CalibrationSegment(
|
||||
edge="left",
|
||||
led_start=bottom_count + right_count + top_count,
|
||||
led_count=left_count,
|
||||
reverse=True,
|
||||
),
|
||||
]
|
||||
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created default calibration for {led_count} LEDs: "
|
||||
f"bottom={bottom_count}, right={right_count}, "
|
||||
f"top={top_count}, left={left_count}"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def calibration_from_dict(data: dict) -> CalibrationConfig:
|
||||
"""Create calibration configuration from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary with calibration data
|
||||
|
||||
Returns:
|
||||
CalibrationConfig instance
|
||||
|
||||
Raises:
|
||||
ValueError: If data is invalid
|
||||
"""
|
||||
try:
|
||||
segments = [
|
||||
CalibrationSegment(
|
||||
edge=seg["edge"],
|
||||
led_start=seg["led_start"],
|
||||
led_count=seg["led_count"],
|
||||
reverse=seg.get("reverse", False),
|
||||
)
|
||||
for seg in data["segments"]
|
||||
]
|
||||
|
||||
config = CalibrationConfig(
|
||||
layout=data["layout"],
|
||||
start_position=data["start_position"],
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
config.validate()
|
||||
return config
|
||||
|
||||
except KeyError as e:
|
||||
raise ValueError(f"Missing required calibration field: {e}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid calibration data: {e}")
|
||||
|
||||
|
||||
def calibration_to_dict(config: CalibrationConfig) -> dict:
|
||||
"""Convert calibration configuration to dictionary.
|
||||
|
||||
Args:
|
||||
config: Calibration configuration
|
||||
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
return {
|
||||
"layout": config.layout,
|
||||
"start_position": config.start_position,
|
||||
"segments": [
|
||||
{
|
||||
"edge": seg.edge,
|
||||
"led_start": seg.led_start,
|
||||
"led_count": seg.led_count,
|
||||
"reverse": seg.reverse,
|
||||
}
|
||||
for seg in config.segments
|
||||
],
|
||||
}
|
||||
166
server/src/wled_controller/core/pixel_processor.py
Normal file
166
server/src/wled_controller/core/pixel_processor.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Pixel processing utilities for color correction and manipulation."""
|
||||
|
||||
from typing import List, Tuple
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def apply_color_correction(
|
||||
colors: List[Tuple[int, int, int]],
|
||||
gamma: float = 2.2,
|
||||
saturation: float = 1.0,
|
||||
brightness: float = 1.0,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
"""Apply color correction to LED colors.
|
||||
|
||||
Args:
|
||||
colors: List of (R, G, B) tuples
|
||||
gamma: Gamma correction factor (default 2.2)
|
||||
saturation: Saturation multiplier (0.0-2.0)
|
||||
brightness: Brightness multiplier (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Corrected list of (R, G, B) tuples
|
||||
"""
|
||||
if not colors:
|
||||
return colors
|
||||
|
||||
# Convert to numpy array for efficient processing
|
||||
colors_array = np.array(colors, dtype=np.float32) / 255.0
|
||||
|
||||
# Apply brightness
|
||||
if brightness != 1.0:
|
||||
colors_array *= brightness
|
||||
|
||||
# Apply saturation
|
||||
if saturation != 1.0:
|
||||
# Convert RGB to HSV-like saturation adjustment
|
||||
# Calculate luminance (grayscale)
|
||||
luminance = np.dot(colors_array, [0.299, 0.587, 0.114])
|
||||
luminance = luminance[:, np.newaxis] # Reshape for broadcasting
|
||||
|
||||
# Blend between grayscale and color based on saturation
|
||||
colors_array = luminance + (colors_array - luminance) * saturation
|
||||
|
||||
# Apply gamma correction
|
||||
if gamma != 1.0:
|
||||
colors_array = np.power(colors_array, 1.0 / gamma)
|
||||
|
||||
# Clamp to valid range and convert back to integers
|
||||
colors_array = np.clip(colors_array * 255.0, 0, 255).astype(np.uint8)
|
||||
|
||||
# Convert back to list of tuples
|
||||
corrected_colors = [tuple(color) for color in colors_array]
|
||||
|
||||
return corrected_colors
|
||||
|
||||
|
||||
def smooth_colors(
|
||||
current_colors: List[Tuple[int, int, int]],
|
||||
previous_colors: List[Tuple[int, int, int]],
|
||||
smoothing_factor: float = 0.5,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
"""Smooth color transitions between frames.
|
||||
|
||||
Args:
|
||||
current_colors: Current frame colors
|
||||
previous_colors: Previous frame colors
|
||||
smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing)
|
||||
|
||||
Returns:
|
||||
Smoothed colors
|
||||
"""
|
||||
if not current_colors or not previous_colors:
|
||||
return current_colors
|
||||
|
||||
if len(current_colors) != len(previous_colors):
|
||||
logger.warning(
|
||||
f"Color count mismatch: current={len(current_colors)}, "
|
||||
f"previous={len(previous_colors)}. Skipping smoothing."
|
||||
)
|
||||
return current_colors
|
||||
|
||||
if smoothing_factor <= 0:
|
||||
return current_colors
|
||||
if smoothing_factor >= 1:
|
||||
return previous_colors
|
||||
|
||||
# Convert to numpy arrays
|
||||
current = np.array(current_colors, dtype=np.float32)
|
||||
previous = np.array(previous_colors, dtype=np.float32)
|
||||
|
||||
# Blend between current and previous
|
||||
smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor
|
||||
|
||||
# Convert back to integers
|
||||
smoothed = np.clip(smoothed, 0, 255).astype(np.uint8)
|
||||
|
||||
return [tuple(color) for color in smoothed]
|
||||
|
||||
|
||||
def adjust_brightness_global(
|
||||
colors: List[Tuple[int, int, int]],
|
||||
target_brightness: int,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
"""Adjust colors to achieve target global brightness.
|
||||
|
||||
Args:
|
||||
colors: List of (R, G, B) tuples
|
||||
target_brightness: Target brightness (0-255)
|
||||
|
||||
Returns:
|
||||
Adjusted colors
|
||||
"""
|
||||
if not colors or target_brightness == 255:
|
||||
return colors
|
||||
|
||||
# Calculate scaling factor
|
||||
scale = target_brightness / 255.0
|
||||
|
||||
# Scale all colors
|
||||
scaled = [
|
||||
(
|
||||
int(r * scale),
|
||||
int(g * scale),
|
||||
int(b * scale),
|
||||
)
|
||||
for r, g, b in colors
|
||||
]
|
||||
|
||||
return scaled
|
||||
|
||||
|
||||
def limit_brightness(
|
||||
colors: List[Tuple[int, int, int]],
|
||||
max_brightness: int = 255,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
"""Limit maximum brightness of any color channel.
|
||||
|
||||
Args:
|
||||
colors: List of (R, G, B) tuples
|
||||
max_brightness: Maximum allowed brightness (0-255)
|
||||
|
||||
Returns:
|
||||
Limited colors
|
||||
"""
|
||||
if not colors or max_brightness == 255:
|
||||
return colors
|
||||
|
||||
limited = []
|
||||
for r, g, b in colors:
|
||||
# Find max channel value
|
||||
max_val = max(r, g, b)
|
||||
|
||||
if max_val > max_brightness:
|
||||
# Scale down proportionally
|
||||
scale = max_brightness / max_val
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
|
||||
limited.append((r, g, b))
|
||||
|
||||
return limited
|
||||
452
server/src/wled_controller/core/processor_manager.py
Normal file
452
server/src/wled_controller/core/processor_manager.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""Processing manager for coordinating screen capture and WLED updates."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
from wled_controller.core.calibration import (
|
||||
CalibrationConfig,
|
||||
PixelMapper,
|
||||
create_default_calibration,
|
||||
)
|
||||
from wled_controller.core.pixel_processor import apply_color_correction, smooth_colors
|
||||
from wled_controller.core.screen_capture import capture_display, extract_border_pixels
|
||||
from wled_controller.core.wled_client import WLEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingSettings:
|
||||
"""Settings for screen processing."""
|
||||
|
||||
display_index: int = 0
|
||||
fps: int = 30
|
||||
border_width: int = 10
|
||||
brightness: float = 1.0
|
||||
gamma: float = 2.2
|
||||
saturation: float = 1.0
|
||||
smoothing: float = 0.3
|
||||
interpolation_mode: str = "average"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingMetrics:
|
||||
"""Metrics for processing performance."""
|
||||
|
||||
frames_processed: int = 0
|
||||
errors_count: int = 0
|
||||
last_error: Optional[str] = None
|
||||
last_update: Optional[datetime] = None
|
||||
start_time: Optional[datetime] = None
|
||||
fps_actual: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessorState:
|
||||
"""State of a running processor."""
|
||||
|
||||
device_id: str
|
||||
device_url: str
|
||||
led_count: int
|
||||
settings: ProcessingSettings
|
||||
calibration: CalibrationConfig
|
||||
wled_client: Optional[WLEDClient] = None
|
||||
pixel_mapper: Optional[PixelMapper] = None
|
||||
is_running: bool = False
|
||||
task: Optional[asyncio.Task] = None
|
||||
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
|
||||
previous_colors: Optional[list] = None
|
||||
|
||||
|
||||
class ProcessorManager:
|
||||
"""Manages screen processing for multiple WLED devices."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize processor manager."""
|
||||
self._processors: Dict[str, ProcessorState] = {}
|
||||
logger.info("Processor manager initialized")
|
||||
|
||||
def add_device(
|
||||
self,
|
||||
device_id: str,
|
||||
device_url: str,
|
||||
led_count: int,
|
||||
settings: Optional[ProcessingSettings] = None,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
):
|
||||
"""Add a device for processing.
|
||||
|
||||
Args:
|
||||
device_id: Unique device identifier
|
||||
device_url: WLED device URL
|
||||
led_count: Number of LEDs
|
||||
settings: Processing settings (uses defaults if None)
|
||||
calibration: Calibration config (creates default if None)
|
||||
"""
|
||||
if device_id in self._processors:
|
||||
raise ValueError(f"Device {device_id} already exists")
|
||||
|
||||
if settings is None:
|
||||
settings = ProcessingSettings()
|
||||
|
||||
if calibration is None:
|
||||
calibration = create_default_calibration(led_count)
|
||||
|
||||
state = ProcessorState(
|
||||
device_id=device_id,
|
||||
device_url=device_url,
|
||||
led_count=led_count,
|
||||
settings=settings,
|
||||
calibration=calibration,
|
||||
)
|
||||
|
||||
self._processors[device_id] = state
|
||||
logger.info(f"Added device {device_id} with {led_count} LEDs")
|
||||
|
||||
def remove_device(self, device_id: str):
|
||||
"""Remove a device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found
|
||||
"""
|
||||
if device_id not in self._processors:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
# Stop processing if running
|
||||
if self._processors[device_id].is_running:
|
||||
raise RuntimeError(f"Cannot remove device {device_id} while processing")
|
||||
|
||||
del self._processors[device_id]
|
||||
logger.info(f"Removed device {device_id}")
|
||||
|
||||
def update_settings(self, device_id: str, settings: ProcessingSettings):
|
||||
"""Update processing settings for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
settings: New settings
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found
|
||||
"""
|
||||
if device_id not in self._processors:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
self._processors[device_id].settings = settings
|
||||
|
||||
# Recreate pixel mapper if interpolation mode changed
|
||||
state = self._processors[device_id]
|
||||
if state.pixel_mapper:
|
||||
state.pixel_mapper = PixelMapper(
|
||||
state.calibration,
|
||||
interpolation_mode=settings.interpolation_mode,
|
||||
)
|
||||
|
||||
logger.info(f"Updated settings for device {device_id}")
|
||||
|
||||
def update_calibration(self, device_id: str, calibration: CalibrationConfig):
|
||||
"""Update calibration for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
calibration: New calibration config
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found or calibration invalid
|
||||
"""
|
||||
if device_id not in self._processors:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
# Validate calibration
|
||||
calibration.validate()
|
||||
|
||||
# Check LED count matches
|
||||
state = self._processors[device_id]
|
||||
if calibration.get_total_leds() != state.led_count:
|
||||
raise ValueError(
|
||||
f"Calibration LED count ({calibration.get_total_leds()}) "
|
||||
f"does not match device LED count ({state.led_count})"
|
||||
)
|
||||
|
||||
state.calibration = calibration
|
||||
|
||||
# Recreate pixel mapper if running
|
||||
if state.pixel_mapper:
|
||||
state.pixel_mapper = PixelMapper(
|
||||
calibration,
|
||||
interpolation_mode=state.settings.interpolation_mode,
|
||||
)
|
||||
|
||||
logger.info(f"Updated calibration for device {device_id}")
|
||||
|
||||
async def start_processing(self, device_id: str):
|
||||
"""Start screen processing for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found
|
||||
RuntimeError: If processing already running
|
||||
"""
|
||||
if device_id not in self._processors:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
state = self._processors[device_id]
|
||||
|
||||
if state.is_running:
|
||||
raise RuntimeError(f"Processing already running for device {device_id}")
|
||||
|
||||
# Connect to WLED device
|
||||
try:
|
||||
state.wled_client = WLEDClient(state.device_url)
|
||||
await state.wled_client.connect()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
|
||||
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
||||
|
||||
# Initialize pixel mapper
|
||||
state.pixel_mapper = PixelMapper(
|
||||
state.calibration,
|
||||
interpolation_mode=state.settings.interpolation_mode,
|
||||
)
|
||||
|
||||
# Reset metrics
|
||||
state.metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
||||
state.previous_colors = None
|
||||
|
||||
# Start processing task
|
||||
state.task = asyncio.create_task(self._processing_loop(device_id))
|
||||
state.is_running = True
|
||||
|
||||
logger.info(f"Started processing for device {device_id}")
|
||||
|
||||
async def stop_processing(self, device_id: str):
|
||||
"""Stop screen processing for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found
|
||||
"""
|
||||
if device_id not in self._processors:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
state = self._processors[device_id]
|
||||
|
||||
if not state.is_running:
|
||||
logger.warning(f"Processing not running for device {device_id}")
|
||||
return
|
||||
|
||||
# Stop processing
|
||||
state.is_running = False
|
||||
|
||||
# Cancel task
|
||||
if state.task:
|
||||
state.task.cancel()
|
||||
try:
|
||||
await state.task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
state.task = None
|
||||
|
||||
# Close WLED connection
|
||||
if state.wled_client:
|
||||
await state.wled_client.close()
|
||||
state.wled_client = None
|
||||
|
||||
logger.info(f"Stopped processing for device {device_id}")
|
||||
|
||||
async def _processing_loop(self, device_id: str):
|
||||
"""Main processing loop for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
"""
|
||||
state = self._processors[device_id]
|
||||
settings = state.settings
|
||||
|
||||
logger.info(
|
||||
f"Processing loop started for {device_id} "
|
||||
f"(display={settings.display_index}, fps={settings.fps})"
|
||||
)
|
||||
|
||||
frame_time = 1.0 / settings.fps
|
||||
fps_samples = []
|
||||
|
||||
try:
|
||||
while state.is_running:
|
||||
loop_start = time.time()
|
||||
|
||||
try:
|
||||
# Capture screen
|
||||
capture = capture_display(settings.display_index)
|
||||
|
||||
# Extract border pixels
|
||||
border_pixels = extract_border_pixels(capture, settings.border_width)
|
||||
|
||||
# Map to LED colors
|
||||
led_colors = state.pixel_mapper.map_border_to_leds(border_pixels)
|
||||
|
||||
# Apply color correction
|
||||
led_colors = apply_color_correction(
|
||||
led_colors,
|
||||
gamma=settings.gamma,
|
||||
saturation=settings.saturation,
|
||||
brightness=settings.brightness,
|
||||
)
|
||||
|
||||
# Apply smoothing
|
||||
if state.previous_colors and settings.smoothing > 0:
|
||||
led_colors = smooth_colors(
|
||||
led_colors,
|
||||
state.previous_colors,
|
||||
settings.smoothing,
|
||||
)
|
||||
|
||||
# Send to WLED with brightness
|
||||
brightness_value = int(settings.brightness * 255)
|
||||
await state.wled_client.send_pixels(led_colors, brightness=brightness_value)
|
||||
|
||||
# Update metrics
|
||||
state.metrics.frames_processed += 1
|
||||
state.metrics.last_update = datetime.utcnow()
|
||||
state.previous_colors = led_colors
|
||||
|
||||
# Calculate actual FPS
|
||||
loop_time = time.time() - loop_start
|
||||
fps_samples.append(1.0 / loop_time if loop_time > 0 else 0)
|
||||
if len(fps_samples) > 10:
|
||||
fps_samples.pop(0)
|
||||
state.metrics.fps_actual = sum(fps_samples) / len(fps_samples)
|
||||
|
||||
except Exception as e:
|
||||
state.metrics.errors_count += 1
|
||||
state.metrics.last_error = str(e)
|
||||
logger.error(f"Processing error for device {device_id}: {e}")
|
||||
|
||||
# FPS control
|
||||
elapsed = time.time() - loop_start
|
||||
sleep_time = max(0, frame_time - elapsed)
|
||||
|
||||
if sleep_time > 0:
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Processing loop cancelled for device {device_id}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error in processing loop for {device_id}: {e}")
|
||||
state.is_running = False
|
||||
raise
|
||||
finally:
|
||||
logger.info(f"Processing loop ended for device {device_id}")
|
||||
|
||||
def get_state(self, device_id: str) -> dict:
|
||||
"""Get current processing state for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
State dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found
|
||||
"""
|
||||
if device_id not in self._processors:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
state = self._processors[device_id]
|
||||
metrics = state.metrics
|
||||
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"processing": state.is_running,
|
||||
"fps_actual": metrics.fps_actual if state.is_running else None,
|
||||
"fps_target": state.settings.fps,
|
||||
"display_index": state.settings.display_index,
|
||||
"last_update": metrics.last_update,
|
||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||
}
|
||||
|
||||
def get_metrics(self, device_id: str) -> dict:
|
||||
"""Get detailed metrics for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Metrics dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found
|
||||
"""
|
||||
if device_id not in self._processors:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
state = self._processors[device_id]
|
||||
metrics = state.metrics
|
||||
|
||||
# Calculate uptime
|
||||
uptime_seconds = 0.0
|
||||
if metrics.start_time and state.is_running:
|
||||
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
|
||||
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"processing": state.is_running,
|
||||
"fps_actual": metrics.fps_actual if state.is_running else None,
|
||||
"fps_target": state.settings.fps,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"frames_processed": metrics.frames_processed,
|
||||
"errors_count": metrics.errors_count,
|
||||
"last_error": metrics.last_error,
|
||||
"last_update": metrics.last_update,
|
||||
}
|
||||
|
||||
def is_processing(self, device_id: str) -> bool:
|
||||
"""Check if device is currently processing.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
True if processing
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found
|
||||
"""
|
||||
if device_id not in self._processors:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
return self._processors[device_id].is_running
|
||||
|
||||
def get_all_devices(self) -> list[str]:
|
||||
"""Get list of all device IDs.
|
||||
|
||||
Returns:
|
||||
List of device IDs
|
||||
"""
|
||||
return list(self._processors.keys())
|
||||
|
||||
async def stop_all(self):
|
||||
"""Stop processing for all devices."""
|
||||
device_ids = list(self._processors.keys())
|
||||
|
||||
for device_id in device_ids:
|
||||
if self._processors[device_id].is_running:
|
||||
try:
|
||||
await self.stop_processing(device_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping device {device_id}: {e}")
|
||||
|
||||
logger.info("Stopped all processors")
|
||||
329
server/src/wled_controller/core/screen_capture.py
Normal file
329
server/src/wled_controller/core/screen_capture.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""Screen capture functionality using mss library."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
|
||||
import mss
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from wled_controller.utils import get_logger, get_monitor_names
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayInfo:
|
||||
"""Information about a display/monitor."""
|
||||
|
||||
index: int
|
||||
name: str
|
||||
width: int
|
||||
height: int
|
||||
x: int
|
||||
y: int
|
||||
is_primary: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenCapture:
|
||||
"""Captured screen image data."""
|
||||
|
||||
image: np.ndarray
|
||||
width: int
|
||||
height: int
|
||||
display_index: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class BorderPixels:
|
||||
"""Border pixels extracted from screen edges."""
|
||||
|
||||
top: np.ndarray
|
||||
right: np.ndarray
|
||||
bottom: np.ndarray
|
||||
left: np.ndarray
|
||||
|
||||
|
||||
def get_available_displays() -> List[DisplayInfo]:
|
||||
"""Get list of available displays/monitors.
|
||||
|
||||
Returns:
|
||||
List of DisplayInfo objects for each available monitor
|
||||
|
||||
Raises:
|
||||
RuntimeError: If unable to detect displays
|
||||
"""
|
||||
try:
|
||||
# Get friendly monitor names (Windows only, falls back to generic names)
|
||||
monitor_names = get_monitor_names()
|
||||
|
||||
with mss.mss() as sct:
|
||||
displays = []
|
||||
|
||||
# Skip the first monitor (combined virtual screen on multi-monitor setups)
|
||||
for idx, monitor in enumerate(sct.monitors[1:], start=0):
|
||||
# Use friendly name from WMI if available, otherwise generic name
|
||||
friendly_name = monitor_names.get(idx, f"Display {idx}")
|
||||
|
||||
display_info = DisplayInfo(
|
||||
index=idx,
|
||||
name=friendly_name,
|
||||
width=monitor["width"],
|
||||
height=monitor["height"],
|
||||
x=monitor["left"],
|
||||
y=monitor["top"],
|
||||
is_primary=(idx == 0),
|
||||
)
|
||||
displays.append(display_info)
|
||||
|
||||
logger.info(f"Detected {len(displays)} display(s)")
|
||||
return displays
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to detect displays: {e}")
|
||||
raise RuntimeError(f"Failed to detect displays: {e}")
|
||||
|
||||
|
||||
def capture_display(display_index: int = 0) -> ScreenCapture:
|
||||
"""Capture the specified display.
|
||||
|
||||
Args:
|
||||
display_index: Index of the display to capture (0-based)
|
||||
|
||||
Returns:
|
||||
ScreenCapture object containing the captured image
|
||||
|
||||
Raises:
|
||||
ValueError: If display_index is invalid
|
||||
RuntimeError: If screen capture fails
|
||||
"""
|
||||
try:
|
||||
with mss.mss() as sct:
|
||||
# mss monitors[0] is the combined screen, monitors[1+] are individual displays
|
||||
monitor_index = display_index + 1
|
||||
|
||||
if monitor_index >= len(sct.monitors):
|
||||
raise ValueError(
|
||||
f"Invalid display index {display_index}. "
|
||||
f"Available displays: 0-{len(sct.monitors) - 2}"
|
||||
)
|
||||
|
||||
monitor = sct.monitors[monitor_index]
|
||||
|
||||
# Capture screenshot
|
||||
screenshot = sct.grab(monitor)
|
||||
|
||||
# Convert to numpy array (RGB)
|
||||
img = Image.frombytes("RGB", screenshot.size, screenshot.rgb)
|
||||
img_array = np.array(img)
|
||||
|
||||
logger.debug(
|
||||
f"Captured display {display_index}: {monitor['width']}x{monitor['height']}"
|
||||
)
|
||||
|
||||
return ScreenCapture(
|
||||
image=img_array,
|
||||
width=monitor["width"],
|
||||
height=monitor["height"],
|
||||
display_index=display_index,
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to capture display {display_index}: {e}")
|
||||
raise RuntimeError(f"Screen capture failed: {e}")
|
||||
|
||||
|
||||
def extract_border_pixels(
|
||||
screen_capture: ScreenCapture,
|
||||
border_width: int = 10
|
||||
) -> BorderPixels:
|
||||
"""Extract border pixels from screen capture.
|
||||
|
||||
Args:
|
||||
screen_capture: Captured screen image
|
||||
border_width: Width of the border in pixels to extract
|
||||
|
||||
Returns:
|
||||
BorderPixels object containing pixels from each edge
|
||||
|
||||
Raises:
|
||||
ValueError: If border_width is invalid
|
||||
"""
|
||||
if border_width < 1:
|
||||
raise ValueError("border_width must be at least 1")
|
||||
|
||||
if border_width > min(screen_capture.width, screen_capture.height) // 4:
|
||||
raise ValueError(
|
||||
f"border_width {border_width} is too large for screen size "
|
||||
f"{screen_capture.width}x{screen_capture.height}"
|
||||
)
|
||||
|
||||
img = screen_capture.image
|
||||
height, width = img.shape[:2]
|
||||
|
||||
# Extract border regions
|
||||
# Top edge: top border_width rows, full width
|
||||
top = img[:border_width, :, :]
|
||||
|
||||
# Bottom edge: bottom border_width rows, full width
|
||||
bottom = img[-border_width:, :, :]
|
||||
|
||||
# Right edge: right border_width columns, full height
|
||||
right = img[:, -border_width:, :]
|
||||
|
||||
# Left edge: left border_width columns, full height
|
||||
left = img[:, :border_width, :]
|
||||
|
||||
logger.debug(
|
||||
f"Extracted borders: top={top.shape}, right={right.shape}, "
|
||||
f"bottom={bottom.shape}, left={left.shape}"
|
||||
)
|
||||
|
||||
return BorderPixels(
|
||||
top=top,
|
||||
right=right,
|
||||
bottom=bottom,
|
||||
left=left,
|
||||
)
|
||||
|
||||
|
||||
def get_edge_segments(
|
||||
edge_pixels: np.ndarray,
|
||||
segment_count: int,
|
||||
edge_name: str
|
||||
) -> List[np.ndarray]:
|
||||
"""Divide edge pixels into segments.
|
||||
|
||||
Args:
|
||||
edge_pixels: Pixel array for one edge
|
||||
segment_count: Number of segments to divide into
|
||||
edge_name: Name of the edge (for orientation)
|
||||
|
||||
Returns:
|
||||
List of pixel arrays, one per segment
|
||||
|
||||
Raises:
|
||||
ValueError: If segment_count is invalid
|
||||
"""
|
||||
if segment_count < 1:
|
||||
raise ValueError("segment_count must be at least 1")
|
||||
|
||||
# Determine the dimension to divide
|
||||
# For top/bottom edges: divide along width (axis 1)
|
||||
# For left/right edges: divide along height (axis 0)
|
||||
if edge_name in ["top", "bottom"]:
|
||||
divide_axis = 1 # Width
|
||||
edge_length = edge_pixels.shape[1]
|
||||
else: # left, right
|
||||
divide_axis = 0 # Height
|
||||
edge_length = edge_pixels.shape[0]
|
||||
|
||||
if segment_count > edge_length:
|
||||
raise ValueError(
|
||||
f"segment_count {segment_count} is larger than edge length {edge_length}"
|
||||
)
|
||||
|
||||
# Calculate segment size
|
||||
segment_size = edge_length // segment_count
|
||||
|
||||
segments = []
|
||||
for i in range(segment_count):
|
||||
start = i * segment_size
|
||||
end = start + segment_size if i < segment_count - 1 else edge_length
|
||||
|
||||
if divide_axis == 1:
|
||||
segment = edge_pixels[:, start:end, :]
|
||||
else:
|
||||
segment = edge_pixels[start:end, :, :]
|
||||
|
||||
segments.append(segment)
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def calculate_average_color(pixels: np.ndarray) -> tuple[int, int, int]:
|
||||
"""Calculate average color of a pixel region.
|
||||
|
||||
Args:
|
||||
pixels: Pixel array (height, width, 3)
|
||||
|
||||
Returns:
|
||||
Tuple of (R, G, B) average values
|
||||
"""
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
|
||||
# Calculate mean across height and width dimensions
|
||||
mean_color = np.mean(pixels, axis=(0, 1))
|
||||
|
||||
# Convert to integers and clamp to valid range
|
||||
r = int(np.clip(mean_color[0], 0, 255))
|
||||
g = int(np.clip(mean_color[1], 0, 255))
|
||||
b = int(np.clip(mean_color[2], 0, 255))
|
||||
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def calculate_median_color(pixels: np.ndarray) -> tuple[int, int, int]:
|
||||
"""Calculate median color of a pixel region.
|
||||
|
||||
Args:
|
||||
pixels: Pixel array (height, width, 3)
|
||||
|
||||
Returns:
|
||||
Tuple of (R, G, B) median values
|
||||
"""
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
|
||||
# Calculate median across height and width dimensions
|
||||
median_color = np.median(pixels, axis=(0, 1))
|
||||
|
||||
# Convert to integers and clamp to valid range
|
||||
r = int(np.clip(median_color[0], 0, 255))
|
||||
g = int(np.clip(median_color[1], 0, 255))
|
||||
b = int(np.clip(median_color[2], 0, 255))
|
||||
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
|
||||
"""Calculate dominant color of a pixel region using simple clustering.
|
||||
|
||||
Args:
|
||||
pixels: Pixel array (height, width, 3)
|
||||
|
||||
Returns:
|
||||
Tuple of (R, G, B) dominant color values
|
||||
"""
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
|
||||
# Reshape to (n_pixels, 3)
|
||||
pixels_reshaped = pixels.reshape(-1, 3)
|
||||
|
||||
# For performance, sample pixels if there are too many
|
||||
max_samples = 1000
|
||||
if len(pixels_reshaped) > max_samples:
|
||||
indices = np.random.choice(len(pixels_reshaped), max_samples, replace=False)
|
||||
pixels_reshaped = pixels_reshaped[indices]
|
||||
|
||||
# Simple dominant color: quantize colors and find most common
|
||||
# Reduce color space to 32 levels per channel for binning
|
||||
quantized = (pixels_reshaped // 8) * 8
|
||||
|
||||
# Find unique colors and their counts
|
||||
unique_colors, counts = np.unique(quantized, axis=0, return_counts=True)
|
||||
|
||||
# Get the most common color
|
||||
dominant_idx = np.argmax(counts)
|
||||
dominant_color = unique_colors[dominant_idx]
|
||||
|
||||
r = int(np.clip(dominant_color[0], 0, 255))
|
||||
g = int(np.clip(dominant_color[1], 0, 255))
|
||||
b = int(np.clip(dominant_color[2], 0, 255))
|
||||
|
||||
return (r, g, b)
|
||||
368
server/src/wled_controller/core/wled_client.py
Normal file
368
server/src/wled_controller/core/wled_client.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""WLED HTTP client for controlling LED devices."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WLEDInfo:
|
||||
"""WLED device information."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
led_count: int
|
||||
brand: str
|
||||
product: str
|
||||
mac: str
|
||||
ip: str
|
||||
|
||||
|
||||
class WLEDClient:
|
||||
"""HTTP client for WLED devices."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
timeout: int = 5,
|
||||
retry_attempts: int = 3,
|
||||
retry_delay: int = 1,
|
||||
):
|
||||
"""Initialize WLED client.
|
||||
|
||||
Args:
|
||||
url: WLED device URL (e.g., http://192.168.1.100)
|
||||
timeout: Request timeout in seconds
|
||||
retry_attempts: Number of retry attempts on failure
|
||||
retry_delay: Delay between retries in seconds
|
||||
"""
|
||||
self.url = url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.retry_attempts = retry_attempts
|
||||
self.retry_delay = retry_delay
|
||||
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._connected = False
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await self.close()
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Establish connection to WLED device.
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
|
||||
Raises:
|
||||
RuntimeError: If connection fails
|
||||
"""
|
||||
try:
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
|
||||
# Test connection by getting device info
|
||||
info = await self.get_info()
|
||||
self._connected = True
|
||||
|
||||
logger.info(
|
||||
f"Connected to WLED device: {info.name} ({info.version}) "
|
||||
f"with {info.led_count} LEDs"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to WLED device at {self.url}: {e}")
|
||||
self._connected = False
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
||||
|
||||
async def close(self):
|
||||
"""Close the connection to WLED device."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
self._connected = False
|
||||
logger.debug(f"Closed connection to {self.url}")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected to WLED device."""
|
||||
return self._connected and self._client is not None
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
retry: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make HTTP request to WLED device with retry logic.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
endpoint: API endpoint
|
||||
json_data: JSON data for request body
|
||||
retry: Whether to retry on failure
|
||||
|
||||
Returns:
|
||||
Response JSON data
|
||||
|
||||
Raises:
|
||||
RuntimeError: If request fails after retries
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not connected. Call connect() first.")
|
||||
|
||||
url = f"{self.url}{endpoint}"
|
||||
attempts = self.retry_attempts if retry else 1
|
||||
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
if method == "GET":
|
||||
response = await self._client.get(url)
|
||||
elif method == "POST":
|
||||
response = await self._client.post(url, json=json_data)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error {e.response.status_code} on attempt {attempt + 1}: {e}")
|
||||
if attempt < attempts - 1:
|
||||
await asyncio.sleep(self.retry_delay)
|
||||
else:
|
||||
raise RuntimeError(f"HTTP request failed: {e}")
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Request error on attempt {attempt + 1}: {e}")
|
||||
if attempt < attempts - 1:
|
||||
await asyncio.sleep(self.retry_delay)
|
||||
else:
|
||||
self._connected = False
|
||||
raise RuntimeError(f"Request to WLED device failed: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error on attempt {attempt + 1}: {e}")
|
||||
if attempt < attempts - 1:
|
||||
await asyncio.sleep(self.retry_delay)
|
||||
else:
|
||||
raise RuntimeError(f"WLED request failed: {e}")
|
||||
|
||||
raise RuntimeError("Request failed after all retry attempts")
|
||||
|
||||
async def get_info(self) -> WLEDInfo:
|
||||
"""Get WLED device information.
|
||||
|
||||
Returns:
|
||||
WLEDInfo object with device details
|
||||
|
||||
Raises:
|
||||
RuntimeError: If request fails
|
||||
"""
|
||||
try:
|
||||
data = await self._request("GET", "/json/info")
|
||||
|
||||
return WLEDInfo(
|
||||
name=data.get("name", "Unknown"),
|
||||
version=data.get("ver", "Unknown"),
|
||||
led_count=data.get("leds", {}).get("count", 0),
|
||||
brand=data.get("brand", "WLED"),
|
||||
product=data.get("product", "FOSS"),
|
||||
mac=data.get("mac", ""),
|
||||
ip=data.get("ip", ""),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get device info: {e}")
|
||||
raise
|
||||
|
||||
async def get_state(self) -> Dict[str, Any]:
|
||||
"""Get current WLED device state.
|
||||
|
||||
Returns:
|
||||
State dictionary
|
||||
|
||||
Raises:
|
||||
RuntimeError: If request fails
|
||||
"""
|
||||
try:
|
||||
return await self._request("GET", "/json/state")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get device state: {e}")
|
||||
raise
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
brightness: int = 255,
|
||||
segment_id: int = 0,
|
||||
) -> bool:
|
||||
"""Send pixel colors to WLED device.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples for each LED
|
||||
brightness: Global brightness (0-255)
|
||||
segment_id: Segment ID to update
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
ValueError: If pixel values are invalid
|
||||
RuntimeError: If request fails
|
||||
"""
|
||||
# Validate inputs
|
||||
if not pixels:
|
||||
raise ValueError("Pixels list cannot be empty")
|
||||
|
||||
if not 0 <= brightness <= 255:
|
||||
raise ValueError(f"Brightness must be 0-255, got {brightness}")
|
||||
|
||||
# Validate pixel values
|
||||
for i, (r, g, b) in enumerate(pixels):
|
||||
if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
|
||||
raise ValueError(f"Invalid RGB values at index {i}: ({r}, {g}, {b})")
|
||||
|
||||
# Build WLED JSON state
|
||||
payload = {
|
||||
"on": True,
|
||||
"bri": brightness,
|
||||
"seg": [
|
||||
{
|
||||
"id": segment_id,
|
||||
"i": pixels, # Individual LED colors
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
try:
|
||||
await self._request("POST", "/json/state", json_data=payload)
|
||||
logger.debug(f"Sent {len(pixels)} pixel colors to WLED device")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send pixels: {e}")
|
||||
raise
|
||||
|
||||
async def set_power(self, on: bool) -> bool:
|
||||
"""Turn WLED device on or off.
|
||||
|
||||
Args:
|
||||
on: True to turn on, False to turn off
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
RuntimeError: If request fails
|
||||
"""
|
||||
payload = {"on": on}
|
||||
|
||||
try:
|
||||
await self._request("POST", "/json/state", json_data=payload)
|
||||
logger.info(f"Set WLED power: {'ON' if on else 'OFF'}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set power: {e}")
|
||||
raise
|
||||
|
||||
async def set_brightness(self, brightness: int) -> bool:
|
||||
"""Set global brightness.
|
||||
|
||||
Args:
|
||||
brightness: Brightness value (0-255)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
ValueError: If brightness is out of range
|
||||
RuntimeError: If request fails
|
||||
"""
|
||||
if not 0 <= brightness <= 255:
|
||||
raise ValueError(f"Brightness must be 0-255, got {brightness}")
|
||||
|
||||
payload = {"bri": brightness}
|
||||
|
||||
try:
|
||||
await self._request("POST", "/json/state", json_data=payload)
|
||||
logger.debug(f"Set brightness to {brightness}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set brightness: {e}")
|
||||
raise
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
"""Test connection to WLED device.
|
||||
|
||||
Returns:
|
||||
True if device is reachable
|
||||
|
||||
Raises:
|
||||
RuntimeError: If connection test fails
|
||||
"""
|
||||
try:
|
||||
await self.get_info()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Connection test failed: {e}")
|
||||
raise
|
||||
|
||||
async def send_test_pattern(self, led_count: int, duration: float = 2.0):
|
||||
"""Send a test pattern to verify LED configuration.
|
||||
|
||||
Cycles through red, green, blue on all LEDs.
|
||||
|
||||
Args:
|
||||
led_count: Number of LEDs
|
||||
duration: Duration for each color in seconds
|
||||
|
||||
Raises:
|
||||
RuntimeError: If test pattern fails
|
||||
"""
|
||||
logger.info(f"Sending test pattern to {led_count} LEDs")
|
||||
|
||||
try:
|
||||
# Red
|
||||
pixels = [(255, 0, 0)] * led_count
|
||||
await self.send_pixels(pixels)
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
# Green
|
||||
pixels = [(0, 255, 0)] * led_count
|
||||
await self.send_pixels(pixels)
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
# Blue
|
||||
pixels = [(0, 0, 255)] * led_count
|
||||
await self.send_pixels(pixels)
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
# Off
|
||||
pixels = [(0, 0, 0)] * led_count
|
||||
await self.send_pixels(pixels)
|
||||
|
||||
logger.info("Test pattern complete")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test pattern failed: {e}")
|
||||
raise
|
||||
166
server/src/wled_controller/main.py
Normal file
166
server/src/wled_controller/main.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""FastAPI application entry point."""
|
||||
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.api import router
|
||||
from wled_controller.api.routes import init_dependencies
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.utils import setup_logging, get_logger
|
||||
|
||||
# Initialize logging
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Get configuration
|
||||
config = get_config()
|
||||
|
||||
# Initialize storage and processing
|
||||
device_store = DeviceStore(config.storage.devices_file)
|
||||
processor_manager = ProcessorManager()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager.
|
||||
|
||||
Handles startup and shutdown events.
|
||||
"""
|
||||
# Startup
|
||||
logger.info(f"Starting WLED Screen Controller v{__version__}")
|
||||
logger.info(f"Python version: {sys.version}")
|
||||
logger.info(f"Server listening on {config.server.host}:{config.server.port}")
|
||||
|
||||
# Validate authentication configuration
|
||||
if not config.auth.api_keys:
|
||||
logger.error("=" * 70)
|
||||
logger.error("CRITICAL: No API keys configured!")
|
||||
logger.error("Authentication is REQUIRED for all API requests.")
|
||||
logger.error("Please add API keys to your configuration:")
|
||||
logger.error(" 1. Generate keys: openssl rand -hex 32")
|
||||
logger.error(" 2. Add to config/default_config.yaml under auth.api_keys")
|
||||
logger.error(" 3. Format: label: \"your-generated-key\"")
|
||||
logger.error("=" * 70)
|
||||
raise RuntimeError("No API keys configured - server cannot start without authentication")
|
||||
|
||||
# Log authentication status
|
||||
logger.info(f"API Authentication: ENFORCED ({len(config.auth.api_keys)} clients configured)")
|
||||
client_labels = ", ".join(config.auth.api_keys.keys())
|
||||
logger.info(f"Authorized clients: {client_labels}")
|
||||
logger.info("All API requests require valid Bearer token authentication")
|
||||
|
||||
# Initialize API dependencies
|
||||
init_dependencies(device_store, processor_manager)
|
||||
|
||||
# Load existing devices into processor manager
|
||||
devices = device_store.get_all_devices()
|
||||
for device in devices:
|
||||
try:
|
||||
processor_manager.add_device(
|
||||
device_id=device.id,
|
||||
device_url=device.url,
|
||||
led_count=device.led_count,
|
||||
settings=device.settings,
|
||||
calibration=device.calibration,
|
||||
)
|
||||
logger.info(f"Loaded device: {device.name} ({device.id})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load device {device.id}: {e}")
|
||||
|
||||
logger.info(f"Loaded {len(devices)} devices from storage")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down WLED Screen Controller")
|
||||
|
||||
# Stop all processing
|
||||
try:
|
||||
await processor_manager.stop_all()
|
||||
logger.info("Stopped all processors")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping processors: {e}")
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="WLED Screen Controller",
|
||||
description="Control WLED devices based on screen content for ambient lighting",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=config.server.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(router)
|
||||
|
||||
# Mount static files
|
||||
static_path = Path(__file__).parent / "static"
|
||||
if static_path.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
|
||||
logger.info(f"Mounted static files from {static_path}")
|
||||
else:
|
||||
logger.warning(f"Static files directory not found: {static_path}")
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
"""Global exception handler for unhandled errors."""
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "InternalServerError",
|
||||
"message": "An unexpected error occurred",
|
||||
"detail": str(exc) if config.server.log_level == "DEBUG" else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Serve the web UI dashboard."""
|
||||
static_path = Path(__file__).parent / "static" / "index.html"
|
||||
if static_path.exists():
|
||||
return FileResponse(static_path)
|
||||
|
||||
# Fallback to JSON if static files not found
|
||||
return {
|
||||
"name": "WLED Screen Controller",
|
||||
"version": __version__,
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
"api": "/api/v1",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"wled_controller.main:app",
|
||||
host=config.server.host,
|
||||
port=config.server.port,
|
||||
log_level=config.server.log_level.lower(),
|
||||
reload=True,
|
||||
)
|
||||
780
server/src/wled_controller/static/app.js
Normal file
780
server/src/wled_controller/static/app.js
Normal file
@@ -0,0 +1,780 @@
|
||||
const API_BASE = '/api/v1';
|
||||
let refreshInterval = null;
|
||||
let apiKey = null;
|
||||
|
||||
// Initialize app
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load API key from localStorage
|
||||
apiKey = localStorage.getItem('wled_api_key');
|
||||
|
||||
// Setup form handler
|
||||
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
|
||||
|
||||
// Show modal if no API key is stored
|
||||
if (!apiKey) {
|
||||
// Wait for modal functions to be defined
|
||||
setTimeout(() => {
|
||||
if (typeof showApiKeyModal === 'function') {
|
||||
showApiKeyModal('Welcome! Please login with your API key to get started.', true);
|
||||
}
|
||||
}, 100);
|
||||
return; // Don't load data yet
|
||||
}
|
||||
|
||||
// User is logged in, load data
|
||||
loadServerInfo();
|
||||
loadDisplays();
|
||||
loadDevices();
|
||||
|
||||
// Start auto-refresh
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
// Helper function to add auth header if needed
|
||||
function getHeaders() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Handle 401 errors by showing login modal
|
||||
function handle401Error() {
|
||||
// Clear invalid API key
|
||||
localStorage.removeItem('wled_api_key');
|
||||
apiKey = null;
|
||||
|
||||
if (typeof updateAuthUI === 'function') {
|
||||
updateAuthUI();
|
||||
}
|
||||
|
||||
if (typeof showApiKeyModal === 'function') {
|
||||
showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true);
|
||||
} else {
|
||||
showToast('Authentication failed. Please reload the page and login.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Configure API key
|
||||
function configureApiKey() {
|
||||
const currentKey = localStorage.getItem('wled_api_key');
|
||||
const message = currentKey
|
||||
? 'Current API key is set. Enter new key to update or leave blank to remove:'
|
||||
: 'Enter your API key:';
|
||||
|
||||
const key = prompt(message);
|
||||
|
||||
if (key === null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
if (key === '') {
|
||||
localStorage.removeItem('wled_api_key');
|
||||
apiKey = null;
|
||||
document.getElementById('api-key-btn').style.display = 'none';
|
||||
showToast('API key removed', 'info');
|
||||
} else {
|
||||
localStorage.setItem('wled_api_key', key);
|
||||
apiKey = key;
|
||||
document.getElementById('api-key-btn').style.display = 'inline-block';
|
||||
showToast('API key updated', 'success');
|
||||
}
|
||||
|
||||
// Reload data with new key
|
||||
loadServerInfo();
|
||||
loadDisplays();
|
||||
loadDevices();
|
||||
}
|
||||
|
||||
// Server info
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('server-version').textContent = `Version: ${data.version}`;
|
||||
document.getElementById('server-status').textContent = '●';
|
||||
document.getElementById('server-status').className = 'status-badge online';
|
||||
} catch (error) {
|
||||
console.error('Failed to load server info:', error);
|
||||
document.getElementById('server-status').className = 'status-badge offline';
|
||||
showToast('Server offline', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Load displays
|
||||
async function loadDisplays() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/config/displays`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById('displays-list');
|
||||
|
||||
if (!data.displays || data.displays.length === 0) {
|
||||
container.innerHTML = '<div class="loading">No displays available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = data.displays.map(display => `
|
||||
<div class="display-card">
|
||||
<div class="display-index">${display.name}</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Resolution:</span>
|
||||
<span class="info-value">${display.width} × ${display.height}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Position:</span>
|
||||
<span class="info-value">${display.x}, ${display.y}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to load displays:', error);
|
||||
document.getElementById('displays-list').innerHTML =
|
||||
'<div class="loading">Failed to load displays</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load devices
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const devices = data.devices || [];
|
||||
|
||||
const container = document.getElementById('devices-list');
|
||||
|
||||
if (!devices || devices.length === 0) {
|
||||
container.innerHTML = '<div class="loading">No devices attached</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch state for each device
|
||||
const devicesWithState = await Promise.all(
|
||||
devices.map(async (device) => {
|
||||
try {
|
||||
const stateResponse = await fetch(`${API_BASE}/devices/${device.id}/state`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const state = await stateResponse.json();
|
||||
|
||||
const metricsResponse = await fetch(`${API_BASE}/devices/${device.id}/metrics`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const metrics = await metricsResponse.json();
|
||||
|
||||
return { ...device, state, metrics };
|
||||
} catch (error) {
|
||||
console.error(`Failed to load state for device ${device.id}:`, error);
|
||||
return device;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
container.innerHTML = devicesWithState.map(device => createDeviceCard(device)).join('');
|
||||
|
||||
// Attach event listeners
|
||||
devicesWithState.forEach(device => {
|
||||
attachDeviceListeners(device.id);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load devices:', error);
|
||||
document.getElementById('devices-list').innerHTML =
|
||||
'<div class="loading">Failed to load devices</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function createDeviceCard(device) {
|
||||
const state = device.state || {};
|
||||
const metrics = device.metrics || {};
|
||||
const settings = device.settings || {};
|
||||
|
||||
const isProcessing = state.processing || false;
|
||||
const status = isProcessing ? 'processing' : 'idle';
|
||||
|
||||
return `
|
||||
<div class="card" data-device-id="${device.id}">
|
||||
<div class="card-header">
|
||||
<div class="card-title">${device.name || device.id}</div>
|
||||
<span class="badge ${status}">${status.toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="info-row">
|
||||
<span class="info-label">URL:</span>
|
||||
<span class="info-value">${device.url || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">LED Count:</span>
|
||||
<span class="info-value">${device.led_count || 0}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Display:</span>
|
||||
<span class="info-value">Display ${settings.display_index !== undefined ? settings.display_index : 0}</span>
|
||||
</div>
|
||||
${isProcessing ? `
|
||||
<div class="metrics-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
|
||||
<div class="metric-label">Actual FPS</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${state.fps_target || 0}</div>
|
||||
<div class="metric-label">Target FPS</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
||||
<div class="metric-label">Frames</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${metrics.errors_count || 0}</div>
|
||||
<div class="metric-label">Errors</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
${isProcessing ? `
|
||||
<button class="btn btn-danger" onclick="stopProcessing('${device.id}')">
|
||||
Stop Processing
|
||||
</button>
|
||||
` : `
|
||||
<button class="btn btn-primary" onclick="startProcessing('${device.id}')">
|
||||
Start Processing
|
||||
</button>
|
||||
`}
|
||||
<button class="btn btn-secondary" onclick="showSettings('${device.id}')">
|
||||
Settings
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="showCalibration('${device.id}')">
|
||||
Calibrate
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="removeDevice('${device.id}')">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function attachDeviceListeners(deviceId) {
|
||||
// Add any specific event listeners here if needed
|
||||
}
|
||||
|
||||
// Device actions
|
||||
async function startProcessing(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/start`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Processing started', 'success');
|
||||
loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to start: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start processing:', error);
|
||||
showToast('Failed to start processing', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcessing(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Processing stopped', 'success');
|
||||
loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to stop: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to stop processing:', error);
|
||||
showToast('Failed to stop processing', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDevice(deviceId) {
|
||||
if (!confirm('Are you sure you want to remove this device?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Device removed', 'success');
|
||||
loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to remove: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove device:', error);
|
||||
showToast('Failed to remove device', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function showSettings(deviceId) {
|
||||
try {
|
||||
// Fetch current device data
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
showToast('Failed to load device settings', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await response.json();
|
||||
|
||||
// Populate modal
|
||||
document.getElementById('settings-device-id').value = device.id;
|
||||
document.getElementById('settings-device-name').value = device.name;
|
||||
document.getElementById('settings-device-url').value = device.url;
|
||||
document.getElementById('settings-device-led-count').value = device.led_count;
|
||||
|
||||
// Set brightness (convert from 0.0-1.0 to 0-100)
|
||||
const brightnessPercent = Math.round((device.settings.brightness || 1.0) * 100);
|
||||
document.getElementById('settings-device-brightness').value = brightnessPercent;
|
||||
document.getElementById('brightness-value').textContent = brightnessPercent + '%';
|
||||
|
||||
// Show modal
|
||||
const modal = document.getElementById('device-settings-modal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Focus first input
|
||||
setTimeout(() => {
|
||||
document.getElementById('settings-device-name').focus();
|
||||
}, 100);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load device settings:', error);
|
||||
showToast('Failed to load device settings', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeviceSettingsModal() {
|
||||
const modal = document.getElementById('device-settings-modal');
|
||||
const error = document.getElementById('settings-error');
|
||||
modal.style.display = 'none';
|
||||
error.style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveDeviceSettings() {
|
||||
const deviceId = document.getElementById('settings-device-id').value;
|
||||
const name = document.getElementById('settings-device-name').value.trim();
|
||||
const url = document.getElementById('settings-device-url').value.trim();
|
||||
const led_count = parseInt(document.getElementById('settings-device-led-count').value);
|
||||
const brightnessPercent = parseInt(document.getElementById('settings-device-brightness').value);
|
||||
const brightness = brightnessPercent / 100.0; // Convert to 0.0-1.0
|
||||
const error = document.getElementById('settings-error');
|
||||
|
||||
// Validation
|
||||
if (!name || !url || !led_count || led_count < 1) {
|
||||
error.textContent = 'Please fill in all fields correctly';
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update device info (name, url, led_count)
|
||||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ name, url, led_count })
|
||||
});
|
||||
|
||||
if (deviceResponse.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deviceResponse.ok) {
|
||||
const errorData = await deviceResponse.json();
|
||||
error.textContent = `Failed to update device: ${errorData.detail}`;
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update settings (brightness)
|
||||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ brightness })
|
||||
});
|
||||
|
||||
if (settingsResponse.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsResponse.ok) {
|
||||
showToast('Device settings updated', 'success');
|
||||
closeDeviceSettingsModal();
|
||||
loadDevices();
|
||||
} else {
|
||||
const errorData = await settingsResponse.json();
|
||||
error.textContent = `Failed to update settings: ${errorData.detail}`;
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save device settings:', err);
|
||||
error.textContent = 'Failed to save settings';
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Add device form handler
|
||||
async function handleAddDevice(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = document.getElementById('device-name').value;
|
||||
const url = document.getElementById('device-url').value;
|
||||
const led_count = parseInt(document.getElementById('device-led-count').value);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ name, url, led_count })
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Device added successfully', 'success');
|
||||
event.target.reset();
|
||||
loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to add device: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add device:', error);
|
||||
showToast('Failed to add device', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh
|
||||
function startAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
|
||||
refreshInterval = setInterval(() => {
|
||||
loadDevices();
|
||||
}, 2000); // Refresh every 2 seconds
|
||||
}
|
||||
|
||||
// Toast notifications
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.className = `toast ${type} show`;
|
||||
|
||||
setTimeout(() => {
|
||||
toast.className = 'toast';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Calibration functions
|
||||
async function showCalibration(deviceId) {
|
||||
try {
|
||||
// Fetch current device data
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
showToast('Failed to load calibration', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await response.json();
|
||||
const calibration = device.calibration;
|
||||
|
||||
// Store device ID and LED count
|
||||
document.getElementById('calibration-device-id').value = device.id;
|
||||
document.getElementById('cal-device-led-count').textContent = device.led_count;
|
||||
|
||||
// Set layout
|
||||
document.getElementById('cal-start-position').value = calibration.start_position;
|
||||
document.getElementById('cal-layout').value = calibration.layout;
|
||||
|
||||
// Set LED counts per edge
|
||||
const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
calibration.segments.forEach(seg => {
|
||||
edgeCounts[seg.edge] = seg.led_count;
|
||||
});
|
||||
|
||||
document.getElementById('cal-top-leds').value = edgeCounts.top;
|
||||
document.getElementById('cal-right-leds').value = edgeCounts.right;
|
||||
document.getElementById('cal-bottom-leds').value = edgeCounts.bottom;
|
||||
document.getElementById('cal-left-leds').value = edgeCounts.left;
|
||||
|
||||
// Update preview
|
||||
updateCalibrationPreview();
|
||||
|
||||
// Show modal
|
||||
const modal = document.getElementById('calibration-modal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load calibration:', error);
|
||||
showToast('Failed to load calibration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeCalibrationModal() {
|
||||
const modal = document.getElementById('calibration-modal');
|
||||
const error = document.getElementById('calibration-error');
|
||||
modal.style.display = 'none';
|
||||
error.style.display = 'none';
|
||||
}
|
||||
|
||||
function updateCalibrationPreview() {
|
||||
// Update edge counts in preview
|
||||
document.getElementById('preview-top-count').textContent = document.getElementById('cal-top-leds').value;
|
||||
document.getElementById('preview-right-count').textContent = document.getElementById('cal-right-leds').value;
|
||||
document.getElementById('preview-bottom-count').textContent = document.getElementById('cal-bottom-leds').value;
|
||||
document.getElementById('preview-left-count').textContent = document.getElementById('cal-left-leds').value;
|
||||
|
||||
// Calculate total
|
||||
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
|
||||
parseInt(document.getElementById('cal-right-leds').value || 0) +
|
||||
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
|
||||
parseInt(document.getElementById('cal-left-leds').value || 0);
|
||||
document.getElementById('cal-total-leds').textContent = total;
|
||||
|
||||
// Update starting position indicator
|
||||
const startPos = document.getElementById('cal-start-position').value;
|
||||
const indicator = document.getElementById('start-indicator');
|
||||
|
||||
const positions = {
|
||||
'bottom_left': { bottom: '10px', left: '10px', top: 'auto', right: 'auto' },
|
||||
'bottom_right': { bottom: '10px', right: '10px', top: 'auto', left: 'auto' },
|
||||
'top_left': { top: '10px', left: '10px', bottom: 'auto', right: 'auto' },
|
||||
'top_right': { top: '10px', right: '10px', bottom: 'auto', left: 'auto' }
|
||||
};
|
||||
|
||||
const pos = positions[startPos];
|
||||
indicator.style.top = pos.top;
|
||||
indicator.style.right = pos.right;
|
||||
indicator.style.bottom = pos.bottom;
|
||||
indicator.style.left = pos.left;
|
||||
}
|
||||
|
||||
async function testCalibrationEdge(edge) {
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
const error = document.getElementById('calibration-error');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ edge, color: [255, 0, 0] }) // Red color
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast(`Testing ${edge} edge (2 seconds)`, 'info');
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
error.textContent = `Test failed: ${errorData.detail}`;
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to test edge:', err);
|
||||
error.textContent = 'Failed to test edge';
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCalibration() {
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count').textContent);
|
||||
const error = document.getElementById('calibration-error');
|
||||
|
||||
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
|
||||
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
|
||||
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
|
||||
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
|
||||
const total = topLeds + rightLeds + bottomLeds + leftLeds;
|
||||
|
||||
// Validation
|
||||
if (total !== deviceLedCount) {
|
||||
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build calibration config
|
||||
const startPosition = document.getElementById('cal-start-position').value;
|
||||
const layout = document.getElementById('cal-layout').value;
|
||||
|
||||
// Build segments based on start position and direction
|
||||
const segments = [];
|
||||
let ledStart = 0;
|
||||
|
||||
const edgeOrder = getEdgeOrder(startPosition, layout);
|
||||
|
||||
const edgeCounts = {
|
||||
top: topLeds,
|
||||
right: rightLeds,
|
||||
bottom: bottomLeds,
|
||||
left: leftLeds
|
||||
};
|
||||
|
||||
edgeOrder.forEach(edge => {
|
||||
const count = edgeCounts[edge];
|
||||
if (count > 0) {
|
||||
segments.push({
|
||||
edge: edge,
|
||||
led_start: ledStart,
|
||||
led_count: count,
|
||||
reverse: shouldReverse(edge, startPosition, layout)
|
||||
});
|
||||
ledStart += count;
|
||||
}
|
||||
});
|
||||
|
||||
const calibration = {
|
||||
layout: layout,
|
||||
start_position: startPosition,
|
||||
segments: segments
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(calibration)
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Calibration saved', 'success');
|
||||
closeCalibrationModal();
|
||||
loadDevices();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
error.textContent = `Failed to save: ${errorData.detail}`;
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save calibration:', err);
|
||||
error.textContent = 'Failed to save calibration';
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function getEdgeOrder(startPosition, layout) {
|
||||
const clockwise = ['bottom', 'right', 'top', 'left'];
|
||||
const counterclockwise = ['bottom', 'left', 'top', 'right'];
|
||||
|
||||
const orders = {
|
||||
'bottom_left_clockwise': clockwise,
|
||||
'bottom_left_counterclockwise': counterclockwise,
|
||||
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
|
||||
'bottom_right_counterclockwise': ['bottom', 'right', 'top', 'left'],
|
||||
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
|
||||
'top_left_counterclockwise': ['top', 'left', 'bottom', 'right'],
|
||||
'top_right_clockwise': ['top', 'left', 'bottom', 'right'],
|
||||
'top_right_counterclockwise': ['top', 'right', 'bottom', 'left']
|
||||
};
|
||||
|
||||
return orders[`${startPosition}_${layout}`] || clockwise;
|
||||
}
|
||||
|
||||
function shouldReverse(edge, startPosition, layout) {
|
||||
// Determine if this edge should be reversed based on LED strip direction
|
||||
const reverseRules = {
|
||||
'bottom_left_clockwise': { bottom: false, right: false, top: true, left: true },
|
||||
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
|
||||
'bottom_right_clockwise': { bottom: true, right: false, top: false, left: true },
|
||||
'bottom_right_counterclockwise': { bottom: true, right: true, top: false, left: false },
|
||||
'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
|
||||
'top_left_counterclockwise': { top: false, right: true, bottom: true, left: false },
|
||||
'top_right_clockwise': { top: true, right: false, bottom: false, left: true },
|
||||
'top_right_counterclockwise': { top: true, right: true, bottom: false, left: false }
|
||||
};
|
||||
|
||||
const rules = reverseRules[`${startPosition}_${layout}`];
|
||||
return rules ? rules[edge] : false;
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
416
server/src/wled_controller/static/index.html
Normal file
416
server/src/wled_controller/static/index.html
Normal file
@@ -0,0 +1,416 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WLED Screen Controller</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>WLED Screen Controller</h1>
|
||||
<div class="server-info">
|
||||
<span id="server-version">Version: Loading...</span>
|
||||
<span id="server-status" class="status-badge">●</span>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
|
||||
<span id="theme-icon">🌙</span>
|
||||
</button>
|
||||
<span id="auth-status" style="margin-left: 10px; display: none;">
|
||||
<span id="logged-in-user" style="color: #4CAF50; margin-right: 8px;">●</span>
|
||||
</span>
|
||||
<button id="login-btn" class="btn btn-primary" onclick="showLogin()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;">
|
||||
🔑 Login
|
||||
</button>
|
||||
<button id="logout-btn" class="btn btn-danger" onclick="logout()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;">
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="displays-section">
|
||||
<h2>Available Displays</h2>
|
||||
<div id="displays-list" class="displays-grid">
|
||||
<div class="loading">Loading displays...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="devices-section">
|
||||
<h2>WLED Devices</h2>
|
||||
<div id="devices-list" class="devices-grid">
|
||||
<div class="loading">Loading devices...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="add-device-section">
|
||||
<h2>Add New Device</h2>
|
||||
<div class="info-banner" style="margin-bottom: 20px; padding: 12px; background: rgba(33, 150, 243, 0.1); border-left: 4px solid #2196F3; border-radius: 4px;">
|
||||
<strong>📱 WLED Configuration:</strong> Configure your WLED device (effects, segments, color order, power limits, etc.) using the
|
||||
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" style="color: #2196F3; text-decoration: underline;">official WLED app</a>.
|
||||
This controller sends pixel color data and controls brightness per device.
|
||||
</div>
|
||||
<form id="add-device-form">
|
||||
<div class="form-group">
|
||||
<label for="device-name">Device Name:</label>
|
||||
<input type="text" id="device-name" placeholder="Living Room TV" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-url">WLED URL:</label>
|
||||
<input type="url" id="device-url" placeholder="http://192.168.1.100" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-led-count">LED Count:</label>
|
||||
<input type="number" id="device-led-count" value="150" min="1" required>
|
||||
<small class="input-hint">Number of LEDs configured in your WLED device</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Device</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- Calibration Modal -->
|
||||
<div id="calibration-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2>📐 LED Calibration</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="calibration-device-id">
|
||||
<p style="margin-bottom: 20px; color: var(--text-secondary);">
|
||||
Configure how your LED strip is mapped to screen edges. Use test buttons to verify each edge lights up correctly.
|
||||
</p>
|
||||
|
||||
<!-- Visual Preview -->
|
||||
<div style="margin-bottom: 25px;">
|
||||
<div style="position: relative; width: 400px; height: 250px; margin: 0 auto; background: var(--card-bg); border: 2px solid var(--border-color); border-radius: 8px;">
|
||||
<!-- Screen representation -->
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 300px; height: 180px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px;">
|
||||
Screen
|
||||
</div>
|
||||
|
||||
<!-- Edge labels -->
|
||||
<div style="position: absolute; top: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);">
|
||||
Top: <span id="preview-top-count">0</span> LEDs
|
||||
</div>
|
||||
<div style="position: absolute; right: 5px; top: 50%; transform: translateY(-50%) rotate(90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;">
|
||||
Right: <span id="preview-right-count">0</span> LEDs
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);">
|
||||
Bottom: <span id="preview-bottom-count">0</span> LEDs
|
||||
</div>
|
||||
<div style="position: absolute; left: 5px; top: 50%; transform: translateY(-50%) rotate(-90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;">
|
||||
Left: <span id="preview-left-count">0</span> LEDs
|
||||
</div>
|
||||
|
||||
<!-- Starting position indicator -->
|
||||
<div id="start-indicator" style="position: absolute; bottom: 10px; left: 10px; width: 12px; height: 12px; background: #4CAF50; border-radius: 50%; border: 2px solid white;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout Configuration -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
|
||||
<div class="form-group">
|
||||
<label for="cal-start-position">Starting Position:</label>
|
||||
<select id="cal-start-position" onchange="updateCalibrationPreview()">
|
||||
<option value="bottom_left">Bottom Left</option>
|
||||
<option value="bottom_right">Bottom Right</option>
|
||||
<option value="top_left">Top Left</option>
|
||||
<option value="top_right">Top Right</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cal-layout">Direction:</label>
|
||||
<select id="cal-layout" onchange="updateCalibrationPreview()">
|
||||
<option value="clockwise">Clockwise</option>
|
||||
<option value="counterclockwise">Counterclockwise</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LED Counts per Edge -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
|
||||
<div class="form-group">
|
||||
<label for="cal-top-leds">Top LEDs:</label>
|
||||
<input type="number" id="cal-top-leds" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cal-right-leds">Right LEDs:</label>
|
||||
<input type="number" id="cal-right-leds" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cal-bottom-leds">Bottom LEDs:</label>
|
||||
<input type="number" id="cal-bottom-leds" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cal-left-leds">Left LEDs:</label>
|
||||
<input type="number" id="cal-left-leds" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 10px; background: rgba(255, 193, 7, 0.1); border-left: 4px solid #FFC107; border-radius: 4px; margin-bottom: 20px;">
|
||||
<strong>Total LEDs:</strong> <span id="cal-total-leds">0</span> / <span id="cal-device-led-count">0</span>
|
||||
</div>
|
||||
|
||||
<!-- Test Buttons -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<p style="font-weight: 600; margin-bottom: 10px;">Test Edges (lights up each edge):</p>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;">
|
||||
<button class="btn btn-secondary" onclick="testCalibrationEdge('top')" style="font-size: 0.9rem; padding: 8px;">
|
||||
⬆️ Top
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="testCalibrationEdge('right')" style="font-size: 0.9rem; padding: 8px;">
|
||||
➡️ Right
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="testCalibrationEdge('bottom')" style="font-size: 0.9rem; padding: 8px;">
|
||||
⬇️ Bottom
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="testCalibrationEdge('left')" style="font-size: 0.9rem; padding: 8px;">
|
||||
⬅️ Left
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="calibration-error" class="error-message" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeCalibrationModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveCalibration()">Save Calibration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Settings Modal -->
|
||||
<div id="device-settings-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>⚙️ Device Settings</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="device-settings-form">
|
||||
<input type="hidden" id="settings-device-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-device-name">Device Name:</label>
|
||||
<input type="text" id="settings-device-name" placeholder="Living Room TV" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-device-url">WLED URL:</label>
|
||||
<input type="url" id="settings-device-url" placeholder="http://192.168.1.100" required>
|
||||
<small class="input-hint">IP address or hostname of your WLED device</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-device-led-count">LED Count:</label>
|
||||
<input type="number" id="settings-device-led-count" min="1" required>
|
||||
<small class="input-hint">Number of LEDs configured in your WLED device</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-device-brightness">Brightness: <span id="brightness-value">100%</span></label>
|
||||
<input type="range" id="settings-device-brightness" min="0" max="100" value="100"
|
||||
oninput="document.getElementById('brightness-value').textContent = this.value + '%'"
|
||||
style="width: 100%;">
|
||||
<small class="input-hint">Global brightness for this WLED device (0-100%)</small>
|
||||
</div>
|
||||
|
||||
<div id="settings-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeDeviceSettingsModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveDeviceSettings()">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal -->
|
||||
<div id="api-key-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>🔑 Login to WLED Controller</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-description">
|
||||
Please enter your API key to authenticate and access the WLED Screen Controller.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="api-key-input">API Key:</label>
|
||||
<div class="password-input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="api-key-input"
|
||||
placeholder="Enter your API key..."
|
||||
autocomplete="off"
|
||||
>
|
||||
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()">
|
||||
👁️
|
||||
</button>
|
||||
</div>
|
||||
<small class="input-hint">Your API key will be stored securely in your browser's local storage.</small>
|
||||
</div>
|
||||
<div id="api-key-error" class="error-message" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="submitApiKey()">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script>
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
updateThemeIcon(savedTheme);
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
const icon = document.getElementById('theme-icon');
|
||||
icon.textContent = theme === 'dark' ? '☀️' : '🌙';
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
updateThemeIcon(newTheme);
|
||||
showToast(`Switched to ${newTheme} theme`, 'info');
|
||||
}
|
||||
|
||||
// Initialize auth state
|
||||
function updateAuthUI() {
|
||||
const apiKey = localStorage.getItem('wled_api_key');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const authStatus = document.getElementById('auth-status');
|
||||
const loggedInUser = document.getElementById('logged-in-user');
|
||||
|
||||
if (apiKey) {
|
||||
// Logged in
|
||||
loginBtn.style.display = 'none';
|
||||
logoutBtn.style.display = 'inline-block';
|
||||
authStatus.style.display = 'inline';
|
||||
|
||||
// Show masked key
|
||||
const masked = apiKey.substring(0, 8) + '...';
|
||||
loggedInUser.textContent = `● Authenticated`;
|
||||
loggedInUser.title = `API Key: ${masked}`;
|
||||
} else {
|
||||
// Logged out
|
||||
loginBtn.style.display = 'inline-block';
|
||||
logoutBtn.style.display = 'none';
|
||||
authStatus.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
showApiKeyModal('Enter your API key to login and access the controller.');
|
||||
document.getElementById('modal-cancel-btn').style.display = 'inline-block';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
if (confirm('Are you sure you want to logout?')) {
|
||||
localStorage.removeItem('wled_api_key');
|
||||
apiKey = null;
|
||||
updateAuthUI();
|
||||
showToast('Logged out successfully', 'info');
|
||||
|
||||
// Clear the UI
|
||||
document.getElementById('devices-list').innerHTML = '<div class="loading">Please login to view devices</div>';
|
||||
document.getElementById('displays-list').innerHTML = '<div class="loading">Please login to view displays</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
updateAuthUI();
|
||||
|
||||
// Modal functions
|
||||
function togglePasswordVisibility() {
|
||||
const input = document.getElementById('api-key-input');
|
||||
const button = document.querySelector('.password-toggle');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
button.textContent = '🙈';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
button.textContent = '👁️';
|
||||
}
|
||||
}
|
||||
|
||||
function showApiKeyModal(message, hideCancel = false) {
|
||||
const modal = document.getElementById('api-key-modal');
|
||||
const description = document.querySelector('.modal-description');
|
||||
const input = document.getElementById('api-key-input');
|
||||
const error = document.getElementById('api-key-error');
|
||||
const cancelBtn = document.getElementById('modal-cancel-btn');
|
||||
|
||||
if (message) {
|
||||
description.textContent = message;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
input.placeholder = 'Enter your API key...';
|
||||
error.style.display = 'none';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Hide cancel button if this is required login (no existing session)
|
||||
cancelBtn.style.display = hideCancel ? 'none' : 'inline-block';
|
||||
|
||||
setTimeout(() => input.focus(), 100);
|
||||
}
|
||||
|
||||
function closeApiKeyModal() {
|
||||
const modal = document.getElementById('api-key-modal');
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function submitApiKey() {
|
||||
const input = document.getElementById('api-key-input');
|
||||
const error = document.getElementById('api-key-error');
|
||||
const key = input.value.trim();
|
||||
|
||||
if (!key) {
|
||||
error.textContent = 'Please enter an API key';
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the key
|
||||
localStorage.setItem('wled_api_key', key);
|
||||
apiKey = key;
|
||||
updateAuthUI();
|
||||
|
||||
closeApiKeyModal();
|
||||
showToast('Logged in successfully!', 'success');
|
||||
|
||||
// Reload data
|
||||
loadServerInfo();
|
||||
loadDisplays();
|
||||
loadDevices();
|
||||
|
||||
// Start auto-refresh if not already running
|
||||
if (!refreshInterval) {
|
||||
startAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Enter key in modal
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('api-key-input').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
submitApiKey();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
510
server/src/wled_controller/static/style.css
Normal file
510
server/src/wled_controller/static/style.css
Normal file
@@ -0,0 +1,510 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #4CAF50;
|
||||
--danger-color: #f44336;
|
||||
--warning-color: #ff9800;
|
||||
--info-color: #2196F3;
|
||||
}
|
||||
|
||||
/* Dark theme (default) */
|
||||
[data-theme="dark"] {
|
||||
--bg-color: #1a1a1a;
|
||||
--card-bg: #2d2d2d;
|
||||
--text-color: #e0e0e0;
|
||||
--border-color: #404040;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme="light"] {
|
||||
--bg-color: #f5f5f5;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #333333;
|
||||
--border-color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Default to dark theme */
|
||||
body {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-color);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 1.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.displays-grid,
|
||||
.devices-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.processing {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.idle {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.error {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
flex: 1 1 auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.display-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.display-index {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--info-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.add-device-section {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="url"],
|
||||
input[type="number"],
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
/* Better password field appearance */
|
||||
input[type="password"] {
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
/* Remove browser autofill styling */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--bg-color) inset;
|
||||
-webkit-text-fill-color: var(--text-color);
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 15px 20px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background: var(--info-color);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
background: var(--bg-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 2000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 24px 24px 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
color: #999;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.password-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.password-input-wrapper input {
|
||||
flex: 1;
|
||||
padding-right: 45px;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
padding: 8px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border: 1px solid var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-top: 15px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px 24px 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: transform 0.2s;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.displays-grid,
|
||||
.devices-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
5
server/src/wled_controller/storage/__init__.py
Normal file
5
server/src/wled_controller/storage/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Storage layer for device and configuration persistence."""
|
||||
|
||||
from .device_store import DeviceStore
|
||||
|
||||
__all__ = ["DeviceStore"]
|
||||
360
server/src/wled_controller/storage/device_store.py
Normal file
360
server/src/wled_controller/storage/device_store.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""Device storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.core.calibration import (
|
||||
CalibrationConfig,
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
create_default_calibration,
|
||||
)
|
||||
from wled_controller.core.processor_manager import ProcessingSettings
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Device:
|
||||
"""Represents a WLED device configuration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
name: str,
|
||||
url: str,
|
||||
led_count: int,
|
||||
enabled: bool = True,
|
||||
settings: Optional[ProcessingSettings] = None,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
created_at: Optional[datetime] = None,
|
||||
updated_at: Optional[datetime] = None,
|
||||
):
|
||||
"""Initialize device.
|
||||
|
||||
Args:
|
||||
device_id: Unique device identifier
|
||||
name: Device name
|
||||
url: WLED device URL
|
||||
led_count: Number of LEDs
|
||||
enabled: Whether device is enabled
|
||||
settings: Processing settings
|
||||
calibration: Calibration configuration
|
||||
created_at: Creation timestamp
|
||||
updated_at: Last update timestamp
|
||||
"""
|
||||
self.id = device_id
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.led_count = led_count
|
||||
self.enabled = enabled
|
||||
self.settings = settings or ProcessingSettings()
|
||||
self.calibration = calibration or create_default_calibration(led_count)
|
||||
self.created_at = created_at or datetime.utcnow()
|
||||
self.updated_at = updated_at or datetime.utcnow()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert device to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"url": self.url,
|
||||
"led_count": self.led_count,
|
||||
"enabled": self.enabled,
|
||||
"settings": {
|
||||
"display_index": self.settings.display_index,
|
||||
"fps": self.settings.fps,
|
||||
"border_width": self.settings.border_width,
|
||||
"brightness": self.settings.brightness,
|
||||
"gamma": self.settings.gamma,
|
||||
"saturation": self.settings.saturation,
|
||||
"smoothing": self.settings.smoothing,
|
||||
"interpolation_mode": self.settings.interpolation_mode,
|
||||
},
|
||||
"calibration": calibration_to_dict(self.calibration),
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Device":
|
||||
"""Create device from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary with device data
|
||||
|
||||
Returns:
|
||||
Device instance
|
||||
"""
|
||||
settings_data = data.get("settings", {})
|
||||
settings = ProcessingSettings(
|
||||
display_index=settings_data.get("display_index", 0),
|
||||
fps=settings_data.get("fps", 30),
|
||||
border_width=settings_data.get("border_width", 10),
|
||||
brightness=settings_data.get("brightness", 1.0),
|
||||
gamma=settings_data.get("gamma", 2.2),
|
||||
saturation=settings_data.get("saturation", 1.0),
|
||||
smoothing=settings_data.get("smoothing", 0.3),
|
||||
interpolation_mode=settings_data.get("interpolation_mode", "average"),
|
||||
)
|
||||
|
||||
calibration_data = data.get("calibration")
|
||||
calibration = (
|
||||
calibration_from_dict(calibration_data)
|
||||
if calibration_data
|
||||
else create_default_calibration(data["led_count"])
|
||||
)
|
||||
|
||||
return cls(
|
||||
device_id=data["id"],
|
||||
name=data["name"],
|
||||
url=data["url"],
|
||||
led_count=data["led_count"],
|
||||
enabled=data.get("enabled", True),
|
||||
settings=settings,
|
||||
calibration=calibration,
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||
)
|
||||
|
||||
|
||||
class DeviceStore:
|
||||
"""Persistent storage for WLED devices."""
|
||||
|
||||
def __init__(self, storage_file: str | Path):
|
||||
"""Initialize device store.
|
||||
|
||||
Args:
|
||||
storage_file: Path to JSON storage file
|
||||
"""
|
||||
self.storage_file = Path(storage_file)
|
||||
self._devices: Dict[str, Device] = {}
|
||||
|
||||
# Ensure directory exists
|
||||
self.storage_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing devices
|
||||
self.load()
|
||||
|
||||
logger.info(f"Device store initialized with {len(self._devices)} devices")
|
||||
|
||||
def load(self):
|
||||
"""Load devices from storage file."""
|
||||
if not self.storage_file.exists():
|
||||
logger.info(f"Storage file does not exist, starting with empty store")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.storage_file, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
devices_data = data.get("devices", {})
|
||||
self._devices = {
|
||||
device_id: Device.from_dict(device_data)
|
||||
for device_id, device_data in devices_data.items()
|
||||
}
|
||||
|
||||
logger.info(f"Loaded {len(self._devices)} devices from storage")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse storage file: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load devices: {e}")
|
||||
raise
|
||||
|
||||
def save(self):
|
||||
"""Save devices to storage file."""
|
||||
try:
|
||||
data = {
|
||||
"devices": {
|
||||
device_id: device.to_dict()
|
||||
for device_id, device in self._devices.items()
|
||||
}
|
||||
}
|
||||
|
||||
# Write to temporary file first
|
||||
temp_file = self.storage_file.with_suffix(".tmp")
|
||||
with open(temp_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Atomic rename
|
||||
temp_file.replace(self.storage_file)
|
||||
|
||||
logger.debug(f"Saved {len(self._devices)} devices to storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save devices: {e}")
|
||||
raise
|
||||
|
||||
def create_device(
|
||||
self,
|
||||
name: str,
|
||||
url: str,
|
||||
led_count: int,
|
||||
settings: Optional[ProcessingSettings] = None,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
) -> Device:
|
||||
"""Create a new device.
|
||||
|
||||
Args:
|
||||
name: Device name
|
||||
url: WLED device URL
|
||||
led_count: Number of LEDs
|
||||
settings: Processing settings
|
||||
calibration: Calibration configuration
|
||||
|
||||
Returns:
|
||||
Created device
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
# Generate unique ID
|
||||
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create device
|
||||
device = Device(
|
||||
device_id=device_id,
|
||||
name=name,
|
||||
url=url,
|
||||
led_count=led_count,
|
||||
settings=settings,
|
||||
calibration=calibration,
|
||||
)
|
||||
|
||||
# Store
|
||||
self._devices[device_id] = device
|
||||
self.save()
|
||||
|
||||
logger.info(f"Created device {device_id}: {name}")
|
||||
return device
|
||||
|
||||
def get_device(self, device_id: str) -> Optional[Device]:
|
||||
"""Get device by ID.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Device or None if not found
|
||||
"""
|
||||
return self._devices.get(device_id)
|
||||
|
||||
def get_all_devices(self) -> List[Device]:
|
||||
"""Get all devices.
|
||||
|
||||
Returns:
|
||||
List of all devices
|
||||
"""
|
||||
return list(self._devices.values())
|
||||
|
||||
def update_device(
|
||||
self,
|
||||
device_id: str,
|
||||
name: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
led_count: Optional[int] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
settings: Optional[ProcessingSettings] = None,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
) -> Device:
|
||||
"""Update device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
name: New name (optional)
|
||||
url: New URL (optional)
|
||||
led_count: New LED count (optional)
|
||||
enabled: New enabled state (optional)
|
||||
settings: New settings (optional)
|
||||
calibration: New calibration (optional)
|
||||
|
||||
Returns:
|
||||
Updated device
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found or validation fails
|
||||
"""
|
||||
device = self._devices.get(device_id)
|
||||
if not device:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
# Update fields
|
||||
if name is not None:
|
||||
device.name = name
|
||||
if url is not None:
|
||||
device.url = url
|
||||
if led_count is not None:
|
||||
device.led_count = led_count
|
||||
# Reset calibration if LED count changed
|
||||
device.calibration = create_default_calibration(led_count)
|
||||
if enabled is not None:
|
||||
device.enabled = enabled
|
||||
if settings is not None:
|
||||
device.settings = settings
|
||||
if calibration is not None:
|
||||
# Validate LED count matches
|
||||
if calibration.get_total_leds() != device.led_count:
|
||||
raise ValueError(
|
||||
f"Calibration LED count ({calibration.get_total_leds()}) "
|
||||
f"does not match device LED count ({device.led_count})"
|
||||
)
|
||||
device.calibration = calibration
|
||||
|
||||
device.updated_at = datetime.utcnow()
|
||||
|
||||
# Save
|
||||
self.save()
|
||||
|
||||
logger.info(f"Updated device {device_id}")
|
||||
return device
|
||||
|
||||
def delete_device(self, device_id: str):
|
||||
"""Delete device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found
|
||||
"""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
del self._devices[device_id]
|
||||
self.save()
|
||||
|
||||
logger.info(f"Deleted device {device_id}")
|
||||
|
||||
def device_exists(self, device_id: str) -> bool:
|
||||
"""Check if device exists.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
True if device exists
|
||||
"""
|
||||
return device_id in self._devices
|
||||
|
||||
def count(self) -> int:
|
||||
"""Get number of devices.
|
||||
|
||||
Returns:
|
||||
Device count
|
||||
"""
|
||||
return len(self._devices)
|
||||
|
||||
def clear(self):
|
||||
"""Clear all devices (for testing)."""
|
||||
self._devices.clear()
|
||||
self.save()
|
||||
logger.warning("Cleared all devices from storage")
|
||||
6
server/src/wled_controller/utils/__init__.py
Normal file
6
server/src/wled_controller/utils/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Utility functions and helpers."""
|
||||
|
||||
from .logger import setup_logging, get_logger
|
||||
from .monitor_names import get_monitor_names, get_monitor_name
|
||||
|
||||
__all__ = ["setup_logging", "get_logger", "get_monitor_names", "get_monitor_name"]
|
||||
86
server/src/wled_controller/utils/logger.py
Normal file
86
server/src/wled_controller/utils/logger.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Logging configuration and setup."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
import structlog
|
||||
from pythonjsonlogger import jsonlogger
|
||||
|
||||
from wled_controller.config import get_config
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure structured logging for the application."""
|
||||
config = get_config()
|
||||
|
||||
# Ensure log directory exists
|
||||
log_path = Path(config.logging.file)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Configure root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(config.server.log_level)
|
||||
|
||||
# Remove existing handlers
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(config.server.log_level)
|
||||
|
||||
# File handler with rotation
|
||||
file_handler = RotatingFileHandler(
|
||||
filename=str(log_path),
|
||||
maxBytes=config.logging.max_size_mb * 1024 * 1024,
|
||||
backupCount=config.logging.backup_count,
|
||||
)
|
||||
file_handler.setLevel(config.server.log_level)
|
||||
|
||||
# Configure formatter based on format setting
|
||||
if config.logging.format == "json":
|
||||
formatter = jsonlogger.JsonFormatter(
|
||||
"%(asctime)s %(name)s %(levelname)s %(message)s"
|
||||
)
|
||||
console_handler.setFormatter(formatter)
|
||||
file_handler.setFormatter(formatter)
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
console_handler.setFormatter(formatter)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Configure structlog
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.dev.set_exc_info,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.JSONRenderer()
|
||||
if config.logging.format == "json"
|
||||
else structlog.dev.ConsoleRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET),
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
"""Get a configured logger instance.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__)
|
||||
|
||||
Returns:
|
||||
Configured structlog logger
|
||||
"""
|
||||
return structlog.get_logger(name)
|
||||
79
server/src/wled_controller/utils/monitor_names.py
Normal file
79
server/src/wled_controller/utils/monitor_names.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Utility functions for retrieving friendly monitor/display names."""
|
||||
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_monitor_names() -> Dict[int, str]:
|
||||
"""Get friendly names for connected monitors.
|
||||
|
||||
On Windows, attempts to retrieve monitor names from WMI.
|
||||
On other platforms, returns empty dict (will fall back to generic names).
|
||||
|
||||
Returns:
|
||||
Dictionary mapping display indices to friendly names
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
logger.debug("Monitor name detection only supported on Windows")
|
||||
return {}
|
||||
|
||||
try:
|
||||
import wmi
|
||||
|
||||
w = wmi.WMI(namespace="wmi")
|
||||
monitors = w.WmiMonitorID()
|
||||
|
||||
monitor_names = {}
|
||||
|
||||
for idx, monitor in enumerate(monitors):
|
||||
try:
|
||||
# Extract manufacturer name
|
||||
manufacturer = ""
|
||||
if monitor.ManufacturerName:
|
||||
manufacturer = "".join(chr(c) for c in monitor.ManufacturerName if c != 0)
|
||||
|
||||
# Extract user-friendly name
|
||||
user_name = ""
|
||||
if monitor.UserFriendlyName:
|
||||
user_name = "".join(chr(c) for c in monitor.UserFriendlyName if c != 0)
|
||||
|
||||
# Build friendly name
|
||||
if user_name:
|
||||
friendly_name = user_name.strip()
|
||||
elif manufacturer:
|
||||
friendly_name = f"{manufacturer.strip()} Monitor"
|
||||
else:
|
||||
friendly_name = f"Display {idx}"
|
||||
|
||||
monitor_names[idx] = friendly_name
|
||||
logger.debug(f"Monitor {idx}: {friendly_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse monitor {idx} name: {e}")
|
||||
monitor_names[idx] = f"Display {idx}"
|
||||
|
||||
return monitor_names
|
||||
|
||||
except ImportError:
|
||||
logger.debug("WMI library not available - install with: pip install wmi")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to retrieve monitor names via WMI: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def get_monitor_name(index: int) -> str:
|
||||
"""Get friendly name for a specific monitor.
|
||||
|
||||
Args:
|
||||
index: Monitor index (0-based)
|
||||
|
||||
Returns:
|
||||
Friendly monitor name or generic fallback
|
||||
"""
|
||||
monitor_names = get_monitor_names()
|
||||
return monitor_names.get(index, f"Display {index}")
|
||||
1
server/tests/__init__.py
Normal file
1
server/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for WLED Screen Controller."""
|
||||
49
server/tests/conftest.py
Normal file
49
server/tests/conftest.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Pytest configuration and fixtures."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_data_dir(tmp_path):
|
||||
"""Provide a temporary directory for test data."""
|
||||
return tmp_path / "data"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_config_dir(tmp_path):
|
||||
"""Provide a temporary directory for test configuration."""
|
||||
return tmp_path / "config"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_calibration():
|
||||
"""Provide a sample calibration configuration."""
|
||||
return {
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [
|
||||
{"edge": "bottom", "led_start": 0, "led_count": 40, "reverse": False},
|
||||
{"edge": "right", "led_start": 40, "led_count": 30, "reverse": False},
|
||||
{"edge": "top", "led_start": 70, "led_count": 40, "reverse": True},
|
||||
{"edge": "left", "led_start": 110, "led_count": 40, "reverse": True},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_device():
|
||||
"""Provide a sample device configuration."""
|
||||
return {
|
||||
"id": "test_device_001",
|
||||
"name": "Test WLED Device",
|
||||
"url": "http://192.168.1.100",
|
||||
"led_count": 150,
|
||||
"enabled": True,
|
||||
"settings": {
|
||||
"display_index": 0,
|
||||
"fps": 30,
|
||||
"border_width": 10,
|
||||
"brightness": 0.8,
|
||||
},
|
||||
}
|
||||
75
server/tests/test_api.py
Normal file
75
server/tests/test_api.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Tests for API endpoints."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wled_controller.main import app
|
||||
from wled_controller import __version__
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_root_endpoint():
|
||||
"""Test root endpoint."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "WLED Screen Controller"
|
||||
assert data["version"] == __version__
|
||||
assert "/docs" in data["docs"]
|
||||
|
||||
|
||||
def test_health_check():
|
||||
"""Test health check endpoint."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert data["version"] == __version__
|
||||
assert "timestamp" in data
|
||||
|
||||
|
||||
def test_version_endpoint():
|
||||
"""Test version endpoint."""
|
||||
response = client.get("/api/v1/version")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["version"] == __version__
|
||||
assert "python_version" in data
|
||||
assert data["api_version"] == "v1"
|
||||
|
||||
|
||||
def test_get_displays():
|
||||
"""Test get displays endpoint."""
|
||||
response = client.get("/api/v1/config/displays")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "displays" in data
|
||||
assert "count" in data
|
||||
assert isinstance(data["displays"], list)
|
||||
assert data["count"] >= 0
|
||||
|
||||
# If displays are found, validate structure
|
||||
if data["count"] > 0:
|
||||
display = data["displays"][0]
|
||||
assert "index" in display
|
||||
assert "name" in display
|
||||
assert "width" in display
|
||||
assert "height" in display
|
||||
assert "is_primary" in display
|
||||
|
||||
|
||||
def test_openapi_docs():
|
||||
"""Test OpenAPI documentation is available."""
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["info"]["title"] == "WLED Screen Controller"
|
||||
assert data["info"]["version"] == __version__
|
||||
|
||||
|
||||
def test_swagger_ui():
|
||||
"""Test Swagger UI is available."""
|
||||
response = client.get("/docs")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
281
server/tests/test_calibration.py
Normal file
281
server/tests/test_calibration.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Tests for calibration system."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.calibration import (
|
||||
CalibrationSegment,
|
||||
CalibrationConfig,
|
||||
PixelMapper,
|
||||
create_default_calibration,
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
)
|
||||
from wled_controller.core.screen_capture import BorderPixels
|
||||
|
||||
|
||||
def test_calibration_segment():
|
||||
"""Test calibration segment creation."""
|
||||
segment = CalibrationSegment(
|
||||
edge="top",
|
||||
led_start=0,
|
||||
led_count=40,
|
||||
reverse=False,
|
||||
)
|
||||
|
||||
assert segment.edge == "top"
|
||||
assert segment.led_start == 0
|
||||
assert segment.led_count == 40
|
||||
assert segment.reverse is False
|
||||
|
||||
|
||||
def test_calibration_config_validation():
|
||||
"""Test calibration configuration validation."""
|
||||
segments = [
|
||||
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
|
||||
CalibrationSegment(edge="right", led_start=40, led_count=30),
|
||||
CalibrationSegment(edge="top", led_start=70, led_count=40),
|
||||
CalibrationSegment(edge="left", led_start=110, led_count=40),
|
||||
]
|
||||
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
assert config.validate() is True
|
||||
assert config.get_total_leds() == 150
|
||||
|
||||
|
||||
def test_calibration_config_duplicate_edges():
|
||||
"""Test validation fails with duplicate edges."""
|
||||
segments = [
|
||||
CalibrationSegment(edge="top", led_start=0, led_count=40),
|
||||
CalibrationSegment(edge="top", led_start=40, led_count=40), # Duplicate
|
||||
]
|
||||
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Duplicate edges"):
|
||||
config.validate()
|
||||
|
||||
|
||||
def test_calibration_config_overlapping_indices():
|
||||
"""Test validation fails with overlapping LED indices."""
|
||||
segments = [
|
||||
CalibrationSegment(edge="bottom", led_start=0, led_count=50),
|
||||
CalibrationSegment(edge="right", led_start=40, led_count=30), # Overlaps
|
||||
]
|
||||
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="overlap"):
|
||||
config.validate()
|
||||
|
||||
|
||||
def test_calibration_config_invalid_led_count():
|
||||
"""Test validation fails with invalid LED counts."""
|
||||
segments = [
|
||||
CalibrationSegment(edge="top", led_start=0, led_count=0), # Invalid
|
||||
]
|
||||
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
config.validate()
|
||||
|
||||
|
||||
def test_get_segment_for_edge():
|
||||
"""Test getting segment by edge name."""
|
||||
segments = [
|
||||
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
|
||||
CalibrationSegment(edge="right", led_start=40, led_count=30),
|
||||
]
|
||||
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
bottom_seg = config.get_segment_for_edge("bottom")
|
||||
assert bottom_seg is not None
|
||||
assert bottom_seg.led_count == 40
|
||||
|
||||
missing_seg = config.get_segment_for_edge("top")
|
||||
assert missing_seg is None
|
||||
|
||||
|
||||
def test_pixel_mapper_initialization():
|
||||
"""Test pixel mapper initialization."""
|
||||
config = create_default_calibration(150)
|
||||
mapper = PixelMapper(config, interpolation_mode="average")
|
||||
|
||||
assert mapper.calibration == config
|
||||
assert mapper.interpolation_mode == "average"
|
||||
|
||||
|
||||
def test_pixel_mapper_invalid_mode():
|
||||
"""Test pixel mapper with invalid interpolation mode."""
|
||||
config = create_default_calibration(150)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
PixelMapper(config, interpolation_mode="invalid")
|
||||
|
||||
|
||||
def test_pixel_mapper_map_border_to_leds():
|
||||
"""Test mapping border pixels to LED colors."""
|
||||
config = create_default_calibration(40) # 10 per edge
|
||||
mapper = PixelMapper(config)
|
||||
|
||||
# Create test border pixels (all red)
|
||||
border_pixels = BorderPixels(
|
||||
top=np.full((10, 100, 3), [255, 0, 0], dtype=np.uint8),
|
||||
right=np.full((100, 10, 3), [0, 255, 0], dtype=np.uint8),
|
||||
bottom=np.full((10, 100, 3), [0, 0, 255], dtype=np.uint8),
|
||||
left=np.full((100, 10, 3), [255, 255, 0], dtype=np.uint8),
|
||||
)
|
||||
|
||||
led_colors = mapper.map_border_to_leds(border_pixels)
|
||||
|
||||
assert len(led_colors) == 40
|
||||
assert all(isinstance(c, tuple) and len(c) == 3 for c in led_colors)
|
||||
|
||||
# Verify colors are reasonable (allowing for some rounding)
|
||||
# Bottom LEDs should be mostly blue
|
||||
bottom_color = led_colors[0]
|
||||
assert bottom_color[2] > 200 # Blue channel high
|
||||
|
||||
# Top LEDs should be mostly red
|
||||
top_segment = config.get_segment_for_edge("top")
|
||||
top_color = led_colors[top_segment.led_start]
|
||||
assert top_color[0] > 200 # Red channel high
|
||||
|
||||
|
||||
def test_pixel_mapper_test_calibration():
|
||||
"""Test calibration testing pattern."""
|
||||
config = create_default_calibration(100)
|
||||
mapper = PixelMapper(config)
|
||||
|
||||
# Test top edge
|
||||
led_colors = mapper.test_calibration("top", (255, 0, 0))
|
||||
|
||||
assert len(led_colors) == 100
|
||||
|
||||
# Top edge should be lit (red)
|
||||
top_segment = config.get_segment_for_edge("top")
|
||||
top_leds = led_colors[top_segment.led_start:top_segment.led_start + top_segment.led_count]
|
||||
assert all(color == (255, 0, 0) for color in top_leds)
|
||||
|
||||
# Other LEDs should be off
|
||||
other_leds = led_colors[:top_segment.led_start]
|
||||
assert all(color == (0, 0, 0) for color in other_leds)
|
||||
|
||||
|
||||
def test_pixel_mapper_test_calibration_invalid_edge():
|
||||
"""Test calibration testing with invalid edge."""
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=[
|
||||
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
|
||||
],
|
||||
)
|
||||
mapper = PixelMapper(config)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
mapper.test_calibration("top", (255, 0, 0)) # Top not in config
|
||||
|
||||
|
||||
def test_create_default_calibration():
|
||||
"""Test creating default calibration."""
|
||||
config = create_default_calibration(150)
|
||||
|
||||
assert config.layout == "clockwise"
|
||||
assert config.start_position == "bottom_left"
|
||||
assert len(config.segments) == 4
|
||||
assert config.get_total_leds() == 150
|
||||
|
||||
# Check all edges are present
|
||||
edges = {seg.edge for seg in config.segments}
|
||||
assert edges == {"top", "right", "bottom", "left"}
|
||||
|
||||
|
||||
def test_create_default_calibration_small_count():
|
||||
"""Test default calibration with small LED count."""
|
||||
config = create_default_calibration(4)
|
||||
assert config.get_total_leds() == 4
|
||||
|
||||
|
||||
def test_create_default_calibration_invalid():
|
||||
"""Test default calibration with invalid LED count."""
|
||||
with pytest.raises(ValueError):
|
||||
create_default_calibration(3) # Too few LEDs
|
||||
|
||||
|
||||
def test_calibration_from_dict():
|
||||
"""Test creating calibration from dictionary."""
|
||||
data = {
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [
|
||||
{"edge": "bottom", "led_start": 0, "led_count": 40, "reverse": False},
|
||||
{"edge": "right", "led_start": 40, "led_count": 30, "reverse": False},
|
||||
],
|
||||
}
|
||||
|
||||
config = calibration_from_dict(data)
|
||||
|
||||
assert config.layout == "clockwise"
|
||||
assert config.start_position == "bottom_left"
|
||||
assert len(config.segments) == 2
|
||||
assert config.get_total_leds() == 70
|
||||
|
||||
|
||||
def test_calibration_from_dict_missing_field():
|
||||
"""Test calibration from dict with missing field."""
|
||||
data = {
|
||||
"layout": "clockwise",
|
||||
# Missing start_position
|
||||
"segments": [],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
calibration_from_dict(data)
|
||||
|
||||
|
||||
def test_calibration_to_dict():
|
||||
"""Test converting calibration to dictionary."""
|
||||
config = create_default_calibration(100)
|
||||
data = calibration_to_dict(config)
|
||||
|
||||
assert "layout" in data
|
||||
assert "start_position" in data
|
||||
assert "segments" in data
|
||||
assert isinstance(data["segments"], list)
|
||||
assert len(data["segments"]) == 4
|
||||
|
||||
|
||||
def test_calibration_round_trip():
|
||||
"""Test converting calibration to dict and back."""
|
||||
original = create_default_calibration(120)
|
||||
data = calibration_to_dict(original)
|
||||
restored = calibration_from_dict(data)
|
||||
|
||||
assert restored.layout == original.layout
|
||||
assert restored.start_position == original.start_position
|
||||
assert len(restored.segments) == len(original.segments)
|
||||
assert restored.get_total_leds() == original.get_total_leds()
|
||||
120
server/tests/test_config.py
Normal file
120
server/tests/test_config.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Tests for configuration management."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from wled_controller.config import (
|
||||
Config,
|
||||
ServerConfig,
|
||||
ProcessingConfig,
|
||||
WLEDConfig,
|
||||
get_config,
|
||||
reload_config,
|
||||
)
|
||||
|
||||
|
||||
def test_default_config():
|
||||
"""Test default configuration values."""
|
||||
config = Config()
|
||||
|
||||
assert config.server.host == "0.0.0.0"
|
||||
assert config.server.port == 8080
|
||||
assert config.processing.default_fps == 30
|
||||
assert config.processing.max_fps == 60
|
||||
assert config.wled.timeout == 5
|
||||
|
||||
|
||||
def test_load_from_yaml(tmp_path):
|
||||
"""Test loading configuration from YAML file."""
|
||||
config_data = {
|
||||
"server": {"host": "127.0.0.1", "port": 9000},
|
||||
"processing": {"default_fps": 60, "border_width": 20},
|
||||
"wled": {"timeout": 10},
|
||||
}
|
||||
|
||||
config_path = tmp_path / "test_config.yaml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
config = Config.from_yaml(config_path)
|
||||
|
||||
assert config.server.host == "127.0.0.1"
|
||||
assert config.server.port == 9000
|
||||
assert config.processing.default_fps == 60
|
||||
assert config.processing.border_width == 20
|
||||
assert config.wled.timeout == 10
|
||||
|
||||
|
||||
def test_load_from_yaml_file_not_found():
|
||||
"""Test loading from non-existent YAML file."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
Config.from_yaml("nonexistent.yaml")
|
||||
|
||||
|
||||
def test_environment_variables(monkeypatch):
|
||||
"""Test configuration from environment variables."""
|
||||
monkeypatch.setenv("WLED_SERVER__HOST", "192.168.1.1")
|
||||
monkeypatch.setenv("WLED_SERVER__PORT", "7000")
|
||||
monkeypatch.setenv("WLED_PROCESSING__DEFAULT_FPS", "45")
|
||||
|
||||
config = Config()
|
||||
|
||||
assert config.server.host == "192.168.1.1"
|
||||
assert config.server.port == 7000
|
||||
assert config.processing.default_fps == 45
|
||||
|
||||
|
||||
def test_server_config():
|
||||
"""Test server configuration."""
|
||||
server_config = ServerConfig(host="localhost", port=8000)
|
||||
|
||||
assert server_config.host == "localhost"
|
||||
assert server_config.port == 8000
|
||||
assert server_config.log_level == "INFO"
|
||||
|
||||
|
||||
def test_processing_config():
|
||||
"""Test processing configuration."""
|
||||
proc_config = ProcessingConfig(default_fps=25, max_fps=50)
|
||||
|
||||
assert proc_config.default_fps == 25
|
||||
assert proc_config.max_fps == 50
|
||||
assert proc_config.interpolation_mode == "average"
|
||||
|
||||
|
||||
def test_wled_config():
|
||||
"""Test WLED configuration."""
|
||||
wled_config = WLEDConfig(timeout=10, retry_attempts=5)
|
||||
|
||||
assert wled_config.timeout == 10
|
||||
assert wled_config.retry_attempts == 5
|
||||
assert wled_config.protocol == "http"
|
||||
|
||||
|
||||
def test_config_validation():
|
||||
"""Test configuration validation."""
|
||||
# Test valid interpolation mode
|
||||
config = Config(
|
||||
processing=ProcessingConfig(interpolation_mode="median")
|
||||
)
|
||||
assert config.processing.interpolation_mode == "median"
|
||||
|
||||
# Test invalid interpolation mode
|
||||
with pytest.raises(ValueError):
|
||||
ProcessingConfig(interpolation_mode="invalid")
|
||||
|
||||
|
||||
def test_get_config():
|
||||
"""Test global config getter."""
|
||||
config = get_config()
|
||||
assert isinstance(config, Config)
|
||||
|
||||
|
||||
def test_reload_config():
|
||||
"""Test config reload."""
|
||||
config1 = get_config()
|
||||
config2 = reload_config()
|
||||
assert isinstance(config2, Config)
|
||||
305
server/tests/test_device_store.py
Normal file
305
server/tests/test_device_store.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Tests for device storage."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.storage.device_store import Device, DeviceStore
|
||||
from wled_controller.core.processor_manager import ProcessingSettings
|
||||
from wled_controller.core.calibration import create_default_calibration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_storage(tmp_path):
|
||||
"""Provide temporary storage file."""
|
||||
return tmp_path / "devices.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_store(temp_storage):
|
||||
"""Provide device store instance."""
|
||||
return DeviceStore(temp_storage)
|
||||
|
||||
|
||||
def test_device_creation():
|
||||
"""Test creating a device."""
|
||||
device = Device(
|
||||
device_id="test_001",
|
||||
name="Test Device",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
assert device.id == "test_001"
|
||||
assert device.name == "Test Device"
|
||||
assert device.url == "http://192.168.1.100"
|
||||
assert device.led_count == 150
|
||||
assert device.enabled is True
|
||||
|
||||
|
||||
def test_device_to_dict():
|
||||
"""Test converting device to dictionary."""
|
||||
device = Device(
|
||||
device_id="test_001",
|
||||
name="Test Device",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
data = device.to_dict()
|
||||
|
||||
assert data["id"] == "test_001"
|
||||
assert data["name"] == "Test Device"
|
||||
assert data["url"] == "http://192.168.1.100"
|
||||
assert data["led_count"] == 150
|
||||
assert "settings" in data
|
||||
assert "calibration" in data
|
||||
|
||||
|
||||
def test_device_from_dict():
|
||||
"""Test creating device from dictionary."""
|
||||
data = {
|
||||
"id": "test_001",
|
||||
"name": "Test Device",
|
||||
"url": "http://192.168.1.100",
|
||||
"led_count": 150,
|
||||
"enabled": True,
|
||||
"settings": {
|
||||
"display_index": 0,
|
||||
"fps": 30,
|
||||
"border_width": 10,
|
||||
},
|
||||
}
|
||||
|
||||
device = Device.from_dict(data)
|
||||
|
||||
assert device.id == "test_001"
|
||||
assert device.name == "Test Device"
|
||||
assert device.led_count == 150
|
||||
|
||||
|
||||
def test_device_round_trip():
|
||||
"""Test converting device to dict and back."""
|
||||
original = Device(
|
||||
device_id="test_001",
|
||||
name="Test Device",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = Device.from_dict(data)
|
||||
|
||||
assert restored.id == original.id
|
||||
assert restored.name == original.name
|
||||
assert restored.url == original.url
|
||||
assert restored.led_count == original.led_count
|
||||
|
||||
|
||||
def test_device_store_init(device_store):
|
||||
"""Test device store initialization."""
|
||||
assert device_store is not None
|
||||
assert device_store.count() == 0
|
||||
|
||||
|
||||
def test_create_device(device_store):
|
||||
"""Test creating a device in store."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
assert device.id is not None
|
||||
assert device.name == "Test WLED"
|
||||
assert device_store.count() == 1
|
||||
|
||||
|
||||
def test_get_device(device_store):
|
||||
"""Test retrieving a device."""
|
||||
created = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
retrieved = device_store.get_device(created.id)
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == created.id
|
||||
assert retrieved.name == "Test WLED"
|
||||
|
||||
|
||||
def test_get_device_not_found(device_store):
|
||||
"""Test retrieving non-existent device."""
|
||||
device = device_store.get_device("nonexistent")
|
||||
assert device is None
|
||||
|
||||
|
||||
def test_get_all_devices(device_store):
|
||||
"""Test getting all devices."""
|
||||
device_store.create_device("Device 1", "http://192.168.1.100", 150)
|
||||
device_store.create_device("Device 2", "http://192.168.1.101", 200)
|
||||
|
||||
devices = device_store.get_all_devices()
|
||||
|
||||
assert len(devices) == 2
|
||||
assert any(d.name == "Device 1" for d in devices)
|
||||
assert any(d.name == "Device 2" for d in devices)
|
||||
|
||||
|
||||
def test_update_device(device_store):
|
||||
"""Test updating a device."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
updated = device_store.update_device(
|
||||
device.id,
|
||||
name="Updated WLED",
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
assert updated.name == "Updated WLED"
|
||||
assert updated.enabled is False
|
||||
|
||||
|
||||
def test_update_device_settings(device_store):
|
||||
"""Test updating device settings."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
new_settings = ProcessingSettings(fps=60, border_width=20)
|
||||
|
||||
updated = device_store.update_device(
|
||||
device.id,
|
||||
settings=new_settings,
|
||||
)
|
||||
|
||||
assert updated.settings.fps == 60
|
||||
assert updated.settings.border_width == 20
|
||||
|
||||
|
||||
def test_update_device_calibration(device_store):
|
||||
"""Test updating device calibration."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
new_calibration = create_default_calibration(150)
|
||||
|
||||
updated = device_store.update_device(
|
||||
device.id,
|
||||
calibration=new_calibration,
|
||||
)
|
||||
|
||||
assert updated.calibration is not None
|
||||
|
||||
|
||||
def test_update_device_not_found(device_store):
|
||||
"""Test updating non-existent device."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
device_store.update_device("nonexistent", name="New Name")
|
||||
|
||||
|
||||
def test_delete_device(device_store):
|
||||
"""Test deleting a device."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
device_store.delete_device(device.id)
|
||||
|
||||
assert device_store.count() == 0
|
||||
assert device_store.get_device(device.id) is None
|
||||
|
||||
|
||||
def test_delete_device_not_found(device_store):
|
||||
"""Test deleting non-existent device."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
device_store.delete_device("nonexistent")
|
||||
|
||||
|
||||
def test_device_exists(device_store):
|
||||
"""Test checking if device exists."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
assert device_store.device_exists(device.id) is True
|
||||
assert device_store.device_exists("nonexistent") is False
|
||||
|
||||
|
||||
def test_persistence(temp_storage):
|
||||
"""Test device persistence across store instances."""
|
||||
# Create store and add device
|
||||
store1 = DeviceStore(temp_storage)
|
||||
device = store1.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
device_id = device.id
|
||||
|
||||
# Create new store instance (loads from file)
|
||||
store2 = DeviceStore(temp_storage)
|
||||
|
||||
# Verify device persisted
|
||||
loaded_device = store2.get_device(device_id)
|
||||
assert loaded_device is not None
|
||||
assert loaded_device.name == "Test WLED"
|
||||
assert loaded_device.led_count == 150
|
||||
|
||||
|
||||
def test_clear(device_store):
|
||||
"""Test clearing all devices."""
|
||||
device_store.create_device("Device 1", "http://192.168.1.100", 150)
|
||||
device_store.create_device("Device 2", "http://192.168.1.101", 200)
|
||||
|
||||
assert device_store.count() == 2
|
||||
|
||||
device_store.clear()
|
||||
|
||||
assert device_store.count() == 0
|
||||
|
||||
|
||||
def test_update_led_count_resets_calibration(device_store):
|
||||
"""Test that updating LED count resets calibration."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
original_calibration = device.calibration
|
||||
|
||||
# Update LED count
|
||||
updated = device_store.update_device(device.id, led_count=200)
|
||||
|
||||
# Calibration should be reset for new LED count
|
||||
assert updated.calibration.get_total_leds() == 200
|
||||
assert updated.calibration != original_calibration
|
||||
|
||||
|
||||
def test_update_calibration_led_count_mismatch(device_store):
|
||||
"""Test updating calibration with mismatched LED count fails."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
wrong_calibration = create_default_calibration(100)
|
||||
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
device_store.update_device(device.id, calibration=wrong_calibration)
|
||||
254
server/tests/test_processor_manager.py
Normal file
254
server/tests/test_processor_manager.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Tests for processor manager."""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from wled_controller.core.processor_manager import (
|
||||
ProcessorManager,
|
||||
ProcessingSettings,
|
||||
)
|
||||
from wled_controller.core.calibration import create_default_calibration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wled_url():
|
||||
"""Provide test WLED device URL."""
|
||||
return "http://192.168.1.100"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_wled_responses():
|
||||
"""Provide mock WLED API responses."""
|
||||
return {
|
||||
"info": {
|
||||
"name": "Test WLED",
|
||||
"ver": "0.14.0",
|
||||
"leds": {"count": 150},
|
||||
"brand": "WLED",
|
||||
"product": "FOSS",
|
||||
"mac": "AA:BB:CC:DD:EE:FF",
|
||||
"ip": "192.168.1.100",
|
||||
},
|
||||
"state": {"on": True, "bri": 255},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor_manager():
|
||||
"""Provide processor manager instance."""
|
||||
return ProcessorManager()
|
||||
|
||||
|
||||
def test_processor_manager_init():
|
||||
"""Test processor manager initialization."""
|
||||
manager = ProcessorManager()
|
||||
assert manager is not None
|
||||
assert manager.get_all_devices() == []
|
||||
|
||||
|
||||
def test_add_device(processor_manager):
|
||||
"""Test adding a device."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
devices = processor_manager.get_all_devices()
|
||||
assert "test_device" in devices
|
||||
|
||||
|
||||
def test_add_device_duplicate(processor_manager):
|
||||
"""Test adding duplicate device fails."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
|
||||
def test_remove_device(processor_manager):
|
||||
"""Test removing a device."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
processor_manager.remove_device("test_device")
|
||||
|
||||
assert "test_device" not in processor_manager.get_all_devices()
|
||||
|
||||
|
||||
def test_remove_device_not_found(processor_manager):
|
||||
"""Test removing non-existent device fails."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
processor_manager.remove_device("nonexistent")
|
||||
|
||||
|
||||
def test_update_settings(processor_manager):
|
||||
"""Test updating device settings."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
new_settings = ProcessingSettings(
|
||||
display_index=1,
|
||||
fps=60,
|
||||
border_width=20,
|
||||
)
|
||||
|
||||
processor_manager.update_settings("test_device", new_settings)
|
||||
|
||||
# Verify settings updated
|
||||
state = processor_manager.get_state("test_device")
|
||||
assert state["fps_target"] == 60
|
||||
|
||||
|
||||
def test_update_calibration(processor_manager):
|
||||
"""Test updating device calibration."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
new_calibration = create_default_calibration(150)
|
||||
|
||||
processor_manager.update_calibration("test_device", new_calibration)
|
||||
|
||||
|
||||
def test_update_calibration_led_count_mismatch(processor_manager):
|
||||
"""Test updating calibration with mismatched LED count fails."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
wrong_calibration = create_default_calibration(100) # Wrong count
|
||||
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
processor_manager.update_calibration("test_device", wrong_calibration)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_start_processing(processor_manager, wled_url, mock_wled_responses):
|
||||
"""Test starting processing."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_responses["info"])
|
||||
)
|
||||
respx.post(f"{wled_url}/json/state").mock(
|
||||
return_value=Response(200, json={"success": True})
|
||||
)
|
||||
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url=wled_url,
|
||||
led_count=150,
|
||||
settings=ProcessingSettings(fps=5), # Low FPS for testing
|
||||
)
|
||||
|
||||
await processor_manager.start_processing("test_device")
|
||||
|
||||
assert processor_manager.is_processing("test_device") is True
|
||||
|
||||
# Let it process a few frames
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Stop processing
|
||||
await processor_manager.stop_processing("test_device")
|
||||
|
||||
assert processor_manager.is_processing("test_device") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_processing_already_running(processor_manager):
|
||||
"""Test starting processing when already running fails."""
|
||||
# This test would need mocked WLED responses
|
||||
# Skipping actual connection for simplicity
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_processing_not_running(processor_manager):
|
||||
"""Test stopping processing when not running."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
# Should not raise error
|
||||
await processor_manager.stop_processing("test_device")
|
||||
|
||||
|
||||
def test_get_state(processor_manager):
|
||||
"""Test getting device state."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
settings=ProcessingSettings(fps=30, display_index=0),
|
||||
)
|
||||
|
||||
state = processor_manager.get_state("test_device")
|
||||
|
||||
assert state["device_id"] == "test_device"
|
||||
assert state["processing"] is False
|
||||
assert state["fps_target"] == 30
|
||||
assert state["display_index"] == 0
|
||||
|
||||
|
||||
def test_get_state_not_found(processor_manager):
|
||||
"""Test getting state for non-existent device."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
processor_manager.get_state("nonexistent")
|
||||
|
||||
|
||||
def test_get_metrics(processor_manager):
|
||||
"""Test getting device metrics."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
metrics = processor_manager.get_metrics("test_device")
|
||||
|
||||
assert metrics["device_id"] == "test_device"
|
||||
assert metrics["processing"] is False
|
||||
assert metrics["frames_processed"] == 0
|
||||
assert metrics["errors_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_all(processor_manager):
|
||||
"""Test stopping all processors."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device1",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
processor_manager.add_device(
|
||||
device_id="test_device2",
|
||||
device_url="http://192.168.1.101",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
await processor_manager.stop_all()
|
||||
|
||||
assert processor_manager.is_processing("test_device1") is False
|
||||
assert processor_manager.is_processing("test_device2") is False
|
||||
223
server/tests/test_screen_capture.py
Normal file
223
server/tests/test_screen_capture.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Tests for screen capture functionality."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.screen_capture import (
|
||||
get_available_displays,
|
||||
capture_display,
|
||||
extract_border_pixels,
|
||||
get_edge_segments,
|
||||
calculate_average_color,
|
||||
calculate_median_color,
|
||||
calculate_dominant_color,
|
||||
ScreenCapture,
|
||||
)
|
||||
|
||||
|
||||
def test_get_available_displays():
|
||||
"""Test getting available displays."""
|
||||
displays = get_available_displays()
|
||||
|
||||
assert isinstance(displays, list)
|
||||
assert len(displays) >= 1 # At least one display should be available
|
||||
|
||||
# Check first display structure
|
||||
display = displays[0]
|
||||
assert hasattr(display, "index")
|
||||
assert hasattr(display, "name")
|
||||
assert hasattr(display, "width")
|
||||
assert hasattr(display, "height")
|
||||
assert display.width > 0
|
||||
assert display.height > 0
|
||||
|
||||
|
||||
def test_capture_display():
|
||||
"""Test capturing a display."""
|
||||
# Capture the first display
|
||||
capture = capture_display(0)
|
||||
|
||||
assert isinstance(capture, ScreenCapture)
|
||||
assert capture.image is not None
|
||||
assert capture.width > 0
|
||||
assert capture.height > 0
|
||||
assert capture.display_index == 0
|
||||
assert isinstance(capture.image, np.ndarray)
|
||||
assert capture.image.shape == (capture.height, capture.width, 3)
|
||||
|
||||
|
||||
def test_capture_display_invalid_index():
|
||||
"""Test capturing with invalid display index."""
|
||||
with pytest.raises(ValueError):
|
||||
capture_display(999) # Invalid display index
|
||||
|
||||
|
||||
def test_extract_border_pixels():
|
||||
"""Test extracting border pixels."""
|
||||
# Create a test screen capture
|
||||
test_image = np.random.randint(0, 256, (100, 200, 3), dtype=np.uint8)
|
||||
capture = ScreenCapture(
|
||||
image=test_image,
|
||||
width=200,
|
||||
height=100,
|
||||
display_index=0
|
||||
)
|
||||
|
||||
border_width = 10
|
||||
borders = extract_border_pixels(capture, border_width)
|
||||
|
||||
# Check border shapes
|
||||
assert borders.top.shape == (border_width, 200, 3)
|
||||
assert borders.bottom.shape == (border_width, 200, 3)
|
||||
assert borders.left.shape == (100, border_width, 3)
|
||||
assert borders.right.shape == (100, border_width, 3)
|
||||
|
||||
|
||||
def test_extract_border_pixels_invalid_width():
|
||||
"""Test extracting borders with invalid width."""
|
||||
test_image = np.random.randint(0, 256, (100, 200, 3), dtype=np.uint8)
|
||||
capture = ScreenCapture(
|
||||
image=test_image,
|
||||
width=200,
|
||||
height=100,
|
||||
display_index=0
|
||||
)
|
||||
|
||||
# Border width too small
|
||||
with pytest.raises(ValueError):
|
||||
extract_border_pixels(capture, 0)
|
||||
|
||||
# Border width too large
|
||||
with pytest.raises(ValueError):
|
||||
extract_border_pixels(capture, 50)
|
||||
|
||||
|
||||
def test_get_edge_segments():
|
||||
"""Test dividing edge into segments."""
|
||||
# Create test edge pixels (horizontal edge)
|
||||
edge_pixels = np.random.randint(0, 256, (10, 100, 3), dtype=np.uint8)
|
||||
|
||||
segments = get_edge_segments(edge_pixels, 10, "top")
|
||||
|
||||
assert len(segments) == 10
|
||||
# Each segment should have width of approximately 10
|
||||
for segment in segments:
|
||||
assert segment.shape[0] == 10 # Height stays same
|
||||
assert 8 <= segment.shape[1] <= 12 # Width varies slightly
|
||||
assert segment.shape[2] == 3 # RGB
|
||||
|
||||
|
||||
def test_get_edge_segments_vertical():
|
||||
"""Test dividing vertical edge into segments."""
|
||||
# Create test edge pixels (vertical edge)
|
||||
edge_pixels = np.random.randint(0, 256, (100, 10, 3), dtype=np.uint8)
|
||||
|
||||
segments = get_edge_segments(edge_pixels, 10, "left")
|
||||
|
||||
assert len(segments) == 10
|
||||
# Each segment should have height of approximately 10
|
||||
for segment in segments:
|
||||
assert 8 <= segment.shape[0] <= 12 # Height varies slightly
|
||||
assert segment.shape[1] == 10 # Width stays same
|
||||
assert segment.shape[2] == 3 # RGB
|
||||
|
||||
|
||||
def test_get_edge_segments_invalid():
|
||||
"""Test edge segments with invalid parameters."""
|
||||
edge_pixels = np.random.randint(0, 256, (10, 100, 3), dtype=np.uint8)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
get_edge_segments(edge_pixels, 0, "top")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
get_edge_segments(edge_pixels, 200, "top") # More segments than pixels
|
||||
|
||||
|
||||
def test_calculate_average_color():
|
||||
"""Test calculating average color."""
|
||||
# Create uniform color region
|
||||
pixels = np.full((10, 10, 3), [100, 150, 200], dtype=np.uint8)
|
||||
|
||||
color = calculate_average_color(pixels)
|
||||
|
||||
assert color == (100, 150, 200)
|
||||
|
||||
|
||||
def test_calculate_average_color_mixed():
|
||||
"""Test average color with mixed colors."""
|
||||
# Create region with two colors
|
||||
pixels = np.zeros((10, 10, 3), dtype=np.uint8)
|
||||
pixels[:5, :, :] = [255, 0, 0] # Top half red
|
||||
pixels[5:, :, :] = [0, 0, 255] # Bottom half blue
|
||||
|
||||
color = calculate_average_color(pixels)
|
||||
|
||||
# Should be roughly purple (average of red and blue)
|
||||
assert 120 <= color[0] <= 135 # R
|
||||
assert 0 <= color[1] <= 10 # G
|
||||
assert 120 <= color[2] <= 135 # B
|
||||
|
||||
|
||||
def test_calculate_median_color():
|
||||
"""Test calculating median color."""
|
||||
# Create region with outliers
|
||||
pixels = np.full((10, 10, 3), [100, 100, 100], dtype=np.uint8)
|
||||
pixels[0, 0, :] = [255, 255, 255] # One bright outlier
|
||||
|
||||
color = calculate_median_color(pixels)
|
||||
|
||||
# Median should be close to 100, not affected by outlier
|
||||
assert 95 <= color[0] <= 105
|
||||
assert 95 <= color[1] <= 105
|
||||
assert 95 <= color[2] <= 105
|
||||
|
||||
|
||||
def test_calculate_dominant_color():
|
||||
"""Test calculating dominant color."""
|
||||
# Create region with mostly one color
|
||||
pixels = np.full((20, 20, 3), [100, 150, 200], dtype=np.uint8)
|
||||
# Add some noise
|
||||
pixels[:2, :2, :] = [50, 75, 100]
|
||||
|
||||
color = calculate_dominant_color(pixels)
|
||||
|
||||
# Dominant color should be close to the main color
|
||||
assert 90 <= color[0] <= 110
|
||||
assert 140 <= color[1] <= 160
|
||||
assert 190 <= color[2] <= 210
|
||||
|
||||
|
||||
def test_calculate_color_empty_pixels():
|
||||
"""Test color calculation with empty pixel array."""
|
||||
empty_pixels = np.array([]).reshape(0, 0, 3)
|
||||
|
||||
assert calculate_average_color(empty_pixels) == (0, 0, 0)
|
||||
assert calculate_median_color(empty_pixels) == (0, 0, 0)
|
||||
assert calculate_dominant_color(empty_pixels) == (0, 0, 0)
|
||||
|
||||
|
||||
def test_end_to_end_screen_capture():
|
||||
"""Test complete screen capture workflow."""
|
||||
# Get available displays
|
||||
displays = get_available_displays()
|
||||
assert len(displays) > 0
|
||||
|
||||
# Capture first display
|
||||
capture = capture_display(0)
|
||||
assert capture is not None
|
||||
|
||||
# Extract borders
|
||||
borders = extract_border_pixels(capture, 10)
|
||||
assert borders.top is not None
|
||||
assert borders.bottom is not None
|
||||
assert borders.left is not None
|
||||
assert borders.right is not None
|
||||
|
||||
# Get segments for top edge
|
||||
top_segments = get_edge_segments(borders.top, 10, "top")
|
||||
assert len(top_segments) == 10
|
||||
|
||||
# Calculate color for first segment
|
||||
color = calculate_average_color(top_segments[0])
|
||||
assert len(color) == 3
|
||||
assert all(0 <= c <= 255 for c in color)
|
||||
253
server/tests/test_wled_client.py
Normal file
253
server/tests/test_wled_client.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Tests for WLED client."""
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from wled_controller.core.wled_client import WLEDClient, WLEDInfo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wled_url():
|
||||
"""Provide test WLED device URL."""
|
||||
return "http://192.168.1.100"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_wled_info():
|
||||
"""Provide mock WLED info response."""
|
||||
return {
|
||||
"name": "Test WLED",
|
||||
"ver": "0.14.0",
|
||||
"leds": {"count": 150},
|
||||
"brand": "WLED",
|
||||
"product": "FOSS",
|
||||
"mac": "AA:BB:CC:DD:EE:FF",
|
||||
"ip": "192.168.1.100",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_wled_state():
|
||||
"""Provide mock WLED state response."""
|
||||
return {
|
||||
"on": True,
|
||||
"bri": 255,
|
||||
"seg": [{"id": 0, "on": True}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_wled_client_connect(wled_url, mock_wled_info):
|
||||
"""Test connecting to WLED device."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_info)
|
||||
)
|
||||
|
||||
client = WLEDClient(wled_url)
|
||||
success = await client.connect()
|
||||
|
||||
assert success is True
|
||||
assert client.is_connected is True
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_wled_client_connect_failure(wled_url):
|
||||
"""Test connection failure handling."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(500, text="Internal Server Error")
|
||||
)
|
||||
|
||||
client = WLEDClient(wled_url, retry_attempts=1)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await client.connect()
|
||||
|
||||
assert client.is_connected is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_get_info(wled_url, mock_wled_info):
|
||||
"""Test getting device info."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_info)
|
||||
)
|
||||
|
||||
async with WLEDClient(wled_url) as client:
|
||||
info = await client.get_info()
|
||||
|
||||
assert isinstance(info, WLEDInfo)
|
||||
assert info.name == "Test WLED"
|
||||
assert info.version == "0.14.0"
|
||||
assert info.led_count == 150
|
||||
assert info.mac == "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_get_state(wled_url, mock_wled_info, mock_wled_state):
|
||||
"""Test getting device state."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_info)
|
||||
)
|
||||
respx.get(f"{wled_url}/json/state").mock(
|
||||
return_value=Response(200, json=mock_wled_state)
|
||||
)
|
||||
|
||||
async with WLEDClient(wled_url) as client:
|
||||
state = await client.get_state()
|
||||
|
||||
assert state["on"] is True
|
||||
assert state["bri"] == 255
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_send_pixels(wled_url, mock_wled_info):
|
||||
"""Test sending pixel data."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_info)
|
||||
)
|
||||
respx.post(f"{wled_url}/json/state").mock(
|
||||
return_value=Response(200, json={"success": True})
|
||||
)
|
||||
|
||||
async with WLEDClient(wled_url) as client:
|
||||
pixels = [
|
||||
(255, 0, 0), # Red
|
||||
(0, 255, 0), # Green
|
||||
(0, 0, 255), # Blue
|
||||
]
|
||||
|
||||
success = await client.send_pixels(pixels, brightness=200)
|
||||
assert success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_send_pixels_invalid_values(wled_url, mock_wled_info):
|
||||
"""Test sending invalid pixel values."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_info)
|
||||
)
|
||||
|
||||
async with WLEDClient(wled_url) as client:
|
||||
# Invalid RGB value
|
||||
with pytest.raises(ValueError):
|
||||
await client.send_pixels([(300, 0, 0)])
|
||||
|
||||
# Invalid brightness
|
||||
with pytest.raises(ValueError):
|
||||
await client.send_pixels([(255, 0, 0)], brightness=300)
|
||||
|
||||
# Empty pixels list
|
||||
with pytest.raises(ValueError):
|
||||
await client.send_pixels([])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_set_power(wled_url, mock_wled_info):
|
||||
"""Test turning device on/off."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_info)
|
||||
)
|
||||
respx.post(f"{wled_url}/json/state").mock(
|
||||
return_value=Response(200, json={"success": True})
|
||||
)
|
||||
|
||||
async with WLEDClient(wled_url) as client:
|
||||
# Turn on
|
||||
success = await client.set_power(True)
|
||||
assert success is True
|
||||
|
||||
# Turn off
|
||||
success = await client.set_power(False)
|
||||
assert success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_set_brightness(wled_url, mock_wled_info):
|
||||
"""Test setting brightness."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_info)
|
||||
)
|
||||
respx.post(f"{wled_url}/json/state").mock(
|
||||
return_value=Response(200, json={"success": True})
|
||||
)
|
||||
|
||||
async with WLEDClient(wled_url) as client:
|
||||
success = await client.set_brightness(128)
|
||||
assert success is True
|
||||
|
||||
# Invalid brightness
|
||||
with pytest.raises(ValueError):
|
||||
await client.set_brightness(300)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_test_connection(wled_url, mock_wled_info):
|
||||
"""Test connection testing."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_info)
|
||||
)
|
||||
|
||||
async with WLEDClient(wled_url) as client:
|
||||
success = await client.test_connection()
|
||||
assert success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_retry_logic(wled_url, mock_wled_info):
|
||||
"""Test retry logic on failures."""
|
||||
# Mock to fail twice, then succeed
|
||||
call_count = 0
|
||||
|
||||
def mock_response(request):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
return Response(500, text="Error")
|
||||
return Response(200, json=mock_wled_info)
|
||||
|
||||
respx.get(f"{wled_url}/json/info").mock(side_effect=mock_response)
|
||||
|
||||
client = WLEDClient(wled_url, retry_attempts=3, retry_delay=0.1)
|
||||
success = await client.connect()
|
||||
|
||||
assert success is True
|
||||
assert call_count == 3 # Failed 2 times, succeeded on 3rd
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_context_manager(wled_url, mock_wled_info):
|
||||
"""Test async context manager usage."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_info)
|
||||
)
|
||||
|
||||
async with WLEDClient(wled_url) as client:
|
||||
assert client.is_connected is True
|
||||
|
||||
# Client should be closed after context
|
||||
assert client.is_connected is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_without_connection(wled_url):
|
||||
"""Test making request without connecting first."""
|
||||
client = WLEDClient(wled_url)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await client.get_state()
|
||||
Reference in New Issue
Block a user