refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s

Security: tighten CORS defaults, add webhook rate limiting, fix XSS in
automations, guard WebSocket JSON.parse, validate ADB address input,
seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors.

Code quality: add Pydantic models for brightness/power endpoints, fix
thread safety and name uniqueness in DeviceStore, immutable update
pattern, split 6 oversized files into 16 focused modules, enable
TypeScript strictNullChecks (741→102 errors), type state variables,
add dom-utils helper, migrate 3 modules from inline onclick to event
delegation, ProcessorDependencies dataclass.

Performance: async store saves, health endpoint log level, command
palette debounce, optimized entity-events comparison, fix service
worker precache list.

Testing: expand from 45 to 293 passing tests — add store tests (141),
route tests (25), core logic tests (42), E2E flow tests (33), organize
into tests/api/, tests/storage/, tests/core/, tests/e2e/.

DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage
build with non-root user and health check, docker-compose improvements,
version bump to 0.2.0.

Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76),
create contexts/server-operations.md, fix .js→.ts references, fix env
var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and
.env.example.
This commit is contained in:
2026-03-22 00:38:28 +03:00
parent 07bb89e9b7
commit f2871319cb
115 changed files with 9808 additions and 5818 deletions

33
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,33 @@
name: Lint & Test
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
working-directory: server
run: |
pip install --upgrade pip
pip install -e ".[dev]"
- name: Lint with ruff
working-directory: server
run: ruff check src/ tests/
- name: Run tests
working-directory: server
run: pytest --tb=short -q

13
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,13 @@
repos:
- repo: https://github.com/psf/black
rev: '24.10.0'
hooks:
- id: black
args: [--line-length=100, --target-version=py311]
language_version: python3.11
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--line-length=100, --target-version=py311]

186
CLAUDE.md
View File

@@ -7,196 +7,50 @@
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt.
```bash
# Check if available:
ast-index version
# Rebuild index (first time or after major changes):
ast-index rebuild
# Common commands:
ast-index search "Query" # Universal search across files, symbols, modules
ast-index search "Query" # Universal search
ast-index class "ClassName" # Find class/struct/interface definitions
ast-index usages "SymbolName" # Find all places a symbol is used
ast-index implementations "BaseClass" # Find all subclasses/implementations
ast-index symbol "FunctionName" # Find any symbol (class, function, property)
ast-index outline "path/to/File.cpp" # Show all symbols in a file
ast-index hierarchy "ClassName" # Show inheritance tree
ast-index usages "SymbolName" # Find all usage sites
ast-index symbol "FunctionName" # Find any symbol
ast-index callers "FunctionName" # Find all call sites
ast-index outline "path/to/File.py" # Show all symbols in a file
ast-index changed --base master # Show symbols changed in current branch
ast-index update # Incremental update after file changes
```
## CRITICAL: Git Commit and Push Policy
## Git Commit and Push Policy
**🚨 NEVER CREATE COMMITS WITHOUT EXPLICIT USER APPROVAL 🚨**
**NEVER commit or push without explicit user approval.** Wait for the user to review changes and explicitly say "commit" or "push". Completing a task, "looks good", or "thanks" do NOT count as approval. See the system-level instructions for the full commit workflow.
**🚨 NEVER PUSH TO REMOTE WITHOUT EXPLICIT USER APPROVAL 🚨**
## Auto-Restart and Rebuild Policy
### Strict Rules
1. **DO NOT** create commits automatically after making changes
2. **DO NOT** commit without being explicitly instructed by the user
3. **DO NOT** push to remote repository without explicit instruction
4. **ALWAYS WAIT** for the user to review changes and ask you to commit
5. **ALWAYS ASK** if you're unsure whether to commit
### Workflow
1. Make code changes as requested
2. **STOP** - Inform user that changes are complete
3. **WAIT** - User reviews the changes
4. **ONLY IF** user explicitly says "commit" or "create a commit":
- Stage the files with `git add`
- Create the commit with a descriptive message
- **STOP** - Do NOT push
5. **ONLY IF** user explicitly says "push" or "commit and push":
- Push to remote repository
### What Counts as Explicit Approval
**YES - These mean you can commit:**
- "commit"
- "create a commit"
- "commit these changes"
- "git commit"
**YES - These mean you can push:**
- "push"
- "commit and push"
- "push to remote"
- "git push"
**NO - These do NOT mean you should commit:**
- "that looks good"
- "thanks"
- "perfect"
- User silence after you make changes
- Completing a feature/fix
### Example Bad Behavior (DON'T DO THIS)
```
❌ User: "Fix the MSS engine test issue"
❌ Claude: [fixes the issue]
❌ Claude: [automatically commits without asking] <-- WRONG!
```
### Example Good Behavior (DO THIS)
```
✅ User: "Fix the MSS engine test issue"
✅ Claude: [fixes the issue]
✅ Claude: "I've fixed the MSS engine test issue by adding auto-initialization..."
✅ [WAITS FOR USER]
✅ User: "Looks good, commit it"
✅ Claude: [now creates the commit]
```
## IMPORTANT: Auto-Restart Server on Code Changes
**Whenever server-side Python code is modified** (any file under `/server/src/` **excluding** `/server/src/wled_controller/static/`), **automatically restart the server** so the changes take effect immediately. Do NOT wait for the user to ask for a restart.
**No restart needed for frontend-only changes** — but you **MUST rebuild the bundle**. The browser loads the esbuild bundle (`static/dist/app.bundle.js`, `static/dist/app.bundle.css`), NOT the source files. After ANY change to frontend files (JS, CSS under `/server/src/wled_controller/static/`), run:
```bash
cd server && npm run build
```
Without this step, changes will NOT take effect. No server restart is needed — just rebuild and refresh the browser.
### Restart procedure
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
```bash
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1"
```
**Do NOT use** `Stop-Process -Name python` (kills unrelated Python processes like VS Code extensions) or bash background `&` jobs (get killed when the shell session ends).
## IMPORTANT: Server Startup Commands
There are two server modes with separate configs, ports, and data directories:
| Mode | Command | Config | Port | API Key | Data |
|------|---------|--------|------|---------|------|
| **Real** | `python -m wled_controller.main` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
Both can run simultaneously on different ports.
### Restarting after code changes
- **Real server**: Use the PowerShell restart script (it only targets the real server process):
```bash
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1"
```
- **Demo server**: Find and kill the process on port 8081, then restart:
```bash
# Find PID
powershell -Command "netstat -ano | Select-String ':8081.*LISTEN'"
# Kill it
powershell -Command "Stop-Process -Id <PID> -Force"
# Restart
cd server && python -m wled_controller.demo
```
**Do NOT use** `Stop-Process -Name python` — it kills unrelated Python processes (VS Code extensions, etc.).
- **Python code changes** (`server/src/` excluding `static/`): Auto-restart the server. See [contexts/server-operations.md](contexts/server-operations.md) for the restart procedure.
- **Frontend changes** (`static/js/`, `static/css/`): Run `cd server && npm run build` to rebuild the bundle. No server restart needed.
## Project Structure
This is a monorepo containing:
- `/server` - Python FastAPI backend (see `server/CLAUDE.md` for detailed instructions)
- `/client` - Future frontend client (if applicable)
- `/server` — Python FastAPI backend (see [server/CLAUDE.md](server/CLAUDE.md))
- `/contexts` — Context files for Claude (frontend conventions, graph editor, Chrome tools, server ops, demo mode)
## Working with Server
## Context Files
For detailed server-specific instructions (restart policy, testing, etc.), see:
- `server/CLAUDE.md`
## Frontend (HTML, CSS, JS, i18n)
For all frontend conventions (CSS variables, UI patterns, modals, localization, tutorials), see [`contexts/frontend.md`](contexts/frontend.md).
| File | When to read |
| ---- | ------------ |
| [contexts/frontend.md](contexts/frontend.md) | HTML, CSS, JS/TS, i18n, modals, icons, bundling |
| [contexts/graph-editor.md](contexts/graph-editor.md) | Visual graph editor changes |
| [contexts/server-operations.md](contexts/server-operations.md) | Server restart, startup modes, demo mode |
| [contexts/chrome-tools.md](contexts/chrome-tools.md) | Chrome MCP tool usage for testing |
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
## Task Tracking via TODO.md
Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the TodoWrite tool** — all progress tracking goes through `TODO.md`.
- **When starting a multi-step task**: add sub-steps as `- [ ]` items under the relevant section
- **When completing a step**: mark it `- [x]` immediately — don't batch updates
- **When a task is fully done**: mark it `- [x]` and leave it for the user to clean up
- **When the user requests a new feature/fix**: add it to the appropriate section with a priority tag
## Documentation Lookup
**Use context7 MCP tools for library/framework documentation lookups.** When you need to check API signatures, usage patterns, or current behavior of external libraries (e.g., FastAPI, OpenCV, Pydantic, yt-dlp), use `mcp__plugin_context7_context7__resolve-library-id` to find the library, then `mcp__plugin_context7_context7__query-docs` to fetch up-to-date docs. This avoids relying on potentially outdated training data.
## IMPORTANT: Demo Mode Awareness
**When adding new entity types, engines, device providers, or stores — keep demo mode in sync:**
1. **New entity stores**: Add the store's file path to `StorageConfig` in `config.py` — the `model_post_init()` auto-rewrites `data/` → `data/demo/` paths when demo is active.
2. **New capture engines**: If a new engine is added, verify demo mode filtering still works (demo engines use `is_demo_mode()` gate in `is_available()`).
3. **New audio engines**: Same as capture engines — `is_available()` must respect `is_demo_mode()`.
4. **New device providers**: If discovery is added, gate it with `is_demo_mode()` like `DemoDeviceProvider.discover()`.
5. **New seed data**: When adding new entity types that should appear in demo mode, update `server/src/wled_controller/core/demo_seed.py` to include sample entities.
6. **Frontend indicators**: Demo mode state is exposed via `GET /api/v1/version` → `demo_mode: bool`. Frontend stores it as `demoMode` in app state and sets `document.body.dataset.demo = 'true'`.
7. **Backup/Restore**: If new stores are added to `STORE_MAP` in `system.py`, they automatically work in demo mode since the data directory is already isolated.
**Key files:**
- Config flag: `server/src/wled_controller/config.py` → `Config.demo`, `is_demo_mode()`
- Demo engines: `core/capture_engines/demo_engine.py`, `core/audio/demo_engine.py`
- Demo devices: `core/devices/demo_provider.py`
- Seed data: `core/demo_seed.py`
**Use context7 MCP tools for library/framework documentation lookups** (FastAPI, OpenCV, Pydantic, yt-dlp, etc.) instead of relying on potentially outdated training data.
## General Guidelines
- Always test changes before marking as complete
- Follow existing code style and patterns
- Update documentation when changing behavior
- Write clear, descriptive commit messages when explicitly instructed
- Never make commits or pushes without explicit user approval

85
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,85 @@
# Contributing
## Prerequisites
- Python 3.11+
- Node.js 20+ (for frontend bundle)
- Git
## Development Setup
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller-mixed/server
# Python environment
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
pip install -e ".[dev]"
# Frontend dependencies
npm install
npm run build
```
## Running the Server
```bash
cd server
export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows
python -m wled_controller.main
```
Open http://localhost:8080 to access the dashboard.
## Running Tests
```bash
cd server
pytest
```
Tests use pytest with pytest-asyncio. Coverage reports are generated automatically.
## Code Style
This project uses **black** for formatting and **ruff** for linting (both configured in `pyproject.toml` with a line length of 100).
```bash
cd server
black src/ tests/
ruff check src/ tests/
```
## Frontend Changes
After modifying any file under `server/src/wled_controller/static/js/` or `static/css/`, rebuild the bundle:
```bash
cd server
npm run build
```
The browser loads the esbuild bundle (`static/dist/`), not the source files directly.
## Commit Messages
Follow the [Conventional Commits](https://www.conventionalcommits.org/) format:
```
feat: add new capture engine
fix: correct LED color mapping
refactor: extract filter pipeline
docs: update API reference
test: add audio source tests
chore: update dependencies
```
## Pull Requests
1. Create a feature branch from `master`
2. Make your changes with tests
3. Ensure `ruff check` and `pytest` pass
4. Open a PR with a clear description of the change

View File

@@ -1,281 +1,222 @@
# Installation Guide
Complete installation guide for WLED Screen Controller server and Home Assistant integration.
Complete installation guide for LED Grab (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)
1. [Docker Installation (recommended)](#docker-installation)
2. [Manual Installation](#manual-installation)
3. [First-Time Setup](#first-time-setup)
4. [Home Assistant Integration](#home-assistant-integration)
5. [Configuration Reference](#configuration-reference)
6. [Troubleshooting](#troubleshooting)
---
## Server Installation
## Docker Installation
### Option 1: Python (Development/Testing)
The fastest way to get running. Requires [Docker](https://docs.docker.com/get-docker/) with Compose.
**Requirements:**
- Python 3.11 or higher
- Windows, Linux, or macOS
1. **Clone and start:**
**Steps:**
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
docker compose up -d
```
2. **Verify:**
```bash
curl http://localhost:8080/health
# → {"status":"healthy", ...}
```
3. **Open the dashboard:** <http://localhost:8080>
4. **View logs:**
```bash
docker compose logs -f
```
5. **Stop / restart:**
```bash
docker compose down # stop
docker compose up -d # start again (data is persisted)
```
### Docker manual build (without Compose)
```bash
cd server
docker build -t ledgrab .
docker run -d \
--name wled-screen-controller \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \
-v $(pwd)/config:/app/config:ro \
ledgrab
```
### Linux screen capture in Docker
Screen capture from inside a container requires X11 access. Uncomment `network_mode: host` in `docker-compose.yml` and ensure the `DISPLAY` variable is set. Wayland is not currently supported for in-container capture.
---
## Manual Installation
### Requirements
| Dependency | Version | Purpose |
| ---------- | ------- | ------- |
| Python | 3.11+ | Backend server |
| Node.js | 18+ | Frontend build (esbuild) |
| pip | latest | Python package installer |
| npm | latest | Node package manager |
### Steps
1. **Clone the repository:**
```bash
git clone https://github.com/yourusername/wled-screen-controller.git
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
```
2. **Create virtual environment:**
2. **Build the frontend bundle:**
```bash
npm ci
npm run build
```
This compiles TypeScript and bundles JS/CSS into `src/wled_controller/static/dist/`.
3. **Create a virtual environment:**
```bash
python -m venv venv
# Windows
# Linux / macOS
source venv/bin/activate
# Windows (cmd)
venv\Scripts\activate
# Linux/Mac
source venv/bin/activate
# Windows (PowerShell)
venv\Scripts\Activate.ps1
```
3. **Install dependencies:**
4. **Install Python dependencies:**
```bash
pip install .
```
4. **Configure (optional):**
Edit `config/default_config.yaml` to customize settings.
Optional extras:
5. **Run the server:**
```bash
# Set PYTHONPATH
export PYTHONPATH=$(pwd)/src # Linux/Mac
set PYTHONPATH=%CD%\src # Windows
pip install ".[camera]" # Webcam capture via OpenCV
pip install ".[perf]" # DXCam, BetterCam, WGC (Windows only)
pip install ".[notifications]" # OS notification capture
pip install ".[dev]" # pytest, black, ruff (development)
```
# Start server
5. **Set PYTHONPATH and start the server:**
```bash
# Linux / macOS
export PYTHONPATH=$(pwd)/src
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
# Windows (cmd)
set PYTHONPATH=%CD%\src
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
```
6. **Verify:**
Open http://localhost:8080/docs in your browser.
6. **Verify:** open <http://localhost:8080> in your browser.
### Option 2: Docker (Recommended for Production)
---
**Requirements:**
- Docker
- Docker Compose
## First-Time Setup
**Steps:**
### Change the default API key
1. **Clone the repository:**
```bash
git clone https://github.com/yourusername/wled-screen-controller.git
cd wled-screen-controller/server
```
The server ships with a development API key (`development-key-change-in-production`). **Change it before exposing the server on your network.**
2. **Start with Docker Compose:**
```bash
docker-compose up -d
```
Option A -- edit the config file:
3. **View logs:**
```bash
docker-compose logs -f
```
```yaml
# server/config/default_config.yaml
auth:
api_keys:
main: "your-secure-key-here" # replace the dev key
```
4. **Verify:**
Open http://localhost:8080/docs in your browser.
### Option 3: Docker (Manual Build)
Option B -- set an environment variable:
```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
export WLED_AUTH__API_KEYS__main="your-secure-key-here"
```
Generate a random key:
```bash
openssl rand -hex 32
```
### Configure CORS for LAN access
By default the server only allows requests from `http://localhost:8080`. To access the dashboard from another machine on your LAN, add its origin:
```yaml
# server/config/default_config.yaml
server:
cors_origins:
- "http://localhost:8080"
- "http://192.168.1.100:8080" # your server's LAN IP
```
Or via environment variable:
```bash
WLED_SERVER__CORS_ORIGINS='["http://localhost:8080","http://192.168.1.100:8080"]'
```
### Discover devices
Open the dashboard and go to the **Devices** tab. Click **Discover** to find WLED devices on your network via mDNS. You can also add devices manually by IP address.
---
## Home Assistant Integration
### Option 1: HACS (Recommended)
### Option 1: HACS (recommended)
1. **Install HACS** if not already installed:
- Follow instructions at https://hacs.xyz/docs/setup/download
1. Install [HACS](https://hacs.xyz/docs/setup/download) if you have not already.
2. Open HACS in Home Assistant.
3. Click the three-dot menu, then **Custom repositories**.
4. Add URL: `https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed`
5. Set category to **Integration** and click **Add**.
6. Search for "WLED Screen Controller" in HACS and click **Download**.
7. Restart Home Assistant.
8. Go to **Settings > Devices & Services > Add Integration** and search for "WLED Screen Controller".
9. Enter your server URL (e.g., `http://192.168.1.100:8080`) and API key.
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
### Option 2: Manual
3. **Install Integration:**
- In HACS, search for "WLED Screen Controller"
- Click Download
- Restart Home Assistant
Copy the `custom_components/wled_screen_controller/` folder from this repository into your Home Assistant `config/custom_components/` directory, then restart Home Assistant and add the integration as above.
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
### Automation example
```yaml
automation:
- alias: "Auto Start WLED on TV On"
- alias: "Start ambient lighting when TV turns on"
trigger:
- platform: state
entity_id: media_player.living_room_tv
@@ -285,7 +226,7 @@ automation:
target:
entity_id: switch.living_room_tv_processing
- alias: "Auto Stop WLED on TV Off"
- alias: "Stop ambient lighting when TV turns off"
trigger:
- platform: state
entity_id: media_player.living_room_tv
@@ -298,8 +239,89 @@ automation:
---
## Configuration Reference
The server reads configuration from three sources (in order of priority):
1. **Environment variables** -- prefix `WLED_`, double underscore as nesting delimiter (e.g., `WLED_SERVER__PORT=9090`)
2. **YAML config file** -- `server/config/default_config.yaml` (or set `WLED_CONFIG_PATH` to override)
3. **Built-in defaults**
See [`server/.env.example`](server/.env.example) for every available variable with descriptions.
### Key settings
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `WLED_SERVER__PORT` | `8080` | HTTP listen port |
| `WLED_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `WLED_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) |
| `WLED_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) |
| `WLED_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery |
| `WLED_MQTT__BROKER_HOST` | `localhost` | MQTT broker address |
| `WLED_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) |
---
## Troubleshooting
### Server will not start
**Check Python version:**
```bash
python --version # must be 3.11+
```
**Check the frontend bundle exists:**
```bash
ls server/src/wled_controller/static/dist/app.bundle.js
```
If missing, run `cd server && npm ci && npm run build`.
**Check logs:**
```bash
# Docker
docker compose logs -f
# Manual install
tail -f logs/wled_controller.log
```
### Cannot access the dashboard from another machine
1. Verify the server is reachable: `curl http://SERVER_IP:8080/health`
2. Check your firewall allows inbound traffic on port 8080.
3. Add your server's LAN IP to `cors_origins` (see [Configure CORS](#configure-cors-for-lan-access) above).
### Home Assistant integration not appearing
1. Verify HACS installed the component: check that `config/custom_components/wled_screen_controller/` exists.
2. Clear your browser cache.
3. Restart Home Assistant.
4. Check logs at **Settings > System > Logs** and search for `wled_screen_controller`.
### WLED device not responding
1. Confirm the device is powered on and connected to Wi-Fi.
2. Test it directly: `curl http://DEVICE_IP/json/info`
3. Check that the server and the device are on the same subnet.
4. Try restarting the WLED device.
### Low FPS or high latency
1. Lower the target FPS in the stream settings.
2. Reduce `border_width` to decrease the number of sampled pixels.
3. Check CPU usage on the server (`htop` or Task Manager).
4. On Windows, install the `perf` extra for GPU-accelerated capture: `pip install ".[perf]"`
---
## Next Steps
- [API Documentation](docs/API.md)
- [Calibration Guide](docs/CALIBRATION.md)
- [GitHub Issues](https://github.com/yourusername/wled-screen-controller/issues)
- [Repository Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)

View File

@@ -84,26 +84,42 @@ A Home Assistant integration exposes devices as entities for smart home automati
## Quick Start
### Docker (recommended)
```bash
git clone https://github.com/yourusername/wled-screen-controller.git
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
docker compose up -d
```
### Manual
Requires Python 3.11+ and Node.js 18+.
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
# Option A: Docker (recommended)
docker-compose up -d
# Build the frontend bundle
npm ci && npm run build
# Option B: Python
# Create a virtual environment and install
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
pip install .
# Start the server
export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
```
Open `http://localhost:8080` to access the dashboard. The default API key for development is `development-key-change-in-production`.
Open **http://localhost:8080** to access the dashboard.
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including Docker manual builds and Home Assistant setup.
> **Important:** The default API key is `development-key-change-in-production`. Change it before exposing the server outside localhost. See [INSTALLATION.md](INSTALLATION.md) for details.
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup.
## Architecture
@@ -146,7 +162,7 @@ wled-screen-controller/
## Configuration
Edit `server/config/default_config.yaml` or use environment variables with the `LED_GRAB_` prefix:
Edit `server/config/default_config.yaml` or use environment variables with the `WLED_` prefix:
```yaml
server:
@@ -168,7 +184,7 @@ logging:
max_size_mb: 100
```
Environment variable override example: `LED_GRAB_SERVER__PORT=9090`.
Environment variable override example: `WLED_SERVER__PORT=9090`.
## API

View File

@@ -72,17 +72,17 @@ For `EntitySelect` with `allowNone: true`, pass the same i18n string as `noneLab
Plain `<select>` dropdowns should be enhanced with visual selectors depending on the data type:
- **Predefined options** (source types, effect types, palettes, waveforms, viz modes) → use `IconSelect` from `js/core/icon-select.js`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.js` for examples.
- **Predefined options** (source types, effect types, palettes, waveforms, viz modes) → use `IconSelect` from `js/core/icon-select.ts`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.ts` for examples.
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.js`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.js` or `_lineSourceEntitySelect` in `advanced-calibration.js` for examples.
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.ts`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.ts` or `_lineSourceEntitySelect` in `advanced-calibration.ts` for examples.
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.js` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
### Modal dirty check (discard unsaved changes)
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.js`:
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.ts`:
1. **Subclass Modal** with `snapshotValues()` returning an object of all tracked field values:
@@ -144,25 +144,25 @@ Do **not** use a `range-with-value` wrapper div.
### Tutorials
The app has an interactive tutorial system (`static/js/features/tutorials.js`) with a generic engine, spotlight overlay, tooltip positioning, and keyboard navigation. Tutorials exist for:
The app has an interactive tutorial system (`static/js/features/tutorials.ts`) with a generic engine, spotlight overlay, tooltip positioning, and keyboard navigation. Tutorials exist for:
- **Getting started** (header-level walkthrough of all tabs and controls)
- **Per-tab tutorials** (Dashboard, Targets, Sources, Profiles) triggered by `?` buttons
- **Device card tutorial** and **Calibration tutorial** (context-specific)
When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.js` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`).
When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.ts` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`).
## Icons
**Always use SVG icons from the icon system, never text/emoji/Unicode symbols for buttons and UI controls.**
- Icon SVG paths are defined in `static/js/core/icon-paths.js` (Lucide icons, 24×24 viewBox)
- Icon constants are exported from `static/js/core/icons.js` (e.g. `ICON_START`, `ICON_TRASH`, `ICON_EDIT`)
- Use `_svg(path)` wrapper from `icons.js` to create new icon constants from paths
- Icon SVG paths are defined in `static/js/core/icon-paths.ts` (Lucide icons, 24×24 viewBox)
- Icon constants are exported from `static/js/core/icons.ts` (e.g. `ICON_START`, `ICON_TRASH`, `ICON_EDIT`)
- Use `_svg(path)` wrapper from `icons.ts` to create new icon constants from paths
When you need a new icon:
1. Find the Lucide icon at https://lucide.dev
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.js` as a new export
3. Add a corresponding `ICON_*` constant in `icons.js` using `_svg(P.myIcon)`
3. Add a corresponding `ICON_*` constant in `icons.ts` using `_svg(P.myIcon)`
4. Import and use the constant in your feature module
Common icons: `ICON_START` (play), `ICON_STOP` (power), `ICON_EDIT` (pencil), `ICON_CLONE` (copy), `ICON_TRASH` (trash), `ICON_SETTINGS` (gear), `ICON_TEST` (flask), `ICON_OK` (circle-check), `ICON_WARNING` (triangle-alert), `ICON_HELP` (circle-help), `ICON_LIST_CHECKS` (list-checks), `ICON_CIRCLE_OFF` (circle-off).
@@ -171,9 +171,9 @@ For icon-only buttons, use `btn btn-icon` CSS classes. The `.icon` class inside
## Localization (i18n)
**Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.js` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`).
**Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.ts` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`).
- In JS modules: `import { t } from '../core/i18n.js';` then `showToast(t('my.key'), 'error')`
- In JS modules: `import { t } from '../core/i18n.ts';` then `showToast(t('my.key'), 'error')`
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.t ? t('key') : 'fallback'`
- In HTML templates: use `data-i18n="key"` for text content, `data-i18n-title="key"` for title attributes, `data-i18n-aria-label="key"` for aria-labels
- Keys follow dotted namespace convention: `feature.context.description` (e.g. `device.error.brightness`, `calibration.saved`)
@@ -196,7 +196,7 @@ The frontend uses **esbuild** to bundle all JS modules and CSS files into single
### Files
- **Entry points:** `static/js/app.js` (JS), `static/css/all.css` (CSS imports all individual sheets)
- **Entry points:** `static/js/app.ts` (JS), `static/css/all.css` (CSS imports all individual sheets)
- **Output:** `static/dist/app.bundle.js` and `static/dist/app.bundle.css` (minified + source maps)
- **Config:** `server/esbuild.mjs`
- **HTML:** `templates/index.html` references the bundles, not individual source files
@@ -219,8 +219,8 @@ The frontend uses **esbuild** to bundle all JS modules and CSS files into single
All JS/CSS dependencies are bundled — **no CDN or external requests** at runtime:
- **Chart.js** — imported in `perf-charts.js`, exposed as `window.Chart` for `targets.js` and `dashboard.js`
- **ELK.js** — imported in `graph-layout.js` for graph auto-layout
- **Chart.js** — imported in `perf-charts.ts`, exposed as `window.Chart` for `targets.ts` and `dashboard.ts`
- **ELK.js** — imported in `graph-layout.ts` for graph auto-layout
- **Fonts** — DM Sans (400-700) and Orbitron (700) woff2 files in `static/fonts/`, declared in `css/fonts.css`
When adding a new JS dependency: `npm install <pkg>` in `server/`, then `import` it in the relevant source file. esbuild bundles it automatically.
@@ -260,7 +260,7 @@ Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(-
### FPS sparkline charts
Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.js`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.
Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.ts`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.
## Visual Graph Editor

View File

@@ -1,6 +1,6 @@
# Visual Graph Editor
**Read this file when working on the graph editor** (`static/js/features/graph-editor.js` and related modules).
**Read this file when working on the graph editor** (`static/js/features/graph-editor.ts` and related modules).
## Architecture
@@ -10,12 +10,12 @@ The graph editor renders all entities (devices, templates, sources, clocks, targ
| File | Responsibility |
|---|---|
| `js/features/graph-editor.js` | Main orchestrator — toolbar, keyboard, search, filter, add-entity menu, port/node drag, minimap |
| `js/core/graph-layout.js` | ELK.js layout, `buildGraph()`, `computePorts()`, entity color/label maps |
| `js/core/graph-nodes.js` | SVG node rendering, overlay buttons, per-node color overrides |
| `js/core/graph-edges.js` | SVG edge rendering (bezier curves, arrowheads, flow dots) |
| `js/core/graph-canvas.js` | Pan/zoom controller with `zoomToPoint()` rAF animation |
| `js/core/graph-connections.js` | CONNECTION_MAP — which fields link entity types, drag-connect/detach logic |
| `js/features/graph-editor.ts` | Main orchestrator — toolbar, keyboard, search, filter, add-entity menu, port/node drag, minimap |
| `js/core/graph-layout.ts` | ELK.js layout, `buildGraph()`, `computePorts()`, entity color/label maps |
| `js/core/graph-nodes.ts` | SVG node rendering, overlay buttons, per-node color overrides |
| `js/core/graph-edges.ts` | SVG edge rendering (bezier curves, arrowheads, flow dots) |
| `js/core/graph-canvas.ts` | Pan/zoom controller with `zoomToPoint()` rAF animation |
| `js/core/graph-connections.ts` | CONNECTION_MAP — which fields link entity types, drag-connect/detach logic |
| `css/graph-editor.css` | All graph-specific styles |
### Data flow
@@ -41,27 +41,27 @@ Nodes have input ports (left) and output ports (right), colored by edge type. Po
### Adding a new entity type
1. **`graph-layout.js`** — `ENTITY_COLORS`, `ENTITY_LABELS`, `buildGraph()` (add node loop + edge loops)
2. **`graph-layout.js`** — `edgeType()` function if the new type needs a distinct edge color
3. **`graph-nodes.js`** — `KIND_ICONS` (default icon), `SUBTYPE_ICONS` (subtype-specific icons)
4. **`graph-nodes.js`** — `START_STOP_KINDS` or `TEST_KINDS` sets if the entity supports start/stop or test
5. **`graph-connections.js`** — `CONNECTION_MAP` for drag-connect edge creation
6. **`graph-editor.js`** — `ADD_ENTITY_MAP` (add-entity menu entry with window function)
7. **`graph-editor.js`** — `ALL_CACHES` array (for new-entity-focus watcher)
8. **`graph-editor.js`** — `_fetchAllEntities()` (add cache fetch + pass to `computeLayout`)
9. **`core/state.js`** — Add/export the new DataCache
10. **`app.js`** — Import and window-export the add/edit/clone functions
1. **`graph-layout.ts`** — `ENTITY_COLORS`, `ENTITY_LABELS`, `buildGraph()` (add node loop + edge loops)
2. **`graph-layout.ts`** — `edgeType()` function if the new type needs a distinct edge color
3. **`graph-nodes.ts`** — `KIND_ICONS` (default icon), `SUBTYPE_ICONS` (subtype-specific icons)
4. **`graph-nodes.ts`** — `START_STOP_KINDS` or `TEST_KINDS` sets if the entity supports start/stop or test
5. **`graph-connections.ts`** — `CONNECTION_MAP` for drag-connect edge creation
6. **`graph-editor.ts`** — `ADD_ENTITY_MAP` (add-entity menu entry with window function)
7. **`graph-editor.ts`** — `ALL_CACHES` array (for new-entity-focus watcher)
8. **`graph-editor.ts`** — `_fetchAllEntities()` (add cache fetch + pass to `computeLayout`)
9. **`core/state.ts`** — Add/export the new DataCache
10. **`app.ts`** — Import and window-export the add/edit/clone functions
### Adding a new field/connection to an existing entity
1. **`graph-layout.js`** — `buildGraph()` edges section: add `addEdge()` call
2. **`graph-connections.js`** — `CONNECTION_MAP`: add the field entry
3. **`graph-edges.js`** — `EDGE_COLORS` if a new edge type is needed
1. **`graph-layout.ts`** — `buildGraph()` edges section: add `addEdge()` call
2. **`graph-connections.ts`** — `CONNECTION_MAP`: add the field entry
3. **`graph-edges.ts`** — `EDGE_COLORS` if a new edge type is needed
### Adding a new entity subtype
1. **`graph-nodes.js`** — `SUBTYPE_ICONS[kind]` — add icon for the new subtype
2. **`graph-layout.js`** — `buildGraph()` — ensure `subtype` is extracted from the entity data
1. **`graph-nodes.ts`** — `SUBTYPE_ICONS[kind]` — add icon for the new subtype
2. **`graph-layout.ts`** — `buildGraph()` — ensure `subtype` is extracted from the entity data
## Features & keyboard shortcuts
@@ -90,7 +90,7 @@ Rendered as a small SVG with colored rects for each node and a viewport rect. Su
## Node hover FPS tooltip
Running `output_target` nodes show a floating HTML tooltip on hover (300ms delay). The tooltip is an absolutely-positioned `<div class="graph-node-tooltip">` inside `.graph-container` (not SVG — needed for Chart.js canvas). It displays errors, uptime, and a FPS sparkline (reusing `createFpsSparkline` from `core/chart-utils.js`). The sparkline is seeded from `/api/v1/system/metrics-history` for instant context.
Running `output_target` nodes show a floating HTML tooltip on hover (300ms delay). The tooltip is an absolutely-positioned `<div class="graph-node-tooltip">` inside `.graph-container` (not SVG — needed for Chart.js canvas). It displays errors, uptime, and a FPS sparkline (reusing `createFpsSparkline` from `core/chart-utils.ts`). The sparkline is seeded from `/api/v1/system/metrics-history` for instant context.
**Hover events** use `pointerover`/`pointerout` with `relatedTarget` check to prevent flicker when the cursor moves between child SVG elements within the same `<g>` node.

View File

@@ -0,0 +1,78 @@
# Server Operations
**Read this file when restarting, starting, or managing the server process.**
## Server Modes
Two independent server modes with separate configs, ports, and data directories:
| Mode | Command | Config | Port | API Key | Data |
| ---- | ------- | ------ | ---- | ------- | ---- |
| **Real** | `python -m wled_controller.main` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
Both can run simultaneously on different ports.
## Restart Procedure
### Real server
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
```bash
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1"
```
### Demo server
Find and kill the process on port 8081, then restart:
```bash
# Find PID
powershell -Command "netstat -ano | Select-String ':8081.*LISTEN'"
# Kill it
powershell -Command "Stop-Process -Id <PID> -Force"
# Restart
cd server && python -m wled_controller.demo
```
**Do NOT use** `Stop-Process -Name python` — it kills unrelated Python processes (VS Code extensions, etc.).
**Do NOT use** bash background `&` jobs — they get killed when the shell session ends.
## When to Restart
**Restart required** for changes to:
- API routes (`api/routes/`, `api/schemas/`)
- Core logic (`core/*.py`)
- Configuration (`config.py`)
- Utilities (`utils/*.py`)
- Data models (`storage/`)
**No restart needed** for:
- Static files (`static/js/`, `static/css/`) — but **must rebuild bundle**: `cd server && npm run build`
- Locale files (`static/locales/*.json`) — loaded by frontend
- Documentation files (`*.md`)
## Auto-Reload Note
Auto-reload is disabled (`reload=False` in `main.py`) due to watchfiles causing an infinite reload loop. Manual restart is required after server code changes.
## Demo Mode Awareness
**When adding new entity types, engines, device providers, or stores — keep demo mode in sync:**
1. **New entity stores**: Add the store's file path to `StorageConfig` in `config.py``model_post_init()` auto-rewrites `data/` to `data/demo/` paths when demo is active.
2. **New capture engines**: Verify demo mode filtering works — demo engines use `is_demo_mode()` gate in `is_available()`.
3. **New audio engines**: Same as capture engines — `is_available()` must respect `is_demo_mode()`.
4. **New device providers**: Gate discovery with `is_demo_mode()` like `DemoDeviceProvider.discover()`.
5. **New seed data**: Update `server/src/wled_controller/core/demo_seed.py` to include sample entities.
6. **Frontend indicators**: Demo state exposed via `GET /api/v1/version` -> `demo_mode: bool`. Frontend stores it as `demoMode` in app state and sets `document.body.dataset.demo = 'true'`.
7. **Backup/Restore**: New stores added to `STORE_MAP` in `system.py` automatically work in demo mode since the data directory is already isolated.
### Key files
- Config flag: `server/src/wled_controller/config.py` -> `Config.demo`, `is_demo_mode()`
- Demo engines: `core/capture_engines/demo_engine.py`, `core/audio/demo_engine.py`
- Demo devices: `core/devices/demo_provider.py`
- Seed data: `core/demo_seed.py`

View File

@@ -4,9 +4,9 @@
"codeowners": ["@alexeidolgolyov"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/yourusername/wled-screen-controller",
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed",
"iot_class": "local_push",
"issue_tracker": "https://github.com/yourusername/wled-screen-controller/issues",
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues",
"requirements": ["aiohttp>=3.9.0"],
"version": "0.2.0"
}

54
server/.env.example Normal file
View File

@@ -0,0 +1,54 @@
# WLED Screen Controller — Environment Variables
# Copy this file to .env and adjust values as needed.
# All variables use the WLED_ prefix with __ (double underscore) as the nesting delimiter.
# ── Server ──────────────────────────────────────────────
# WLED_SERVER__HOST=0.0.0.0 # Listen address (default: 0.0.0.0)
# WLED_SERVER__PORT=8080 # Listen port (default: 8080)
# WLED_SERVER__LOG_LEVEL=INFO # Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
# WLED_SERVER__CORS_ORIGINS=["*"] # JSON array of allowed CORS origins
# ── Authentication ──────────────────────────────────────
# API keys are required. Format: JSON object {"label": "key"}.
# WLED_AUTH__API_KEYS={"dev": "development-key-change-in-production"}
# ── Storage paths ───────────────────────────────────────
# All paths are relative to the server working directory.
# WLED_STORAGE__DEVICES_FILE=data/devices.json
# WLED_STORAGE__TEMPLATES_FILE=data/capture_templates.json
# WLED_STORAGE__POSTPROCESSING_TEMPLATES_FILE=data/postprocessing_templates.json
# WLED_STORAGE__PICTURE_SOURCES_FILE=data/picture_sources.json
# WLED_STORAGE__OUTPUT_TARGETS_FILE=data/output_targets.json
# WLED_STORAGE__PATTERN_TEMPLATES_FILE=data/pattern_templates.json
# WLED_STORAGE__COLOR_STRIP_SOURCES_FILE=data/color_strip_sources.json
# WLED_STORAGE__AUDIO_SOURCES_FILE=data/audio_sources.json
# WLED_STORAGE__AUDIO_TEMPLATES_FILE=data/audio_templates.json
# WLED_STORAGE__VALUE_SOURCES_FILE=data/value_sources.json
# WLED_STORAGE__AUTOMATIONS_FILE=data/automations.json
# WLED_STORAGE__SCENE_PRESETS_FILE=data/scene_presets.json
# WLED_STORAGE__COLOR_STRIP_PROCESSING_TEMPLATES_FILE=data/color_strip_processing_templates.json
# WLED_STORAGE__SYNC_CLOCKS_FILE=data/sync_clocks.json
# ── MQTT (optional) ────────────────────────────────────
# WLED_MQTT__ENABLED=false
# WLED_MQTT__BROKER_HOST=localhost
# WLED_MQTT__BROKER_PORT=1883
# WLED_MQTT__USERNAME=
# WLED_MQTT__PASSWORD=
# WLED_MQTT__CLIENT_ID=ledgrab
# WLED_MQTT__BASE_TOPIC=ledgrab
# ── Logging ─────────────────────────────────────────────
# WLED_LOGGING__FORMAT=json # json or text (default: json)
# WLED_LOGGING__FILE=logs/wled_controller.log
# WLED_LOGGING__MAX_SIZE_MB=100
# WLED_LOGGING__BACKUP_COUNT=5
# ── Demo mode ───────────────────────────────────────────
# WLED_DEMO=false # Enable demo mode (uses data/demo/ directory)
# ── Config file override ───────────────────────────────
# WLED_CONFIG_PATH= # Absolute path to a YAML config file (overrides all above)
# ── Docker Compose extras (not part of WLED_ prefix) ───
# DISPLAY=:0 # X11 display for Linux screen capture

View File

@@ -1,212 +1,76 @@
# Claude Instructions for WLED Screen Controller Server
## Development Workflow
## Project Structure
### Server Restart Policy
- `src/wled_controller/main.py` — FastAPI application entry point
- `src/wled_controller/api/routes/` — REST API endpoints (one file per entity)
- `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity)
- `src/wled_controller/core/` — Core business logic (capture, devices, audio, processing, automations)
- `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection)
- `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales)
- `src/wled_controller/templates/` — Jinja2 HTML templates
- `config/` — Configuration files (YAML)
- `data/` — Runtime data (JSON stores, persisted state)
**IMPORTANT**: When making changes to server code (Python files in `src/wled_controller/`), you MUST restart the server if it's currently running to ensure the changes take effect.
## Entity & Storage Pattern
**NOTE**: Auto-reload is currently disabled (`reload=False` in `main.py`) due to watchfiles causing an infinite reload loop. Changes to server code will NOT be automatically picked up - manual server restart is required.
#### When to restart:
- After modifying API routes (`api/routes.py`, `api/schemas.py`)
- After updating core logic (`core/*.py`)
- After changing configuration (`config.py`)
- After modifying utilities (`utils/*.py`)
- After updating data models or database schemas
#### How to check if server is running:
```bash
# Look for running Python processes with wled_controller
ps aux | grep wled_controller
# Or check for processes listening on port 8080
netstat -an | grep 8080
```
#### How to restart:
1. **Find the task ID** of the running server (look for background bash tasks in conversation)
2. **Stop the server** using TaskStop with the task ID
3. **Check for port conflicts** (port 8080 may still be in use):
```bash
netstat -ano | findstr :8080
```
If a process is still using port 8080, kill it:
```bash
powershell -Command "Stop-Process -Id <PID> -Force"
```
4. **Start a new server instance** in the background:
```bash
cd server && python -m wled_controller.main
```
Use `run_in_background: true` parameter in Bash tool
5. **Wait 3 seconds** for server to initialize:
```bash
sleep 3
```
6. **Verify startup** by reading the output file:
- Look for "Uvicorn running on http://0.0.0.0:8080"
- Check for any errors in stderr
- Verify "Application startup complete" message
**Common Issues:**
- **Port 8080 in use**: Old process didn't terminate cleanly - kill it manually
- **Module import errors**: Check that all Python files are syntactically correct
- **Permission errors**: Ensure file permissions allow Python to execute
#### Files that DON'T require restart:
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - but you **MUST rebuild the bundle** after changes: `cd server && npm run build`
- Locale files (`static/locales/*.json`) - loaded by frontend
- Documentation files (`*.md`)
- Configuration files in `config/` if server supports hot-reload (check implementation)
### Git Commit and Push Policy
**CRITICAL**: NEVER commit OR push code changes without explicit user approval.
#### Rules
- You MUST NOT create commits without explicit user instruction
- You MUST NOT push commits unless explicitly instructed by the user
- Wait for the user to review changes and ask you to commit
- If the user says "commit", create a commit but DO NOT push
- If the user says "commit and push", you may push after committing
- Always wait for explicit permission before any commit or push operation
#### Workflow
1. Make changes to code
2. **STOP and WAIT** - inform the user of changes and wait for instruction
3. Only create commit when user explicitly requests it (e.g., "commit", "create a commit")
4. **STOP and WAIT** - do not push
5. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote")
### Testing Changes
After restarting the server with new code:
1. Test the modified endpoints/functionality
2. Check browser console for any JavaScript errors
3. Verify API responses match updated schemas
4. Test with different locales if i18n was modified
## Project Structure Notes
- `src/wled_controller/main.py` - FastAPI application entry point
- `src/wled_controller/api/` - REST API endpoints and schemas
- `src/wled_controller/core/` - Core business logic (screen capture, WLED client, processing)
- `src/wled_controller/utils/` - Utility functions (logging, monitor detection)
- `src/wled_controller/static/` - Frontend files (HTML, CSS, JS, locales)
- `config/` - Configuration files (YAML)
- `data/` - Runtime data (devices.json, persistence)
## Common Tasks
### Adding a new API endpoint:
1. Add route to `api/routes.py`
2. Define request/response schemas in `api/schemas.py`
3. **Restart the server**
4. Test the endpoint via `/docs` (Swagger UI)
### Adding a new field to existing API:
1. Update Pydantic schema in `api/schemas.py`
2. Update corresponding dataclass (if applicable)
3. Update backend logic to populate the field
4. **Restart the server**
5. Update frontend to display the new field
### Modifying display/monitor detection:
1. Update functions in `utils/monitor_names.py` or `core/screen_capture.py`
2. **Restart the server**
3. Test with `GET /api/v1/config/displays`
### Modifying server login:
1. Update the logic.
2. **Restart the server**
### Adding translations:
1. Add keys to `static/locales/en.json` and `static/locales/ru.json`
2. Add `data-i18n` attributes to HTML elements in `static/index.html`
3. Use `t('key')` function in `static/app.js` for dynamic content
4. No server restart needed (frontend only)
## Frontend UI Patterns
### Entity Cards
All entity cards (devices, targets, CSS sources, streams, scenes, automations, etc.) **must support clone functionality**. Clone buttons use the `ICON_CLONE` (📋) icon in `.card-actions`.
**Clone pattern**: Clone must open the entity's add/create modal with fields prefilled from the cloned item. It must **never** silently create a duplicate — the user should review and confirm.
Implementation:
1. Export a `cloneMyEntity(id)` function that fetches (or finds in cache) the entity data
2. Call the add/create modal function, passing the entity data as `cloneData`
3. In the modal opener, detect clone mode (no ID + cloneData present) and prefill all fields
4. Append `' (Copy)'` to the name
5. Set the modal title to the "add" variant (not "edit")
6. The save action creates a new entity (POST), not an update (PUT)
```javascript
export async function cloneMyEntity(id) {
const entity = myCache.data.find(e => e.id === id);
if (!entity) return;
showMyEditor(null, entity); // null id = create mode, entity = cloneData
}
```
Register the clone function in `app.js` window exports so inline `onclick` handlers can call it.
### Modal Dialogs
**IMPORTANT**: All modal dialogs must follow these standards for consistent UX:
#### Backdrop Click Behavior
All modals MUST close when the user clicks outside the dialog (on the backdrop). Implement this by adding a click handler that checks if the clicked element is the modal backdrop itself:
```javascript
// Show modal
const modal = document.getElementById('my-modal');
modal.style.display = 'flex';
// Add backdrop click handler to close modal
modal.onclick = function(event) {
if (event.target === modal) {
closeMyModal();
}
};
```
**Where to add**: In every function that shows a modal (e.g., `showAddTemplateModal()`, `editTemplate()`, `showTestTemplateModal()`).
#### Close Button Requirement
Each modal dialog that has a "Cancel" button MUST also have a cross (×) close button at the top-right corner of the dialog. This provides users with multiple intuitive ways to dismiss the dialog:
1. Click the backdrop (outside the dialog)
2. Click the × button (top-right corner)
3. Click the Cancel button (bottom of dialog)
4. Press Escape key (if implemented)
**HTML Structure**:
```html
<div class="modal-content">
<button class="close-btn" onclick="closeMyModal()">&times;</button>
<h2>Dialog Title</h2>
<!-- dialog content -->
<div class="modal-actions">
<button onclick="closeMyModal()">Cancel</button>
<button onclick="submitAction()">Submit</button>
</div>
</div>
```
**CSS Requirements**:
- Close button should be positioned absolutely at top-right
- Should be easily clickable (min 24px × 24px hit area)
- Should have clear hover state
Each entity follows: dataclass model (`storage/`) + JSON store (`storage/*_store.py`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`).
## Authentication
Server uses API key authentication. Keys are configured in:
- `config/default_config.yaml` under `auth.api_keys`
- Or via environment variables: `WLED_AUTH__API_KEYS`
Server uses API key authentication via Bearer token in `Authorization` header.
For development, ensure at least one API key is configured or the server won't start.
- Config: `config/default_config.yaml` under `auth.api_keys`
- Env var: `WLED_AUTH__API_KEYS`
- Dev key: `development-key-change-in-production`
## Common Tasks
### Adding a new API endpoint
1. Create route file in `api/routes/`
2. Define request/response schemas in `api/schemas/`
3. Register the router in `main.py`
4. Restart the server
5. Test via `/docs` (Swagger UI)
### Adding a new field to existing API
1. Update Pydantic schema in `api/schemas/`
2. Update corresponding dataclass in `storage/`
3. Update backend logic to populate the field
4. Restart the server
5. Update frontend to display the new field
6. Rebuild bundle: `cd server && npm run build`
### Adding translations
1. Add keys to `static/locales/en.json`, `static/locales/ru.json`, and `static/locales/zh.json`
2. Add `data-i18n` attributes to HTML elements in `templates/`
3. Use `t('key')` in TypeScript modules (`static/js/`)
4. No server restart needed (frontend only), but rebuild bundle if JS changed
### Modifying display/monitor detection
1. Update functions in `utils/monitor_names.py` or `core/screen_capture.py`
2. Restart the server
3. Test with `GET /api/v1/config/displays`
## Testing
```bash
cd server && pytest # Run all tests
cd server && pytest --cov # With coverage report
cd server && pytest tests/test_api.py # Single test file
```
Tests are in `server/tests/`. Config in `pyproject.toml` under `[tool.pytest]`.
## Frontend Conventions
For all frontend conventions (CSS variables, UI patterns, modals, localization, icons, bundling), see [contexts/frontend.md](../contexts/frontend.md).
## Server Operations
For restart procedures, server modes, and demo mode checklist, see [contexts/server-operations.md](../contexts/server-operations.md).

View File

@@ -1,12 +1,28 @@
FROM python:3.11-slim
## Stage 1: Build frontend bundle
FROM node:20.18-slim AS frontend
WORKDIR /build
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
COPY esbuild.mjs tsconfig.json ./
COPY src/wled_controller/static/ ./src/wled_controller/static/
RUN npm run build
## Stage 2: Python application
FROM python:3.11.11-slim AS runtime
LABEL maintainer="Alexei Dolgolyov <dolgolyov.alexei@gmail.com>"
LABEL description="WLED Screen Controller - Ambient lighting based on screen content"
LABEL org.opencontainers.image.title="LED Grab"
LABEL org.opencontainers.image.description="Ambient lighting system that captures screen content and drives LED strips in real time"
LABEL org.opencontainers.image.version="0.2.0"
LABEL org.opencontainers.image.url="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /app
# Install system dependencies for screen capture
RUN apt-get update && apt-get install -y \
# Install system dependencies for screen capture and health check
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
libxcb1 \
libxcb-randr0 \
libxcb-shm0 \
@@ -14,21 +30,35 @@ RUN apt-get update && apt-get install -y \
libxcb-shape0 \
&& rm -rf /var/lib/apt/lists/*
# Copy project files and install Python dependencies
# Install Python dependencies first (layer caching optimization).
# Copy pyproject.toml with a minimal package stub so pip can resolve deps.
# The real source is copied afterward, keeping the dep layer cached.
COPY pyproject.toml .
RUN mkdir -p src/wled_controller && touch src/wled_controller/__init__.py \
&& pip install --no-cache-dir ".[notifications]" \
&& rm -rf src/wled_controller
# Copy source code and config (invalidates cache only when source changes)
COPY src/ ./src/
COPY config/ ./config/
RUN pip install --no-cache-dir ".[notifications]"
# Create directories for data and logs
RUN mkdir -p /app/data /app/logs
# Copy built frontend bundle from stage 1
COPY --from=frontend /build/src/wled_controller/static/dist/ ./src/wled_controller/static/dist/
# Create non-root user for security
RUN groupadd --gid 1000 ledgrab \
&& useradd --uid 1000 --gid ledgrab --shell /bin/bash --create-home ledgrab \
&& mkdir -p /app/data /app/logs \
&& chown -R ledgrab:ledgrab /app
USER ledgrab
# 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
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# Set Python path
ENV PYTHONPATH=/app/src

View File

@@ -188,4 +188,4 @@ MIT - see [../LICENSE](../LICENSE)
## Support
- 📖 [Full Documentation](../docs/)
- 🐛 [Issues](https://github.com/yourusername/wled-screen-controller/issues)
- 🐛 [Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)

View File

@@ -2,8 +2,10 @@ server:
host: "0.0.0.0"
port: 8080
log_level: "INFO"
# CORS: restrict to localhost by default.
# For LAN access, add your machine's IP, e.g. "http://192.168.1.100:8080"
cors_origins:
- "*"
- "http://localhost:8080"
auth:
# API keys are REQUIRED - authentication is always enforced

View File

@@ -9,8 +9,10 @@ server:
host: "0.0.0.0"
port: 8081
log_level: "INFO"
# CORS: restrict to localhost by default.
# For LAN access, add your machine's IP, e.g. "http://192.168.1.100:8081"
cors_origins:
- "*"
- "http://localhost:8081"
auth:
api_keys:

View File

@@ -1,41 +1,54 @@
version: '3.8'
services:
wled-controller:
build:
context: .
dockerfile: Dockerfile
image: ledgrab:latest
container_name: wled-screen-controller
restart: unless-stopped
ports:
- "8080:8080"
- "${WLED_PORT:-8080}:8080"
volumes:
# Persist device data
# Persist device data and configuration across restarts
- ./data:/app/data
# Persist logs
- ./logs:/app/logs
# Mount configuration (optional override)
- ./config:/app/config
# Required for screen capture on Linux
# Mount configuration for easy editing without rebuild
- ./config:/app/config:ro
# Required for screen capture on Linux (X11)
- /tmp/.X11-unix:/tmp/.X11-unix:ro
environment:
# Server configuration
## Server
# Bind address and port (usually no need to change)
- WLED_SERVER__HOST=0.0.0.0
- WLED_SERVER__PORT=8080
- WLED_SERVER__LOG_LEVEL=INFO
# CORS origins — add your LAN IP for remote access, e.g.:
# WLED_SERVER__CORS_ORIGINS=["http://localhost:8080","http://192.168.1.100:8080"]
# Display for X11 (Linux only)
## Auth
# Override the default API key (STRONGLY recommended for production):
# WLED_AUTH__API_KEYS__main=your-secure-key-here
# Generate a key: openssl rand -hex 32
## Display (Linux X11 only)
- DISPLAY=${DISPLAY:-:0}
# Processing defaults
- WLED_PROCESSING__DEFAULT_FPS=30
- WLED_PROCESSING__BORDER_WIDTH=10
## 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
## MQTT (optional — for Home Assistant auto-discovery)
#- WLED_MQTT__ENABLED=true
#- WLED_MQTT__BROKER_HOST=192.168.1.2
#- WLED_MQTT__BROKER_PORT=1883
#- WLED_MQTT__USERNAME=
#- WLED_MQTT__PASSWORD=
# Uncomment for Linux screen capture (requires host network for X11 access)
# network_mode: host
networks:
- wled-network

View File

@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "wled-screen-controller"
version = "0.1.0"
description = "WLED ambient lighting controller based on screen content"
version = "0.2.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
]
@@ -75,6 +75,7 @@ perf = [
[project.urls]
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
Documentation = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/src/branch/master/INSTALLATION.md"
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues"
[tool.setuptools]

View File

@@ -3,12 +3,16 @@
from fastapi import APIRouter
from .routes.system import router as system_router
from .routes.backup import router as backup_router
from .routes.system_settings import router as system_settings_router
from .routes.devices import router as devices_router
from .routes.templates import router as templates_router
from .routes.postprocessing import router as postprocessing_router
from .routes.picture_sources import router as picture_sources_router
from .routes.pattern_templates import router as pattern_templates_router
from .routes.output_targets import router as output_targets_router
from .routes.output_targets_control import router as output_targets_control_router
from .routes.output_targets_keycolors import router as output_targets_keycolors_router
from .routes.color_strip_sources import router as color_strip_sources_router
from .routes.audio import router as audio_router
from .routes.audio_sources import router as audio_sources_router
@@ -22,6 +26,8 @@ from .routes.color_strip_processing import router as cspt_router
router = APIRouter()
router.include_router(system_router)
router.include_router(backup_router)
router.include_router(system_settings_router)
router.include_router(devices_router)
router.include_router(templates_router)
router.include_router(postprocessing_router)
@@ -33,6 +39,8 @@ router.include_router(audio_sources_router)
router.include_router(audio_templates_router)
router.include_router(value_sources_router)
router.include_router(output_targets_router)
router.include_router(output_targets_control_router)
router.include_router(output_targets_keycolors_router)
router.include_router(automations_router)
router.include_router(scene_presets_router)
router.include_router(webhooks_router)

View File

@@ -1,4 +1,4 @@
"""Shared helpers for WebSocket-based capture test endpoints."""
"""Shared helpers for WebSocket-based capture preview endpoints."""
import asyncio
import base64
@@ -50,7 +50,7 @@ def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int
scale = max_width / image.shape[1]
new_h = int(image.shape[0] * scale)
image = cv2.resize(image, (max_width, new_h), interpolation=cv2.INTER_AREA)
# RGB BGR for OpenCV JPEG encoding
# RGB -> BGR for OpenCV JPEG encoding
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
_, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
return buf.tobytes()
@@ -124,7 +124,7 @@ async def stream_capture_test(
continue
total_capture_time += t1 - t0
frame_count += 1
# Convert numpy PIL once in the capture thread
# Convert numpy -> PIL once in the capture thread
if isinstance(capture.image, np.ndarray):
latest_frame = Image.fromarray(capture.image)
else:

View File

@@ -0,0 +1,395 @@
"""System routes: backup, restore, export, import, auto-backup.
Extracted from system.py to keep files under 800 lines.
"""
import asyncio
import io
import json
import subprocess
import sys
import threading
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse
from wled_controller import __version__
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_auto_backup_engine
from wled_controller.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
BackupFileInfo,
BackupListResponse,
RestoreResponse,
)
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.config import get_config
from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Configuration backup / restore
# ---------------------------------------------------------------------------
# Mapping: logical store name -> StorageConfig attribute name
STORE_MAP = {
"devices": "devices_file",
"capture_templates": "templates_file",
"postprocessing_templates": "postprocessing_templates_file",
"picture_sources": "picture_sources_file",
"output_targets": "output_targets_file",
"pattern_templates": "pattern_templates_file",
"color_strip_sources": "color_strip_sources_file",
"audio_sources": "audio_sources_file",
"audio_templates": "audio_templates_file",
"value_sources": "value_sources_file",
"sync_clocks": "sync_clocks_file",
"color_strip_processing_templates": "color_strip_processing_templates_file",
"automations": "automations_file",
"scene_presets": "scene_presets_file",
}
_SERVER_DIR = Path(__file__).resolve().parents[4]
def _schedule_restart() -> None:
"""Spawn a restart script after a short delay so the HTTP response completes."""
def _restart():
import time
time.sleep(1)
if sys.platform == "win32":
subprocess.Popen(
["powershell", "-ExecutionPolicy", "Bypass", "-File",
str(_SERVER_DIR / "restart.ps1")],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
subprocess.Popen(
["bash", str(_SERVER_DIR / "restart.sh")],
start_new_session=True,
)
threading.Thread(target=_restart, daemon=True).start()
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
def export_store(store_key: str, _: AuthRequired):
"""Download a single entity store as a JSON file."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
export = {
"meta": {
"format": "ledgrab-partial-export",
"format_version": 1,
"store_key": store_key,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
},
"store": data,
}
content = json.dumps(export, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-{store_key}-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
async def import_store(
store_key: str,
_: AuthRequired,
file: UploadFile = File(...),
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
):
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
payload = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
# Support both full-backup format and partial-export format
if "stores" in payload and isinstance(payload.get("meta"), dict):
# Full backup: extract the specific store
if payload["meta"].get("format") not in ("ledgrab-backup",):
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
stores = payload.get("stores", {})
if store_key not in stores:
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
incoming = stores[store_key]
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
# Partial export format
if payload["meta"].get("store_key") != store_key:
raise HTTPException(
status_code=400,
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
)
incoming = payload.get("store", {})
else:
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
if not isinstance(incoming, dict):
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
def _write():
if merge and file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
existing = json.load(f)
if isinstance(existing, dict):
existing.update(incoming)
atomic_write_json(file_path, existing)
return len(existing)
atomic_write_json(file_path, incoming)
return len(incoming)
count = await asyncio.to_thread(_write)
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
_schedule_restart()
return {
"status": "imported",
"store_key": store_key,
"entries": count,
"merge": merge,
"restart_scheduled": True,
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
}
@router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired):
"""Download all configuration as a single JSON backup file."""
config = get_config()
stores = {}
for store_key, config_attr in STORE_MAP.items():
file_path = Path(getattr(config.storage, config_attr))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
stores[store_key] = json.load(f)
else:
stores[store_key] = {}
backup = {
"meta": {
"format": "ledgrab-backup",
"format_version": 1,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
"store_count": len(stores),
},
"stores": stores,
}
content = json.dumps(backup, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
_schedule_restart()
return {"status": "restarting"}
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
async def restore_config(
_: AuthRequired,
file: UploadFile = File(...),
):
"""Upload a backup file to restore all configuration. Triggers server restart."""
# Read and parse
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024: # 10 MB limit
raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)")
backup = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
# Validate envelope
meta = backup.get("meta")
if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup":
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file")
fmt_version = meta.get("format_version", 0)
if fmt_version > 1:
raise HTTPException(
status_code=400,
detail=f"Backup format version {fmt_version} is not supported by this server version",
)
stores = backup.get("stores")
if not isinstance(stores, dict):
raise HTTPException(status_code=400, detail="Backup file missing 'stores' section")
known_keys = set(STORE_MAP.keys())
present_keys = known_keys & set(stores.keys())
if not present_keys:
raise HTTPException(status_code=400, detail="Backup contains no recognized store data")
for key in present_keys:
if not isinstance(stores[key], dict):
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
# Write store files atomically (in thread to avoid blocking event loop)
config = get_config()
def _write_stores():
count = 0
for store_key, config_attr in STORE_MAP.items():
if store_key in stores:
file_path = Path(getattr(config.storage, config_attr))
atomic_write_json(file_path, stores[store_key])
count += 1
logger.info(f"Restored store: {store_key} -> {file_path}")
return count
written = await asyncio.to_thread(_write_stores)
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
_schedule_restart()
missing = known_keys - present_keys
return RestoreResponse(
status="restored",
stores_written=written,
stores_total=len(STORE_MAP),
missing_stores=sorted(missing) if missing else [],
restart_scheduled=True,
message=f"Restored {written} stores. Server restarting...",
)
# ---------------------------------------------------------------------------
# Auto-backup settings & saved backups
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def get_auto_backup_settings(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Get auto-backup settings and status."""
return engine.get_settings()
@router.put(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def update_auto_backup_settings(
_: AuthRequired,
body: AutoBackupSettings,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings(
enabled=body.enabled,
interval_hours=body.interval_hours,
max_backups=body.max_backups,
)
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
async def trigger_backup(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Manually trigger a backup now."""
backup = await engine.trigger_backup()
return {"status": "ok", "backup": backup}
@router.get(
"/api/v1/system/backups",
response_model=BackupListResponse,
tags=["System"],
)
async def list_backups(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""List all saved backup files."""
backups = engine.list_backups()
return BackupListResponse(
backups=[BackupFileInfo(**b) for b in backups],
count=len(backups),
)
@router.get("/api/v1/system/backups/{filename}", tags=["System"])
def download_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Download a specific saved backup file."""
try:
path = engine.get_backup_path(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
content = path.read_bytes()
return StreamingResponse(
io.BytesIO(content),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete("/api/v1/system/backups/{filename}", tags=["System"])
async def delete_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Delete a specific saved backup file."""
try:
engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "deleted", "filename": filename}

View File

@@ -16,6 +16,7 @@ from wled_controller.api.dependencies import (
get_processor_manager,
)
from wled_controller.api.schemas.devices import (
BrightnessRequest,
DeviceCreate,
DeviceListResponse,
DeviceResponse,
@@ -25,6 +26,7 @@ from wled_controller.api.schemas.devices import (
DiscoverDevicesResponse,
OpenRGBZoneResponse,
OpenRGBZonesResponse,
PowerRequest,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
@@ -53,18 +55,19 @@ def _device_to_response(device) -> DeviceResponse:
zone_mode=device.zone_mode,
capabilities=sorted(get_device_capabilities(device.device_type)),
tags=device.tags,
dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'),
dmx_start_universe=getattr(device, 'dmx_start_universe', 0),
dmx_start_channel=getattr(device, 'dmx_start_channel', 1),
espnow_peer_mac=getattr(device, 'espnow_peer_mac', ''),
espnow_channel=getattr(device, 'espnow_channel', 1),
hue_username=getattr(device, 'hue_username', ''),
hue_client_key=getattr(device, 'hue_client_key', ''),
hue_entertainment_group_id=getattr(device, 'hue_entertainment_group_id', ''),
spi_speed_hz=getattr(device, 'spi_speed_hz', 800000),
spi_led_type=getattr(device, 'spi_led_type', 'WS2812B'),
chroma_device_type=getattr(device, 'chroma_device_type', 'chromalink'),
gamesense_device_type=getattr(device, 'gamesense_device_type', 'keyboard'),
dmx_protocol=device.dmx_protocol,
dmx_start_universe=device.dmx_start_universe,
dmx_start_channel=device.dmx_start_channel,
espnow_peer_mac=device.espnow_peer_mac,
espnow_channel=device.espnow_channel,
hue_username=device.hue_username,
hue_client_key=device.hue_client_key,
hue_entertainment_group_id=device.hue_entertainment_group_id,
spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type,
gamesense_device_type=device.gamesense_device_type,
default_css_processing_template_id=device.default_css_processing_template_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -508,7 +511,7 @@ async def get_device_brightness(
@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
async def set_device_brightness(
device_id: str,
body: dict,
body: BrightnessRequest,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
@@ -521,9 +524,7 @@ async def set_device_brightness(
if "brightness_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
bri = body.get("brightness")
if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255:
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
bri = body.brightness
try:
try:
@@ -581,7 +582,7 @@ async def get_device_power(
@router.put("/api/v1/devices/{device_id}/power", tags=["Settings"])
async def set_device_power(
device_id: str,
body: dict,
body: PowerRequest,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
@@ -594,9 +595,7 @@ async def set_device_power(
if "power_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
on = body.get("on")
if on is None or not isinstance(on, bool):
raise HTTPException(status_code=400, detail="'on' must be a boolean")
on = body.power
try:
# For serial devices, use the cached idle client to avoid port conflicts

View File

@@ -1,57 +1,26 @@
"""Output target routes: CRUD, processing control, settings, state, metrics."""
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
import base64
import io
import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from PIL import Image
from fastapi import APIRouter, HTTPException, Depends
from fastapi import Query as QueryParam
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_device_store,
get_pattern_template_store,
get_picture_source_store,
get_output_target_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.output_targets import (
BulkTargetRequest,
BulkTargetResponse,
ExtractedColorResponse,
KCTestRectangleResponse,
KCTestResponse,
KeyColorsResponse,
KeyColorsSettingsSchema,
OutputTargetCreate,
OutputTargetListResponse,
OutputTargetResponse,
OutputTargetUpdate,
TargetMetricsResponse,
TargetProcessingState,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
get_available_displays,
)
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource
from wled_controller.storage import DeviceStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.key_colors_output_target import (
KeyColorsSettings,
@@ -326,7 +295,7 @@ async def update_target(
except ValueError:
pass
# Device change requires async stop swap start cycle
# Device change requires async stop -> swap -> start cycle
if data.device_id is not None:
try:
await manager.update_target_device(target_id, target.device_id)
@@ -377,795 +346,3 @@ async def delete_target(
except Exception as e:
logger.error(f"Failed to delete target: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_start_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for multiple output targets. Returns lists of started IDs and per-ID errors."""
started: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
target_store.get_target(target_id)
await manager.start_processing(target_id)
started.append(target_id)
logger.info(f"Bulk start: started processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except RuntimeError as e:
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
errors[target_id] = msg
except Exception as e:
logger.error(f"Bulk start: failed to start target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(started=started, errors=errors)
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_stop_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
stopped: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
await manager.stop_processing(target_id)
stopped.append(target_id)
logger.info(f"Bulk stop: stopped processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except Exception as e:
logger.error(f"Bulk stop: failed to stop target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(stopped=stopped, errors=errors)
# ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
async def start_processing(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for a output target."""
try:
# Verify target exists in store
target_store.get_target(target_id)
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}")
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
# Resolve target IDs to human-readable names in error messages
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
raise HTTPException(status_code=409, detail=msg)
except Exception as e:
logger.error(f"Failed to start processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
async def stop_processing(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for a output target."""
try:
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}")
return {"status": "stopped", "target_id": target_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))
# ===== STATE & METRICS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"])
async def get_target_state(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get current processing state for a target."""
try:
state = manager.get_target_state(target_id)
return TargetProcessingState(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target state: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
async def get_target_metrics(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get processing metrics for a target."""
try:
metrics = manager.get_target_metrics(target_id)
return TargetMetricsResponse(**metrics)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== KEY COLORS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
async def get_target_colors(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get latest extracted colors for a key-colors target (polling)."""
try:
raw_colors = manager.get_kc_latest_colors(target_id)
colors = {}
for name, (r, g, b) in raw_colors.items():
colors[name] = ExtractedColorResponse(
r=r, g=g, b=b,
hex=f"#{r:02x}{g:02x}{b:02x}",
)
from datetime import datetime, timezone
return KeyColorsResponse(
target_id=target_id,
colors=colors,
timestamp=datetime.now(timezone.utc),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"])
async def test_kc_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
source_store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
pattern_store: PatternTemplateStore = Depends(get_pattern_template_store),
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
pp_template_store=Depends(get_pp_template_store),
):
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
import httpx
stream = None
try:
# 1. Load and validate KC target
try:
target = target_store.get_target(target_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if not isinstance(target, KeyColorsOutputTarget):
raise HTTPException(status_code=400, detail="Target is not a key_colors target")
settings = target.settings
# 2. Resolve pattern template
if not settings.pattern_template_id:
raise HTTPException(status_code=400, detail="No pattern template configured")
try:
pattern_tmpl = pattern_store.get_template(settings.pattern_template_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}")
rectangles = pattern_tmpl.rectangles
if not rectangles:
raise HTTPException(status_code=400, detail="Pattern template has no rectangles")
# 3. Resolve picture source and capture a frame
if not target.picture_source_id:
raise HTTPException(status_code=400, detail="No picture source configured")
try:
chain = source_store.resolve_stream_chain(target.picture_source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
else:
from pathlib import Path
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
pil_image = Image.open(path).convert("RGB")
elif isinstance(raw_stream, ScreenCapturePictureSource):
try:
capture_template = template_store.get_template(raw_stream.capture_template_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Capture template not found: {raw_stream.capture_template_id}",
)
display_index = raw_stream.display_index
if capture_template.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException(
status_code=400,
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
)
locked_device_id = processor_manager.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
raise HTTPException(
status_code=409,
detail=f"Display {display_index} is currently being captured by device '{device_name}'. "
f"Please stop the device processing before testing.",
)
stream = EngineRegistry.create_stream(
capture_template.engine_type, display_index, capture_template.engine_config
)
stream.initialize()
screen_capture = stream.capture_frame()
if screen_capture is None:
raise RuntimeError("No frame captured")
if isinstance(screen_capture.image, np.ndarray):
pil_image = Image.fromarray(screen_capture.image)
else:
raise ValueError("Unexpected image format from engine")
else:
raise HTTPException(status_code=400, detail="Unsupported picture source type")
# 3b. Apply postprocessing filters (if the picture source has a filter chain)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store.get_template(pp_id)
except ValueError:
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
continue
flat_filters = pp_template_store.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
pil_image = Image.fromarray(img_array)
# 4. Extract colors from each rectangle
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append(KCTestRectangleResponse(
name=rect.name,
x=rect.x,
y=rect.y,
width=rect.width,
height=rect.height,
color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"),
))
# 5. Encode frame as base64 JPEG
full_buffer = io.BytesIO()
pil_image.save(full_buffer, format='JPEG', quality=90)
full_buffer.seek(0)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
image_data_uri = f"data:image/jpeg;base64,{full_b64}"
return KCTestResponse(
image=image_data_uri,
rectangles=result_rects,
interpolation_mode=settings.interpolation_mode,
pattern_template_name=pattern_tmpl.name,
)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}")
except Exception as e:
logger.error(f"Failed to test KC target: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
if stream:
try:
stream.cleanup()
except Exception as e:
logger.error(f"Error cleaning up test stream: {e}")
@router.websocket("/api/v1/output-targets/{target_id}/test/ws")
async def test_kc_target_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
fps: int = Query(3),
preview_width: int = Query(480),
):
"""WebSocket for real-time KC target test preview. Auth via ?token=<api_key>.
Streams JSON frames: {"type": "frame", "image": "data:image/jpeg;base64,...",
"rectangles": [...], "pattern_template_name": "...", "interpolation_mode": "..."}
"""
import json as _json
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Load stores
target_store_inst: OutputTargetStore = get_output_target_store()
source_store_inst: PictureSourceStore = get_picture_source_store()
template_store_inst: TemplateStore = get_template_store()
pattern_store_inst: PatternTemplateStore = get_pattern_template_store()
processor_manager_inst: ProcessorManager = get_processor_manager()
device_store_inst: DeviceStore = get_device_store()
pp_template_store_inst = get_pp_template_store()
# Validate target
try:
target = target_store_inst.get_target(target_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
if not isinstance(target, KeyColorsOutputTarget):
await websocket.close(code=4003, reason="Target is not a key_colors target")
return
settings = target.settings
if not settings.pattern_template_id:
await websocket.close(code=4003, reason="No pattern template configured")
return
try:
pattern_tmpl = pattern_store_inst.get_template(settings.pattern_template_id)
except ValueError:
await websocket.close(code=4003, reason=f"Pattern template not found: {settings.pattern_template_id}")
return
rectangles = pattern_tmpl.rectangles
if not rectangles:
await websocket.close(code=4003, reason="Pattern template has no rectangles")
return
if not target.picture_source_id:
await websocket.close(code=4003, reason="No picture source configured")
return
try:
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
except ValueError as e:
await websocket.close(code=4003, reason=str(e))
return
raw_stream = chain["raw_stream"]
# For screen capture sources, check display lock
if isinstance(raw_stream, ScreenCapturePictureSource):
display_index = raw_stream.display_index
locked_device_id = processor_manager_inst.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store_inst.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
await websocket.close(
code=4003,
reason=f"Display {display_index} is captured by '{device_name}'. Stop processing first.",
)
return
fps = max(1, min(30, fps))
preview_width = max(120, min(1920, preview_width))
frame_interval = 1.0 / fps
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
await websocket.accept()
logger.info(f"KC test WS connected for {target_id} (fps={fps})")
# Use the shared LiveStreamManager so we share the capture stream with
# running LED targets instead of creating a competing DXGI duplicator.
live_stream_mgr = processor_manager_inst._live_stream_manager
live_stream = None
try:
live_stream = await asyncio.to_thread(
live_stream_mgr.acquire, target.picture_source_id
)
logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}")
prev_frame_ref = None
while True:
loop_start = time.monotonic()
try:
capture = await asyncio.to_thread(live_stream.get_latest_frame)
if capture is None or capture.image is None:
await asyncio.sleep(frame_interval)
continue
# Skip if same frame object (no new capture yet)
if capture is prev_frame_ref:
await asyncio.sleep(frame_interval * 0.5)
continue
prev_frame_ref = capture
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None
if pil_image is None:
await asyncio.sleep(frame_interval)
continue
# Apply postprocessing (if the source chain has PP templates)
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store_inst:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store_inst.get_template(pp_id)
except ValueError:
continue
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
pass
pil_image = Image.fromarray(img_array)
# Extract colors
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append({
"name": rect.name,
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"color": {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"},
})
# Encode frame as JPEG
if preview_width and pil_image.width > preview_width:
ratio = preview_width / pil_image.width
thumb = pil_image.resize((preview_width, int(pil_image.height * ratio)), Image.LANCZOS)
else:
thumb = pil_image
buf = io.BytesIO()
thumb.save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode()
await websocket.send_text(_json.dumps({
"type": "frame",
"image": f"data:image/jpeg;base64,{b64}",
"rectangles": result_rects,
"pattern_template_name": pattern_tmpl.name,
"interpolation_mode": settings.interpolation_mode,
}))
except (WebSocketDisconnect, Exception) as inner_e:
if isinstance(inner_e, WebSocketDisconnect):
raise
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
elapsed = time.monotonic() - loop_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except WebSocketDisconnect:
logger.info(f"KC test WS disconnected for {target_id}")
except Exception as e:
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
finally:
if live_stream is not None:
try:
await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id
)
except Exception:
pass
logger.info(f"KC test WS closed for {target_id}")
@router.websocket("/api/v1/output-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
try:
manager.add_kc_ws_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
# Keep alive — wait for client messages (or disconnect)
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)
@router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws")
async def led_preview_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
try:
manager.add_led_preview_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_led_preview_client(target_id, websocket)
# ===== STATE CHANGE EVENT STREAM =====
@router.websocket("/api/v1/events/ws")
async def events_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
queue = manager.subscribe_events()
try:
while True:
event = await queue.get()
await websocket.send_json(event)
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
manager.unsubscribe_events(queue)
# ===== OVERLAY VISUALIZATION =====
@router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"])
async def start_target_overlay(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
target_store: OutputTargetStore = Depends(get_output_target_store),
color_strip_store: ColorStripStore = Depends(get_color_strip_store),
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Start screen overlay visualization for a target.
Displays a transparent overlay on the target display showing:
- Border sampling zones (colored rectangles)
- LED position markers (numbered dots)
- Pixel-to-LED mapping ranges (colored segments)
- Calibration info text
"""
try:
# Get target name from store
target = target_store.get_target(target_id)
if not target:
raise ValueError(f"Target {target_id} not found")
# Pre-load calibration and display info from the CSS store so the overlay
# can start even when processing is not currently running.
calibration = None
display_info = None
if isinstance(target, WledOutputTarget) and target.color_strip_source_id:
first_css_id = target.color_strip_source_id
if first_css_id:
try:
css = color_strip_store.get_source(first_css_id)
if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration:
calibration = css.calibration
# Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
ps_id = getattr(css, "picture_source_id", "") or ""
display_index = _resolve_display_index(ps_id, picture_source_store)
displays = get_available_displays()
if displays:
display_index = min(display_index, len(displays) - 1)
display_info = displays[display_index]
except Exception as e:
logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}")
await manager.start_overlay(target_id, target.name, calibration=calibration, display_info=display_info)
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error(f"Failed to start overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
async def stop_target_overlay(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop screen overlay visualization for a target."""
try:
await manager.stop_overlay(target_id)
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])
async def get_overlay_status(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Check if overlay is active for a target."""
try:
active = manager.is_overlay_active(target_id)
return {"target_id": target_id, "active": active}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,341 @@
"""Output target routes: processing control, state, metrics, events, overlay.
Extracted from output_targets.py to keep files under 800 lines.
"""
import asyncio
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_device_store,
get_output_target_store,
get_picture_source_store,
get_processor_manager,
)
from wled_controller.api.schemas.output_targets import (
BulkTargetRequest,
BulkTargetResponse,
TargetMetricsResponse,
TargetProcessingState,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_start_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for multiple output targets. Returns lists of started IDs and per-ID errors."""
started: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
target_store.get_target(target_id)
await manager.start_processing(target_id)
started.append(target_id)
logger.info(f"Bulk start: started processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except RuntimeError as e:
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
errors[target_id] = msg
except Exception as e:
logger.error(f"Bulk start: failed to start target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(started=started, errors=errors)
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_stop_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
stopped: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
await manager.stop_processing(target_id)
stopped.append(target_id)
logger.info(f"Bulk stop: stopped processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except Exception as e:
logger.error(f"Bulk stop: failed to stop target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(stopped=stopped, errors=errors)
# ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
async def start_processing(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for a output target."""
try:
# Verify target exists in store
target_store.get_target(target_id)
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}")
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
# Resolve target IDs to human-readable names in error messages
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
raise HTTPException(status_code=409, detail=msg)
except Exception as e:
logger.error(f"Failed to start processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
async def stop_processing(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for a output target."""
try:
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}")
return {"status": "stopped", "target_id": target_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))
# ===== STATE & METRICS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"])
async def get_target_state(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get current processing state for a target."""
try:
state = manager.get_target_state(target_id)
return TargetProcessingState(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target state: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
async def get_target_metrics(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get processing metrics for a target."""
try:
metrics = manager.get_target_metrics(target_id)
return TargetMetricsResponse(**metrics)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== STATE CHANGE EVENT STREAM =====
@router.websocket("/api/v1/events/ws")
async def events_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
queue = manager.subscribe_events()
try:
while True:
event = await queue.get()
await websocket.send_json(event)
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
manager.unsubscribe_events(queue)
# ===== OVERLAY VISUALIZATION =====
@router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"])
async def start_target_overlay(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
target_store: OutputTargetStore = Depends(get_output_target_store),
color_strip_store: ColorStripStore = Depends(get_color_strip_store),
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Start screen overlay visualization for a target.
Displays a transparent overlay on the target display showing:
- Border sampling zones (colored rectangles)
- LED position markers (numbered dots)
- Pixel-to-LED mapping ranges (colored segments)
- Calibration info text
"""
try:
# Get target name from store
target = target_store.get_target(target_id)
if not target:
raise ValueError(f"Target {target_id} not found")
# Pre-load calibration and display info from the CSS store so the overlay
# can start even when processing is not currently running.
calibration = None
display_info = None
if isinstance(target, WledOutputTarget) and target.color_strip_source_id:
first_css_id = target.color_strip_source_id
if first_css_id:
try:
css = color_strip_store.get_source(first_css_id)
if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration:
calibration = css.calibration
# Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
ps_id = getattr(css, "picture_source_id", "") or ""
display_index = _resolve_display_index(ps_id, picture_source_store)
displays = get_available_displays()
if displays:
display_index = min(display_index, len(displays) - 1)
display_info = displays[display_index]
except Exception as e:
logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}")
await manager.start_overlay(target_id, target.name, calibration=calibration, display_info=display_info)
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error(f"Failed to start overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
async def stop_target_overlay(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop screen overlay visualization for a target."""
try:
await manager.stop_overlay(target_id)
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])
async def get_overlay_status(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Check if overlay is active for a target."""
try:
active = manager.is_overlay_active(target_id)
return {"target_id": target_id, "active": active}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ===== LED PREVIEW WEBSOCKET =====
@router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws")
async def led_preview_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
try:
manager.add_led_preview_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_led_preview_client(target_id, websocket)

View File

@@ -0,0 +1,540 @@
"""Output target routes: key colors endpoints, testing, and WebSocket streams.
Extracted from output_targets.py to keep files under 800 lines.
"""
import asyncio
import base64
import io
import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from PIL import Image
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
get_output_target_store,
get_pattern_template_store,
get_picture_source_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.output_targets import (
ExtractedColorResponse,
KCTestRectangleResponse,
KCTestResponse,
KeyColorsResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ===== KEY COLORS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
async def get_target_colors(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get latest extracted colors for a key-colors target (polling)."""
try:
raw_colors = manager.get_kc_latest_colors(target_id)
colors = {}
for name, (r, g, b) in raw_colors.items():
colors[name] = ExtractedColorResponse(
r=r, g=g, b=b,
hex=f"#{r:02x}{g:02x}{b:02x}",
)
from datetime import datetime, timezone
return KeyColorsResponse(
target_id=target_id,
colors=colors,
timestamp=datetime.now(timezone.utc),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"])
async def test_kc_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
source_store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
pattern_store: PatternTemplateStore = Depends(get_pattern_template_store),
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
pp_template_store=Depends(get_pp_template_store),
):
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
import httpx
stream = None
try:
# 1. Load and validate KC target
try:
target = target_store.get_target(target_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if not isinstance(target, KeyColorsOutputTarget):
raise HTTPException(status_code=400, detail="Target is not a key_colors target")
settings = target.settings
# 2. Resolve pattern template
if not settings.pattern_template_id:
raise HTTPException(status_code=400, detail="No pattern template configured")
try:
pattern_tmpl = pattern_store.get_template(settings.pattern_template_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}")
rectangles = pattern_tmpl.rectangles
if not rectangles:
raise HTTPException(status_code=400, detail="Pattern template has no rectangles")
# 3. Resolve picture source and capture a frame
if not target.picture_source_id:
raise HTTPException(status_code=400, detail="No picture source configured")
try:
chain = source_store.resolve_stream_chain(target.picture_source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
else:
from pathlib import Path
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
pil_image = Image.open(path).convert("RGB")
elif isinstance(raw_stream, ScreenCapturePictureSource):
try:
capture_template = template_store.get_template(raw_stream.capture_template_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Capture template not found: {raw_stream.capture_template_id}",
)
display_index = raw_stream.display_index
if capture_template.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException(
status_code=400,
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
)
locked_device_id = processor_manager.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
raise HTTPException(
status_code=409,
detail=f"Display {display_index} is currently being captured by device '{device_name}'. "
f"Please stop the device processing before testing.",
)
stream = EngineRegistry.create_stream(
capture_template.engine_type, display_index, capture_template.engine_config
)
stream.initialize()
screen_capture = stream.capture_frame()
if screen_capture is None:
raise RuntimeError("No frame captured")
if isinstance(screen_capture.image, np.ndarray):
pil_image = Image.fromarray(screen_capture.image)
else:
raise ValueError("Unexpected image format from engine")
else:
raise HTTPException(status_code=400, detail="Unsupported picture source type")
# 3b. Apply postprocessing filters (if the picture source has a filter chain)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store.get_template(pp_id)
except ValueError:
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
continue
flat_filters = pp_template_store.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
pil_image = Image.fromarray(img_array)
# 4. Extract colors from each rectangle
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append(KCTestRectangleResponse(
name=rect.name,
x=rect.x,
y=rect.y,
width=rect.width,
height=rect.height,
color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"),
))
# 5. Encode frame as base64 JPEG
full_buffer = io.BytesIO()
pil_image.save(full_buffer, format='JPEG', quality=90)
full_buffer.seek(0)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
image_data_uri = f"data:image/jpeg;base64,{full_b64}"
return KCTestResponse(
image=image_data_uri,
rectangles=result_rects,
interpolation_mode=settings.interpolation_mode,
pattern_template_name=pattern_tmpl.name,
)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}")
except Exception as e:
logger.error(f"Failed to test KC target: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
if stream:
try:
stream.cleanup()
except Exception as e:
logger.error(f"Error cleaning up test stream: {e}")
@router.websocket("/api/v1/output-targets/{target_id}/test/ws")
async def test_kc_target_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
fps: int = Query(3),
preview_width: int = Query(480),
):
"""WebSocket for real-time KC target test preview. Auth via ?token=<api_key>.
Streams JSON frames: {"type": "frame", "image": "data:image/jpeg;base64,...",
"rectangles": [...], "pattern_template_name": "...", "interpolation_mode": "..."}
"""
import json as _json
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Load stores
target_store_inst: OutputTargetStore = get_output_target_store()
source_store_inst: PictureSourceStore = get_picture_source_store()
template_store_inst: TemplateStore = get_template_store()
pattern_store_inst: PatternTemplateStore = get_pattern_template_store()
processor_manager_inst: ProcessorManager = get_processor_manager()
device_store_inst: DeviceStore = get_device_store()
pp_template_store_inst = get_pp_template_store()
# Validate target
try:
target = target_store_inst.get_target(target_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
if not isinstance(target, KeyColorsOutputTarget):
await websocket.close(code=4003, reason="Target is not a key_colors target")
return
settings = target.settings
if not settings.pattern_template_id:
await websocket.close(code=4003, reason="No pattern template configured")
return
try:
pattern_tmpl = pattern_store_inst.get_template(settings.pattern_template_id)
except ValueError:
await websocket.close(code=4003, reason=f"Pattern template not found: {settings.pattern_template_id}")
return
rectangles = pattern_tmpl.rectangles
if not rectangles:
await websocket.close(code=4003, reason="Pattern template has no rectangles")
return
if not target.picture_source_id:
await websocket.close(code=4003, reason="No picture source configured")
return
try:
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
except ValueError as e:
await websocket.close(code=4003, reason=str(e))
return
raw_stream = chain["raw_stream"]
# For screen capture sources, check display lock
if isinstance(raw_stream, ScreenCapturePictureSource):
display_index = raw_stream.display_index
locked_device_id = processor_manager_inst.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store_inst.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
await websocket.close(
code=4003,
reason=f"Display {display_index} is captured by '{device_name}'. Stop processing first.",
)
return
fps = max(1, min(30, fps))
preview_width = max(120, min(1920, preview_width))
frame_interval = 1.0 / fps
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
await websocket.accept()
logger.info(f"KC test WS connected for {target_id} (fps={fps})")
# Use the shared LiveStreamManager so we share the capture stream with
# running LED targets instead of creating a competing DXGI duplicator.
live_stream_mgr = processor_manager_inst._live_stream_manager
live_stream = None
try:
live_stream = await asyncio.to_thread(
live_stream_mgr.acquire, target.picture_source_id
)
logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}")
prev_frame_ref = None
while True:
loop_start = time.monotonic()
try:
capture = await asyncio.to_thread(live_stream.get_latest_frame)
if capture is None or capture.image is None:
await asyncio.sleep(frame_interval)
continue
# Skip if same frame object (no new capture yet)
if capture is prev_frame_ref:
await asyncio.sleep(frame_interval * 0.5)
continue
prev_frame_ref = capture
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None
if pil_image is None:
await asyncio.sleep(frame_interval)
continue
# Apply postprocessing (if the source chain has PP templates)
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store_inst:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store_inst.get_template(pp_id)
except ValueError:
continue
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
pass
pil_image = Image.fromarray(img_array)
# Extract colors
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append({
"name": rect.name,
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"color": {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"},
})
# Encode frame as JPEG
if preview_width and pil_image.width > preview_width:
ratio = preview_width / pil_image.width
thumb = pil_image.resize((preview_width, int(pil_image.height * ratio)), Image.LANCZOS)
else:
thumb = pil_image
buf = io.BytesIO()
thumb.save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode()
await websocket.send_text(_json.dumps({
"type": "frame",
"image": f"data:image/jpeg;base64,{b64}",
"rectangles": result_rects,
"pattern_template_name": pattern_tmpl.name,
"interpolation_mode": settings.interpolation_mode,
}))
except (WebSocketDisconnect, Exception) as inner_e:
if isinstance(inner_e, WebSocketDisconnect):
raise
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
elapsed = time.monotonic() - loop_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except WebSocketDisconnect:
logger.info(f"KC test WS disconnected for {target_id}")
except Exception as e:
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
finally:
if live_stream is not None:
try:
await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id
)
except Exception:
pass
logger.info(f"KC test WS closed for {target_id}")
@router.websocket("/api/v1/output-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
try:
manager.add_kc_ws_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
# Keep alive — wait for client messages (or disconnect)
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)

View File

@@ -584,7 +584,7 @@ async def test_picture_source_ws(
preview_width: int = Query(0),
):
"""WebSocket for picture source test with intermediate frame previews."""
from wled_controller.api.routes._test_helpers import (
from wled_controller.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)

View File

@@ -365,7 +365,7 @@ async def test_pp_template_ws(
preview_width: int = Query(0),
):
"""WebSocket for PP template test with intermediate frame previews."""
from wled_controller.api.routes._test_helpers import (
from wled_controller.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)

View File

@@ -1,26 +1,21 @@
"""System routes: health, version, displays, performance, backup/restore, ADB."""
"""System routes: health, version, displays, performance, tags, api-keys.
Backup/restore and settings routes are in backup.py and system_settings.py.
"""
import asyncio
import io
import json
import logging
import platform
import subprocess
import sys
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import psutil
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query
from wled_controller import __version__
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_auto_backup_engine,
get_audio_source_store,
get_audio_template_store,
get_automation_store,
@@ -37,29 +32,22 @@ from wled_controller.api.dependencies import (
get_value_source_store,
)
from wled_controller.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
BackupFileInfo,
BackupListResponse,
DisplayInfo,
DisplayListResponse,
ExternalUrlRequest,
ExternalUrlResponse,
GpuInfo,
HealthResponse,
LogLevelRequest,
LogLevelResponse,
MQTTSettingsRequest,
MQTTSettingsResponse,
PerformanceResponse,
ProcessListResponse,
RestoreResponse,
VersionResponse,
)
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.config import get_config, is_demo_mode
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.utils import atomic_write_json, get_logger
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
# Re-export STORE_MAP and load_external_url so existing callers still work
from wled_controller.api.routes.backup import STORE_MAP # noqa: F401
from wled_controller.api.routes.system_settings import load_external_url # noqa: F401
logger = get_logger(__name__)
@@ -68,7 +56,6 @@ psutil.cpu_percent(interval=None)
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
from wled_controller.storage.base_store import EntityNotFoundError
def _get_cpu_name() -> str | None:
@@ -113,7 +100,7 @@ async def health_check():
Returns basic health information including status, version, and timestamp.
"""
logger.info("Health check requested")
logger.debug("Health check requested")
return HealthResponse(
status="healthy",
@@ -129,7 +116,7 @@ async def get_version():
Returns application version, Python version, and API version.
"""
logger.info("Version info requested")
logger.debug("Version info requested")
return VersionResponse(
version=__version__,
@@ -308,52 +295,6 @@ async def get_metrics_history(
return manager.metrics_history.get_history()
# ---------------------------------------------------------------------------
# Configuration backup / restore
# ---------------------------------------------------------------------------
# Mapping: logical store name → StorageConfig attribute name
STORE_MAP = {
"devices": "devices_file",
"capture_templates": "templates_file",
"postprocessing_templates": "postprocessing_templates_file",
"picture_sources": "picture_sources_file",
"output_targets": "output_targets_file",
"pattern_templates": "pattern_templates_file",
"color_strip_sources": "color_strip_sources_file",
"audio_sources": "audio_sources_file",
"audio_templates": "audio_templates_file",
"value_sources": "value_sources_file",
"sync_clocks": "sync_clocks_file",
"color_strip_processing_templates": "color_strip_processing_templates_file",
"automations": "automations_file",
"scene_presets": "scene_presets_file",
}
_SERVER_DIR = Path(__file__).resolve().parents[4]
def _schedule_restart() -> None:
"""Spawn a restart script after a short delay so the HTTP response completes."""
def _restart():
import time
time.sleep(1)
if sys.platform == "win32":
subprocess.Popen(
["powershell", "-ExecutionPolicy", "Bypass", "-File",
str(_SERVER_DIR / "restart.ps1")],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
subprocess.Popen(
["bash", str(_SERVER_DIR / "restart.sh")],
start_new_session=True,
)
threading.Thread(target=_restart, daemon=True).start()
@router.get("/api/v1/system/api-keys", tags=["System"])
def list_api_keys(_: AuthRequired):
"""List API key labels (read-only; keys are defined in the YAML config file)."""
@@ -363,632 +304,3 @@ def list_api_keys(_: AuthRequired):
for label, key in config.auth.api_keys.items()
]
return {"keys": keys, "count": len(keys)}
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
def export_store(store_key: str, _: AuthRequired):
"""Download a single entity store as a JSON file."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
export = {
"meta": {
"format": "ledgrab-partial-export",
"format_version": 1,
"store_key": store_key,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
},
"store": data,
}
content = json.dumps(export, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-{store_key}-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
async def import_store(
store_key: str,
_: AuthRequired,
file: UploadFile = File(...),
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
):
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
payload = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
# Support both full-backup format and partial-export format
if "stores" in payload and isinstance(payload.get("meta"), dict):
# Full backup: extract the specific store
if payload["meta"].get("format") not in ("ledgrab-backup",):
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
stores = payload.get("stores", {})
if store_key not in stores:
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
incoming = stores[store_key]
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
# Partial export format
if payload["meta"].get("store_key") != store_key:
raise HTTPException(
status_code=400,
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
)
incoming = payload.get("store", {})
else:
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
if not isinstance(incoming, dict):
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
def _write():
if merge and file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
existing = json.load(f)
if isinstance(existing, dict):
existing.update(incoming)
atomic_write_json(file_path, existing)
return len(existing)
atomic_write_json(file_path, incoming)
return len(incoming)
count = await asyncio.to_thread(_write)
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
_schedule_restart()
return {
"status": "imported",
"store_key": store_key,
"entries": count,
"merge": merge,
"restart_scheduled": True,
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
}
@router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired):
"""Download all configuration as a single JSON backup file."""
config = get_config()
stores = {}
for store_key, config_attr in STORE_MAP.items():
file_path = Path(getattr(config.storage, config_attr))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
stores[store_key] = json.load(f)
else:
stores[store_key] = {}
backup = {
"meta": {
"format": "ledgrab-backup",
"format_version": 1,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
"store_count": len(stores),
},
"stores": stores,
}
content = json.dumps(backup, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
_schedule_restart()
return {"status": "restarting"}
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
async def restore_config(
_: AuthRequired,
file: UploadFile = File(...),
):
"""Upload a backup file to restore all configuration. Triggers server restart."""
# Read and parse
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024: # 10 MB limit
raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)")
backup = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
# Validate envelope
meta = backup.get("meta")
if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup":
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file")
fmt_version = meta.get("format_version", 0)
if fmt_version > 1:
raise HTTPException(
status_code=400,
detail=f"Backup format version {fmt_version} is not supported by this server version",
)
stores = backup.get("stores")
if not isinstance(stores, dict):
raise HTTPException(status_code=400, detail="Backup file missing 'stores' section")
known_keys = set(STORE_MAP.keys())
present_keys = known_keys & set(stores.keys())
if not present_keys:
raise HTTPException(status_code=400, detail="Backup contains no recognized store data")
for key in present_keys:
if not isinstance(stores[key], dict):
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
# Write store files atomically (in thread to avoid blocking event loop)
config = get_config()
def _write_stores():
count = 0
for store_key, config_attr in STORE_MAP.items():
if store_key in stores:
file_path = Path(getattr(config.storage, config_attr))
atomic_write_json(file_path, stores[store_key])
count += 1
logger.info(f"Restored store: {store_key} -> {file_path}")
return count
written = await asyncio.to_thread(_write_stores)
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
_schedule_restart()
missing = known_keys - present_keys
return RestoreResponse(
status="restored",
stores_written=written,
stores_total=len(STORE_MAP),
missing_stores=sorted(missing) if missing else [],
restart_scheduled=True,
message=f"Restored {written} stores. Server restarting...",
)
# ---------------------------------------------------------------------------
# Auto-backup settings & saved backups
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def get_auto_backup_settings(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Get auto-backup settings and status."""
return engine.get_settings()
@router.put(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def update_auto_backup_settings(
_: AuthRequired,
body: AutoBackupSettings,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings(
enabled=body.enabled,
interval_hours=body.interval_hours,
max_backups=body.max_backups,
)
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
async def trigger_backup(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Manually trigger a backup now."""
backup = await engine.trigger_backup()
return {"status": "ok", "backup": backup}
@router.get(
"/api/v1/system/backups",
response_model=BackupListResponse,
tags=["System"],
)
async def list_backups(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""List all saved backup files."""
backups = engine.list_backups()
return BackupListResponse(
backups=[BackupFileInfo(**b) for b in backups],
count=len(backups),
)
@router.get("/api/v1/system/backups/{filename}", tags=["System"])
def download_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Download a specific saved backup file."""
try:
path = engine.get_backup_path(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
content = path.read_bytes()
return StreamingResponse(
io.BytesIO(content),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete("/api/v1/system/backups/{filename}", tags=["System"])
async def delete_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Delete a specific saved backup file."""
try:
engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "deleted", "filename": filename}
# ---------------------------------------------------------------------------
# MQTT settings
# ---------------------------------------------------------------------------
_MQTT_SETTINGS_FILE: Path | None = None
def _get_mqtt_settings_path() -> Path:
global _MQTT_SETTINGS_FILE
if _MQTT_SETTINGS_FILE is None:
cfg = get_config()
# Derive the data directory from any known storage file path
data_dir = Path(cfg.storage.devices_file).parent
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
return _MQTT_SETTINGS_FILE
def _load_mqtt_settings() -> dict:
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
cfg = get_config()
defaults = {
"enabled": cfg.mqtt.enabled,
"broker_host": cfg.mqtt.broker_host,
"broker_port": cfg.mqtt.broker_port,
"username": cfg.mqtt.username,
"password": cfg.mqtt.password,
"client_id": cfg.mqtt.client_id,
"base_topic": cfg.mqtt.base_topic,
}
path = _get_mqtt_settings_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
overrides = json.load(f)
defaults.update(overrides)
except Exception as e:
logger.warning(f"Failed to load MQTT settings override file: {e}")
return defaults
def _save_mqtt_settings(settings: dict) -> None:
"""Persist MQTT settings to the JSON override file."""
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_mqtt_settings_path(), settings)
@router.get(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def get_mqtt_settings(_: AuthRequired):
"""Get current MQTT broker settings. Password is masked."""
s = _load_mqtt_settings()
return MQTTSettingsResponse(
enabled=s["enabled"],
broker_host=s["broker_host"],
broker_port=s["broker_port"],
username=s["username"],
password_set=bool(s.get("password")),
client_id=s["client_id"],
base_topic=s["base_topic"],
)
@router.put(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings()
# If caller sends an empty password, keep the existing one
password = body.password if body.password else current.get("password", "")
new_settings = {
"enabled": body.enabled,
"broker_host": body.broker_host,
"broker_port": body.broker_port,
"username": body.username,
"password": password,
"client_id": body.client_id,
"base_topic": body.base_topic,
}
_save_mqtt_settings(new_settings)
logger.info("MQTT settings updated")
return MQTTSettingsResponse(
enabled=new_settings["enabled"],
broker_host=new_settings["broker_host"],
broker_port=new_settings["broker_port"],
username=new_settings["username"],
password_set=bool(new_settings["password"]),
client_id=new_settings["client_id"],
base_topic=new_settings["base_topic"],
)
# ---------------------------------------------------------------------------
# External URL setting
# ---------------------------------------------------------------------------
_EXTERNAL_URL_FILE: Path | None = None
def _get_external_url_path() -> Path:
global _EXTERNAL_URL_FILE
if _EXTERNAL_URL_FILE is None:
cfg = get_config()
data_dir = Path(cfg.storage.devices_file).parent
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
return _EXTERNAL_URL_FILE
def load_external_url() -> str:
"""Load the external URL setting. Returns empty string if not set."""
path = _get_external_url_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("external_url", "")
except Exception:
pass
return ""
def _save_external_url(url: str) -> None:
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_external_url_path(), {"external_url": url})
@router.get(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def get_external_url(_: AuthRequired):
"""Get the configured external base URL."""
return ExternalUrlResponse(external_url=load_external_url())
@router.put(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/")
_save_external_url(url)
logger.info("External URL updated: %s", url or "(cleared)")
return ExternalUrlResponse(external_url=url)
# ---------------------------------------------------------------------------
# Live log viewer WebSocket
# ---------------------------------------------------------------------------
@router.websocket("/api/v1/system/logs/ws")
async def logs_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket that streams server log lines in real time.
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
lines as individual text messages, then pushes new lines as they appear.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.utils import log_broadcaster
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
# Ensure the broadcaster knows the event loop (may be first connection)
log_broadcaster.ensure_loop()
# Subscribe *before* reading the backlog so no lines slip through
queue = log_broadcaster.subscribe()
try:
# Send backlog first
for line in log_broadcaster.get_backlog():
await websocket.send_text(line)
# Stream new lines
while True:
try:
line = await asyncio.wait_for(queue.get(), timeout=30.0)
await websocket.send_text(line)
except asyncio.TimeoutError:
# Send a keepalive ping so the connection stays alive
try:
await websocket.send_text("")
except Exception:
break
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
log_broadcaster.unsubscribe(queue)
# ---------------------------------------------------------------------------
# ADB helpers (for Android / scrcpy engine)
# ---------------------------------------------------------------------------
class AdbConnectRequest(BaseModel):
address: str
def _get_adb_path() -> str:
"""Get the adb binary path from the scrcpy engine's resolver."""
from wled_controller.core.capture_engines.scrcpy_engine import _get_adb
return _get_adb()
@router.post("/api/v1/adb/connect", tags=["ADB"])
async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
"""Connect to a WiFi ADB device by IP address.
Appends ``:5555`` if no port is specified.
"""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
if ":" not in address:
address = f"{address}:5555"
adb = _get_adb_path()
logger.info(f"Connecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "connect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = (stdout.decode() + stderr.decode()).strip()
if "connected" in output.lower():
return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed")
except FileNotFoundError:
raise HTTPException(
status_code=500,
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB connect timed out")
@router.post("/api/v1/adb/disconnect", tags=["ADB"])
async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
"""Disconnect a WiFi ADB device."""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
adb = _get_adb_path()
logger.info(f"Disconnecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "disconnect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
return {"status": "disconnected", "message": stdout.decode().strip()}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
# ─── Log level ─────────────────────────────────────────────────
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
@router.get("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def get_log_level(_: AuthRequired):
"""Get the current root logger log level."""
level_int = logging.getLogger().getEffectiveLevel()
return LogLevelResponse(level=logging.getLevelName(level_int))
@router.put("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def set_log_level(_: AuthRequired, body: LogLevelRequest):
"""Change the root logger log level at runtime (no server restart required)."""
level_name = body.level.upper()
if level_name not in _VALID_LOG_LEVELS:
raise HTTPException(
status_code=400,
detail=f"Invalid log level '{body.level}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}",
)
level_int = getattr(logging, level_name)
root = logging.getLogger()
root.setLevel(level_int)
# Also update all handlers so they actually emit at the new level
for handler in root.handlers:
handler.setLevel(level_int)
logger.info("Log level changed to %s", level_name)
return LogLevelResponse(level=level_name)

View File

@@ -0,0 +1,377 @@
"""System routes: MQTT, external URL, ADB, logs WebSocket, log level.
Extracted from system.py to keep files under 800 lines.
"""
import asyncio
import json
import logging
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from wled_controller.api.auth import AuthRequired
from wled_controller.api.schemas.system import (
ExternalUrlRequest,
ExternalUrlResponse,
LogLevelRequest,
LogLevelResponse,
MQTTSettingsRequest,
MQTTSettingsResponse,
)
from wled_controller.config import get_config
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# MQTT settings
# ---------------------------------------------------------------------------
_MQTT_SETTINGS_FILE: Path | None = None
def _get_mqtt_settings_path() -> Path:
global _MQTT_SETTINGS_FILE
if _MQTT_SETTINGS_FILE is None:
cfg = get_config()
# Derive the data directory from any known storage file path
data_dir = Path(cfg.storage.devices_file).parent
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
return _MQTT_SETTINGS_FILE
def _load_mqtt_settings() -> dict:
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
cfg = get_config()
defaults = {
"enabled": cfg.mqtt.enabled,
"broker_host": cfg.mqtt.broker_host,
"broker_port": cfg.mqtt.broker_port,
"username": cfg.mqtt.username,
"password": cfg.mqtt.password,
"client_id": cfg.mqtt.client_id,
"base_topic": cfg.mqtt.base_topic,
}
path = _get_mqtt_settings_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
overrides = json.load(f)
defaults.update(overrides)
except Exception as e:
logger.warning(f"Failed to load MQTT settings override file: {e}")
return defaults
def _save_mqtt_settings(settings: dict) -> None:
"""Persist MQTT settings to the JSON override file."""
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_mqtt_settings_path(), settings)
@router.get(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def get_mqtt_settings(_: AuthRequired):
"""Get current MQTT broker settings. Password is masked."""
s = _load_mqtt_settings()
return MQTTSettingsResponse(
enabled=s["enabled"],
broker_host=s["broker_host"],
broker_port=s["broker_port"],
username=s["username"],
password_set=bool(s.get("password")),
client_id=s["client_id"],
base_topic=s["base_topic"],
)
@router.put(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings()
# If caller sends an empty password, keep the existing one
password = body.password if body.password else current.get("password", "")
new_settings = {
"enabled": body.enabled,
"broker_host": body.broker_host,
"broker_port": body.broker_port,
"username": body.username,
"password": password,
"client_id": body.client_id,
"base_topic": body.base_topic,
}
_save_mqtt_settings(new_settings)
logger.info("MQTT settings updated")
return MQTTSettingsResponse(
enabled=new_settings["enabled"],
broker_host=new_settings["broker_host"],
broker_port=new_settings["broker_port"],
username=new_settings["username"],
password_set=bool(new_settings["password"]),
client_id=new_settings["client_id"],
base_topic=new_settings["base_topic"],
)
# ---------------------------------------------------------------------------
# External URL setting
# ---------------------------------------------------------------------------
_EXTERNAL_URL_FILE: Path | None = None
def _get_external_url_path() -> Path:
global _EXTERNAL_URL_FILE
if _EXTERNAL_URL_FILE is None:
cfg = get_config()
data_dir = Path(cfg.storage.devices_file).parent
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
return _EXTERNAL_URL_FILE
def load_external_url() -> str:
"""Load the external URL setting. Returns empty string if not set."""
path = _get_external_url_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("external_url", "")
except Exception:
pass
return ""
def _save_external_url(url: str) -> None:
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_external_url_path(), {"external_url": url})
@router.get(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def get_external_url(_: AuthRequired):
"""Get the configured external base URL."""
return ExternalUrlResponse(external_url=load_external_url())
@router.put(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/")
_save_external_url(url)
logger.info("External URL updated: %s", url or "(cleared)")
return ExternalUrlResponse(external_url=url)
# ---------------------------------------------------------------------------
# Live log viewer WebSocket
# ---------------------------------------------------------------------------
@router.websocket("/api/v1/system/logs/ws")
async def logs_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket that streams server log lines in real time.
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
lines as individual text messages, then pushes new lines as they appear.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.utils import log_broadcaster
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
# Ensure the broadcaster knows the event loop (may be first connection)
log_broadcaster.ensure_loop()
# Subscribe *before* reading the backlog so no lines slip through
queue = log_broadcaster.subscribe()
try:
# Send backlog first
for line in log_broadcaster.get_backlog():
await websocket.send_text(line)
# Stream new lines
while True:
try:
line = await asyncio.wait_for(queue.get(), timeout=30.0)
await websocket.send_text(line)
except asyncio.TimeoutError:
# Send a keepalive ping so the connection stays alive
try:
await websocket.send_text("")
except Exception:
break
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
log_broadcaster.unsubscribe(queue)
# ---------------------------------------------------------------------------
# ADB helpers (for Android / scrcpy engine)
# ---------------------------------------------------------------------------
# Regex: IPv4 address with optional port, e.g. "192.168.1.5" or "192.168.1.5:5555"
_ADB_ADDRESS_RE = re.compile(
r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?$"
)
class AdbConnectRequest(BaseModel):
address: str
def _validate_adb_address(address: str) -> None:
"""Raise 400 if *address* is not a valid IP:port for ADB."""
if not _ADB_ADDRESS_RE.match(address):
raise HTTPException(
status_code=400,
detail=(
f"Invalid ADB address '{address}'. "
"Expected format: <IP> or <IP>:<port>, e.g. 192.168.1.5 or 192.168.1.5:5555"
),
)
# Validate each octet is 0-255 and port is 1-65535
parts = address.split(":")
ip_parts = parts[0].split(".")
for octet in ip_parts:
if not (0 <= int(octet) <= 255):
raise HTTPException(
status_code=400,
detail=f"Invalid IP octet '{octet}' in address '{address}'. Each octet must be 0-255.",
)
if len(parts) == 2:
port = int(parts[1])
if not (1 <= port <= 65535):
raise HTTPException(
status_code=400,
detail=f"Invalid port '{parts[1]}' in address '{address}'. Port must be 1-65535.",
)
def _get_adb_path() -> str:
"""Get the adb binary path from the scrcpy engine's resolver."""
from wled_controller.core.capture_engines.scrcpy_engine import _get_adb
return _get_adb()
@router.post("/api/v1/adb/connect", tags=["ADB"])
async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
"""Connect to a WiFi ADB device by IP address.
Appends ``:5555`` if no port is specified.
"""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
_validate_adb_address(address)
if ":" not in address:
address = f"{address}:5555"
adb = _get_adb_path()
logger.info(f"Connecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "connect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = (stdout.decode() + stderr.decode()).strip()
if "connected" in output.lower():
return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed")
except FileNotFoundError:
raise HTTPException(
status_code=500,
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB connect timed out")
@router.post("/api/v1/adb/disconnect", tags=["ADB"])
async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
"""Disconnect a WiFi ADB device."""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
adb = _get_adb_path()
logger.info(f"Disconnecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "disconnect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
return {"status": "disconnected", "message": stdout.decode().strip()}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
# --- Log level -----
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
@router.get("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def get_log_level(_: AuthRequired):
"""Get the current root logger log level."""
level_int = logging.getLogger().getEffectiveLevel()
return LogLevelResponse(level=logging.getLevelName(level_int))
@router.put("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def set_log_level(_: AuthRequired, body: LogLevelRequest):
"""Change the root logger log level at runtime (no server restart required)."""
level_name = body.level.upper()
if level_name not in _VALID_LOG_LEVELS:
raise HTTPException(
status_code=400,
detail=f"Invalid log level '{body.level}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}",
)
level_int = getattr(logging, level_name)
root = logging.getLogger()
root.setLevel(level_int)
# Also update all handlers so they actually emit at the new level
for handler in root.handlers:
handler.setLevel(level_int)
logger.info("Log level changed to %s", level_name)
return LogLevelResponse(level=level_name)

View File

@@ -403,7 +403,7 @@ async def test_template_ws(
Config is sent as the first client message (JSON with engine_type,
engine_config, display_index, capture_duration).
"""
from wled_controller.api.routes._test_helpers import (
from wled_controller.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)

View File

@@ -6,7 +6,10 @@ automations that have a webhook condition. No API-key auth is required —
the secret token itself authenticates the caller.
"""
from fastapi import APIRouter, Depends, HTTPException
import time
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from wled_controller.api.dependencies import get_automation_engine, get_automation_store
@@ -18,6 +21,28 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Simple in-memory rate limiter: 30 requests per 60-second window per IP
# ---------------------------------------------------------------------------
_RATE_LIMIT = 30
_RATE_WINDOW = 60.0 # seconds
_rate_hits: dict[str, list[float]] = defaultdict(list)
def _check_rate_limit(client_ip: str) -> None:
"""Raise 429 if *client_ip* exceeded the webhook rate limit."""
now = time.time()
window_start = now - _RATE_WINDOW
# Prune timestamps outside the window
timestamps = _rate_hits[client_ip]
_rate_hits[client_ip] = [t for t in timestamps if t > window_start]
if len(_rate_hits[client_ip]) >= _RATE_LIMIT:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded. Max 30 webhook requests per minute.",
)
_rate_hits[client_ip].append(now)
class WebhookPayload(BaseModel):
action: str = Field(description="'activate' or 'deactivate'")
@@ -30,10 +55,13 @@ class WebhookPayload(BaseModel):
async def handle_webhook(
token: str,
body: WebhookPayload,
request: Request,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Receive a webhook call and set the corresponding condition state."""
_check_rate_limit(request.client.host if request.client else "unknown")
if body.action not in ("activate", "deactivate"):
raise HTTPException(status_code=400, detail="action must be 'activate' or 'deactivate'")

View File

@@ -144,6 +144,18 @@ class CalibrationTestModeResponse(BaseModel):
device_id: str = Field(description="Device ID")
class BrightnessRequest(BaseModel):
"""Request to set device brightness."""
brightness: int = Field(ge=0, le=255, description="Brightness level (0-255)")
class PowerRequest(BaseModel):
"""Request to set device power state."""
power: bool = Field(description="Whether the device should be on (true) or off (false)")
class DeviceResponse(BaseModel):
"""Device information response."""

View File

@@ -1,6 +1,10 @@
"""Target processing pipeline."""
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.processor_manager import (
DeviceState,
ProcessorDependencies,
ProcessorManager,
)
from wled_controller.core.processing.target_processor import (
DeviceInfo,
ProcessingMetrics,
@@ -10,7 +14,9 @@ from wled_controller.core.processing.target_processor import (
__all__ = [
"DeviceInfo",
"DeviceState",
"ProcessingMetrics",
"ProcessorDependencies",
"ProcessorManager",
"TargetContext",
"TargetProcessor",

View File

@@ -0,0 +1,144 @@
"""Auto-restart mixin for ProcessorManager.
Handles crash detection, exponential backoff, and automatic restart
of failed target processors.
Extracted from processor_manager.py to keep files under 800 lines.
"""
import asyncio
import time
from dataclasses import dataclass
from typing import Dict, Optional
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Auto-restart constants
RESTART_MAX_ATTEMPTS = 5 # max restarts within the window
RESTART_WINDOW_SEC = 300 # 5 minutes — reset counter after stable period
RESTART_BACKOFF_BASE = 2.0 # initial backoff seconds
RESTART_BACKOFF_MAX = 30.0 # cap backoff at 30s
@dataclass
class RestartState:
"""Per-target auto-restart tracking."""
attempts: int = 0
first_crash_time: float = 0.0
last_crash_time: float = 0.0
restart_task: Optional[asyncio.Task] = None
enabled: bool = True # disabled on manual stop
class AutoRestartMixin:
"""Mixin providing auto-restart logic for crashed target processors.
Requires the host class to have:
_processors: Dict[str, TargetProcessor]
_restart_states: Dict[str, RestartState]
fire_event(event: dict) -> None
start_processing(target_id: str) -> coroutine
"""
def _on_task_done(self, target_id: str, task: asyncio.Task) -> None:
"""Task done callback — detects crashes and schedules auto-restart."""
# Ignore graceful cancellation (manual stop)
if task.cancelled():
return
exc = task.exception()
if exc is None:
return # Clean exit (shouldn't happen, but harmless)
rs = self._restart_states.get(target_id)
if not rs or not rs.enabled:
return # Auto-restart disabled (manual stop was called)
now = time.monotonic()
# Reset counter if previous crash window expired
if rs.first_crash_time and (now - rs.first_crash_time) > RESTART_WINDOW_SEC:
rs.attempts = 0
rs.first_crash_time = 0.0
rs.attempts += 1
rs.last_crash_time = now
if not rs.first_crash_time:
rs.first_crash_time = now
if rs.attempts > RESTART_MAX_ATTEMPTS:
logger.error(
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
f"in {now - rs.first_crash_time:.0f}s — giving up"
)
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_exhausted": True,
})
return
backoff = min(
RESTART_BACKOFF_BASE * (2 ** (rs.attempts - 1)),
RESTART_BACKOFF_MAX,
)
logger.warning(
f"[AUTO-RESTART] Target {target_id} crashed (attempt {rs.attempts}/"
f"{RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
)
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_in": backoff,
"auto_restart_attempt": rs.attempts,
})
# Schedule the restart (runs in the event loop)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
logger.error(f"[AUTO-RESTART] No running event loop for {target_id}")
return
rs.restart_task = loop.create_task(self._auto_restart(target_id, backoff))
async def _auto_restart(self, target_id: str, delay: float) -> None:
"""Wait for backoff delay, then restart the target processor."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
logger.info(f"[AUTO-RESTART] Restart cancelled for {target_id}")
return
rs = self._restart_states.get(target_id)
if not rs or not rs.enabled:
logger.info(f"[AUTO-RESTART] Restart aborted for {target_id} (disabled)")
return
proc = self._processors.get(target_id)
if proc is None:
logger.warning(f"[AUTO-RESTART] Target {target_id} no longer registered")
return
if proc.is_running:
logger.info(f"[AUTO-RESTART] Target {target_id} already running, skipping")
return
logger.info(f"[AUTO-RESTART] Restarting target {target_id} (attempt {rs.attempts})")
try:
await self.start_processing(target_id)
except Exception as e:
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_error": str(e),
})

View File

@@ -0,0 +1,145 @@
"""Device health monitoring mixin for ProcessorManager.
Extracted from processor_manager.py to keep files under 800 lines.
"""
import asyncio
from datetime import datetime, timezone
from wled_controller.core.devices.led_client import (
DeviceHealth,
check_device_health,
get_device_capabilities,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class DeviceHealthMixin:
"""Mixin providing health monitoring loop and health check methods.
Requires the host class to have:
_devices: Dict[str, DeviceState]
_processors: Dict[str, TargetProcessor]
_health_monitoring_active: bool
_http_client: Optional[httpx.AsyncClient]
_device_store: object
fire_event(event: dict) -> None
_get_http_client() -> httpx.AsyncClient
"""
# ===== HEALTH MONITORING =====
async def start_health_monitoring(self):
"""Start background health checks for all registered devices."""
self._health_monitoring_active = True
for device_id in self._devices:
self._start_device_health_check(device_id)
await self._metrics_history.start()
logger.info("Started health monitoring for all devices")
async def stop_health_monitoring(self):
"""Stop all background health checks."""
self._health_monitoring_active = False
for device_id in list(self._devices.keys()):
self._stop_device_health_check(device_id)
logger.info("Stopped health monitoring for all devices")
def _start_device_health_check(self, device_id: str):
state = self._devices.get(device_id)
if not state:
return
# Skip periodic health checks for virtual devices (always online)
if "health_check" not in get_device_capabilities(state.device_type):
state.health = DeviceHealth(
online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc)
)
return
if state.health_task and not state.health_task.done():
return
state.health_task = asyncio.create_task(self._health_check_loop(device_id))
def _stop_device_health_check(self, device_id: str):
state = self._devices.get(device_id)
if not state or not state.health_task:
return
state.health_task.cancel()
state.health_task = None
def _device_is_processing(self, device_id: str) -> bool:
"""Check if any target is actively streaming to this device."""
return any(
p.device_id == device_id and p.is_running
for p in self._processors.values()
)
def _is_device_streaming(self, device_id: str) -> bool:
"""Check if any running processor targets this device."""
for proc in self._processors.values():
if getattr(proc, 'device_id', None) == device_id and proc.is_running:
return True
return False
async def _health_check_loop(self, device_id: str):
"""Background loop that periodically checks a device.
Uses adaptive intervals: 10s for actively streaming devices,
60s for idle devices, to balance responsiveness with overhead.
"""
state = self._devices.get(device_id)
if not state:
return
ACTIVE_INTERVAL = 10 # streaming devices — faster detection
IDLE_INTERVAL = 60 # idle devices — less overhead
try:
while self._health_monitoring_active:
await self._check_device_health(device_id)
interval = ACTIVE_INTERVAL if self._is_device_streaming(device_id) else IDLE_INTERVAL
await asyncio.sleep(interval)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
async def _check_device_health(self, device_id: str):
"""Check device health. Also auto-syncs LED count if changed."""
state = self._devices.get(device_id)
if not state:
return
prev_online = state.health.online
client = await self._get_http_client()
state.health = await check_device_health(
state.device_type, state.device_url, client, state.health,
)
# Fire event when online status changes
if state.health.online != prev_online:
self.fire_event({
"type": "device_health_changed",
"device_id": device_id,
"online": state.health.online,
"latency_ms": state.health.latency_ms,
})
# Auto-sync LED count
reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store:
old_count = state.led_count
logger.info(
f"Device {device_id} LED count changed: {old_count}{reported}"
)
try:
self._device_store.update_device(device_id, led_count=reported)
state.led_count = reported
except Exception as e:
logger.error(f"Failed to sync LED count for {device_id}: {e}")
async def force_device_health_check(self, device_id: str) -> dict:
"""Run an immediate health check for a device and return the result."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
await self._check_device_health(device_id)
return self.get_device_health_dict(device_id)

View File

@@ -0,0 +1,189 @@
"""Device test mode and idle client management mixin for ProcessorManager.
Extracted from processor_manager.py to keep files under 800 lines.
"""
from typing import Dict, List, Optional, Tuple
from wled_controller.core.capture.calibration import CalibrationConfig
from wled_controller.core.devices.led_client import create_led_client
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class DeviceTestModeMixin:
"""Mixin providing calibration test mode and idle LED client management.
Requires the host class to have:
_devices: Dict[str, DeviceState]
_processors: Dict[str, TargetProcessor]
_idle_clients: Dict[str, object]
"""
# ===== CALIBRATION TEST MODE (on device, driven by CSS calibration) =====
async def set_test_mode(
self,
device_id: str,
edges: Dict[str, List[int]],
calibration: Optional[CalibrationConfig] = None,
) -> None:
"""Set or clear calibration test mode for a device.
When setting test mode, pass the calibration from the CSS being tested.
When clearing (edges={}), calibration is not needed.
"""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
ds = self._devices[device_id]
if edges:
ds.test_mode_active = True
ds.test_mode_edges = {
edge: tuple(color) for edge, color in edges.items()
}
if calibration is not None:
ds.test_calibration = calibration
await self._send_test_pixels(device_id)
else:
ds.test_mode_active = False
ds.test_mode_edges = {}
ds.test_calibration = None
await self._send_clear_pixels(device_id)
async def _get_idle_client(self, device_id: str):
"""Get or create a cached idle LED client for a device.
Reuses an existing connected client to avoid repeated serial
reconnection (which triggers Arduino bootloader reset on Adalight).
"""
# Prefer a running processor's client (already connected)
active = self._find_active_led_client(device_id)
if active:
return active
# Reuse cached idle client if still connected
cached = self._idle_clients.get(device_id)
if cached and cached.is_connected:
return cached
# Create and cache a new client
ds = self._devices[device_id]
client = create_led_client(
ds.device_type, ds.device_url,
use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate,
)
await client.connect()
self._idle_clients[device_id] = client
return client
async def _close_idle_client(self, device_id: str) -> None:
"""Close and remove the cached idle client for a device."""
client = self._idle_clients.pop(device_id, None)
if client:
try:
await client.close()
except Exception as e:
logger.warning(f"Error closing idle client for {device_id}: {e}")
async def _send_test_pixels(self, device_id: str) -> None:
"""Build and send test pixel array for active test edges."""
ds = self._devices[device_id]
# Require calibration to know which LEDs map to which edges
if ds.test_calibration is None:
logger.debug(f"No calibration for test mode on {device_id}, skipping LED test")
return
pixels = [(0, 0, 0)] * ds.led_count
for edge_name, color in ds.test_mode_edges.items():
for seg in ds.test_calibration.segments:
if seg.edge == edge_name:
for i in range(seg.led_start, seg.led_start + seg.led_count):
if i < ds.led_count:
pixels[i] = color
break
# Apply offset rotation (same as Phase 2 in PixelMapper.map_border_to_leds)
total_leds = ds.test_calibration.get_total_leds()
offset = ds.test_calibration.offset % total_leds if total_leds > 0 else 0
if offset > 0:
pixels = pixels[-offset:] + pixels[:-offset]
await self._send_pixels_to_device(device_id, pixels)
async def _send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to clear LED output."""
ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count
await self._send_pixels_to_device(device_id, pixels)
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
"""Send pixels to a device via cached idle client.
Reuses a cached connection to avoid repeated serial reconnections
(which trigger Arduino bootloader reset on Adalight devices).
"""
try:
client = await self._get_idle_client(device_id)
await client.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to send pixels to {device_id}: {e}")
def _find_active_led_client(self, device_id: str):
"""Find an active LED client for a device (from a running processor)."""
for proc in self._processors.values():
if proc.device_id == device_id and proc.is_running and proc.led_client:
return proc.led_client
return None
# ===== DISPLAY LOCK INFO =====
def is_display_locked(self, display_index: int) -> bool:
"""Check if a display is currently being captured by any target."""
for proc in self._processors.values():
if proc.is_running and proc.get_display_index() == display_index:
return True
return False
def get_display_lock_info(self, display_index: int) -> Optional[str]:
"""Get the device ID that is currently capturing from a display."""
for proc in self._processors.values():
if proc.is_running and proc.get_display_index() == display_index:
return proc.device_id
return None
async def clear_device(self, device_id: str) -> None:
"""Clear LED output on a device (send black / power off)."""
ds = self._devices.get(device_id)
if not ds:
raise ValueError(f"Device {device_id} not found")
try:
await self._send_clear_pixels(device_id)
except Exception as e:
logger.error(f"Failed to clear device {device_id}: {e}")
async def _restore_device_idle_state(self, device_id: str) -> None:
"""Restore a device to its idle state when all targets stop.
- For WLED: do nothing — stop() already restored the snapshot.
- For serial: do nothing — AdalightClient.close() already sent black frame.
"""
ds = self._devices.get(device_id)
if not ds or not ds.auto_shutdown:
return
if self.is_device_processing(device_id):
return
if ds.device_type == "wled":
logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot")
else:
logger.info(f"Auto-restore: {ds.device_type} device {device_id} dark (closed by processor)")
async def send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to a device (public wrapper)."""
await self._send_clear_pixels(device_id)

View File

@@ -8,13 +8,7 @@ from typing import Dict, List, Optional, Tuple
import httpx
from wled_controller.core.capture.calibration import CalibrationConfig
from wled_controller.core.devices.led_client import (
DeviceHealth,
check_device_health,
create_led_client,
get_device_capabilities,
get_provider,
)
from wled_controller.core.devices.led_client import DeviceHealth
from wled_controller.core.audio.audio_capture import AudioCaptureManager
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
@@ -28,27 +22,39 @@ from wled_controller.core.processing.target_processor import (
)
from wled_controller.core.processing.wled_target_processor import WledTargetProcessor
from wled_controller.core.processing.kc_target_processor import KCTargetProcessor
from wled_controller.core.processing.auto_restart import (
AutoRestartMixin,
RestartState as _RestartState,
RESTART_MAX_ATTEMPTS as _RESTART_MAX_ATTEMPTS,
RESTART_WINDOW_SEC as _RESTART_WINDOW_SEC,
)
from wled_controller.core.processing.device_health import DeviceHealthMixin
from wled_controller.core.processing.device_test_mode import DeviceTestModeMixin
from wled_controller.utils import get_logger
logger = get_logger(__name__)
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
# Auto-restart constants
_RESTART_MAX_ATTEMPTS = 5 # max restarts within the window
_RESTART_WINDOW_SEC = 300 # 5 minutes — reset counter after stable period
_RESTART_BACKOFF_BASE = 2.0 # initial backoff seconds
_RESTART_BACKOFF_MAX = 30.0 # cap backoff at 30s
@dataclass
class _RestartState:
"""Per-target auto-restart tracking."""
attempts: int = 0
first_crash_time: float = 0.0
last_crash_time: float = 0.0
restart_task: Optional[asyncio.Task] = None
enabled: bool = True # disabled on manual stop
class ProcessorDependencies:
"""Bundles all store and manager references needed by ProcessorManager.
Keeps the constructor signature stable when new stores are added.
"""
picture_source_store: object = None
capture_template_store: object = None
pp_template_store: object = None
pattern_template_store: object = None
device_store: object = None
color_strip_store: object = None
audio_source_store: object = None
audio_template_store: object = None
value_source_store: object = None
sync_clock_manager: object = None
cspt_store: object = None
@dataclass
@@ -79,51 +85,58 @@ class DeviceState:
zone_mode: str = "combined"
class ProcessorManager:
class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin):
"""Manages devices and delegates target processing to TargetProcessor instances.
Devices are registered for health monitoring.
Targets are registered for processing via polymorphic TargetProcessor subclasses.
Health monitoring is provided by DeviceHealthMixin.
Test mode and idle client management is provided by DeviceTestModeMixin.
"""
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None, sync_clock_manager=None, cspt_store=None):
"""Initialize processor manager."""
def __init__(self, deps: ProcessorDependencies):
"""Initialize processor manager.
Args:
deps: Bundled store and manager references.
"""
self._devices: Dict[str, DeviceState] = {}
self._processors: Dict[str, TargetProcessor] = {}
self._idle_clients: Dict[str, object] = {} # device_id -> cached LEDClient
self._health_monitoring_active = False
self._http_client: Optional[httpx.AsyncClient] = None
self._picture_source_store = picture_source_store
self._capture_template_store = capture_template_store
self._pp_template_store = pp_template_store
self._pattern_template_store = pattern_template_store
self._device_store = device_store
self._color_strip_store = color_strip_store
self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store
self._value_source_store = value_source_store
self._cspt_store = cspt_store
self._picture_source_store = deps.picture_source_store
self._capture_template_store = deps.capture_template_store
self._pp_template_store = deps.pp_template_store
self._pattern_template_store = deps.pattern_template_store
self._device_store = deps.device_store
self._color_strip_store = deps.color_strip_store
self._audio_source_store = deps.audio_source_store
self._audio_template_store = deps.audio_template_store
self._value_source_store = deps.value_source_store
self._cspt_store = deps.cspt_store
self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store
deps.picture_source_store, deps.capture_template_store, deps.pp_template_store
)
self._audio_capture_manager = AudioCaptureManager()
self._sync_clock_manager = sync_clock_manager
self._sync_clock_manager = deps.sync_clock_manager
self._color_strip_stream_manager = ColorStripStreamManager(
color_strip_store=color_strip_store,
color_strip_store=deps.color_strip_store,
live_stream_manager=self._live_stream_manager,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=audio_source_store,
audio_template_store=audio_template_store,
sync_clock_manager=sync_clock_manager,
cspt_store=cspt_store,
audio_source_store=deps.audio_source_store,
audio_template_store=deps.audio_template_store,
sync_clock_manager=deps.sync_clock_manager,
cspt_store=deps.cspt_store,
)
self._value_stream_manager = ValueStreamManager(
value_source_store=value_source_store,
value_source_store=deps.value_source_store,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=audio_source_store,
audio_source_store=deps.audio_source_store,
live_stream_manager=self._live_stream_manager,
audio_template_store=audio_template_store,
) if value_source_store else None
audio_template_store=deps.audio_template_store,
) if deps.value_source_store else None
# Wire value stream manager into CSS stream manager for composite layer brightness
self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
self._overlay_manager = OverlayManager()
@@ -167,70 +180,37 @@ class ProcessorManager:
get_device_info=self._get_device_info,
)
# Default values for device-specific fields read from persistent storage
_DEVICE_FIELD_DEFAULTS = {
"send_latency_ms": 0, "rgbw": False, "dmx_protocol": "artnet",
"dmx_start_universe": 0, "dmx_start_channel": 1, "espnow_peer_mac": "",
"espnow_channel": 1, "hue_username": "", "hue_client_key": "",
"hue_entertainment_group_id": "", "spi_speed_hz": 800000,
"spi_led_type": "WS2812B", "chroma_device_type": "chromalink",
"gamesense_device_type": "keyboard",
}
def _get_device_info(self, device_id: str) -> Optional[DeviceInfo]:
"""Create a DeviceInfo snapshot from the current device state."""
ds = self._devices.get(device_id)
if ds is None:
return None
# Read device-specific fields from persistent storage
send_latency_ms = 0
rgbw = False
dmx_protocol = "artnet"
dmx_start_universe = 0
dmx_start_channel = 1
espnow_peer_mac = ""
espnow_channel = 1
hue_username = ""
hue_client_key = ""
hue_entertainment_group_id = ""
spi_speed_hz = 800000
spi_led_type = "WS2812B"
chroma_device_type = "chromalink"
gamesense_device_type = "keyboard"
extras = dict(self._DEVICE_FIELD_DEFAULTS)
if self._device_store:
try:
dev = self._device_store.get_device(ds.device_id)
send_latency_ms = getattr(dev, "send_latency_ms", 0)
rgbw = getattr(dev, "rgbw", False)
dmx_protocol = getattr(dev, "dmx_protocol", "artnet")
dmx_start_universe = getattr(dev, "dmx_start_universe", 0)
dmx_start_channel = getattr(dev, "dmx_start_channel", 1)
espnow_peer_mac = getattr(dev, "espnow_peer_mac", "")
espnow_channel = getattr(dev, "espnow_channel", 1)
hue_username = getattr(dev, "hue_username", "")
hue_client_key = getattr(dev, "hue_client_key", "")
hue_entertainment_group_id = getattr(dev, "hue_entertainment_group_id", "")
spi_speed_hz = getattr(dev, "spi_speed_hz", 800000)
spi_led_type = getattr(dev, "spi_led_type", "WS2812B")
chroma_device_type = getattr(dev, "chroma_device_type", "chromalink")
gamesense_device_type = getattr(dev, "gamesense_device_type", "keyboard")
for key, default in self._DEVICE_FIELD_DEFAULTS.items():
extras[key] = getattr(dev, key, default)
except ValueError:
pass
return DeviceInfo(
device_id=ds.device_id,
device_url=ds.device_url,
led_count=ds.led_count,
device_type=ds.device_type,
baud_rate=ds.baud_rate,
software_brightness=ds.software_brightness,
test_mode_active=ds.test_mode_active,
send_latency_ms=send_latency_ms,
rgbw=rgbw,
zone_mode=ds.zone_mode,
auto_shutdown=ds.auto_shutdown,
dmx_protocol=dmx_protocol,
dmx_start_universe=dmx_start_universe,
dmx_start_channel=dmx_start_channel,
espnow_peer_mac=espnow_peer_mac,
espnow_channel=espnow_channel,
hue_username=hue_username,
hue_client_key=hue_client_key,
hue_entertainment_group_id=hue_entertainment_group_id,
spi_speed_hz=spi_speed_hz,
spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type,
gamesense_device_type=gamesense_device_type,
device_id=ds.device_id, device_url=ds.device_url,
led_count=ds.led_count, device_type=ds.device_type,
baud_rate=ds.baud_rate, software_brightness=ds.software_brightness,
test_mode_active=ds.test_mode_active, zone_mode=ds.zone_mode,
auto_shutdown=ds.auto_shutdown, **extras,
)
# ===== EVENT SYSTEM (state change notifications) =====
@@ -260,7 +240,7 @@ class ProcessorManager:
self._http_client = httpx.AsyncClient(timeout=5)
return self._http_client
# ===== DEVICE MANAGEMENT (health monitoring) =====
# ===== DEVICE MANAGEMENT =====
def add_device(
self,
@@ -475,7 +455,7 @@ class ProcessorManager:
async def update_target_device(self, target_id: str, device_id: str):
"""Update the device for a target.
If the target is currently running, performs a stop swap start
If the target is currently running, performs a stop -> swap -> start
cycle so the new device connection is established properly.
"""
proc = self._get_processor(target_id)
@@ -495,7 +475,7 @@ class ProcessorManager:
if was_running:
await self.start_processing(target_id)
logger.info(
"Hot-switch complete for target %s device %s",
"Hot-switch complete for target %s -> device %s",
target_id, device_id,
)
@@ -742,272 +722,6 @@ class ProcessorManager:
if proc:
proc.remove_led_preview_client(ws)
# ===== CALIBRATION TEST MODE (on device, driven by CSS calibration) =====
async def set_test_mode(
self,
device_id: str,
edges: Dict[str, List[int]],
calibration: Optional[CalibrationConfig] = None,
) -> None:
"""Set or clear calibration test mode for a device.
When setting test mode, pass the calibration from the CSS being tested.
When clearing (edges={}), calibration is not needed.
"""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
ds = self._devices[device_id]
if edges:
ds.test_mode_active = True
ds.test_mode_edges = {
edge: tuple(color) for edge, color in edges.items()
}
if calibration is not None:
ds.test_calibration = calibration
await self._send_test_pixels(device_id)
else:
ds.test_mode_active = False
ds.test_mode_edges = {}
ds.test_calibration = None
await self._send_clear_pixels(device_id)
async def _get_idle_client(self, device_id: str):
"""Get or create a cached idle LED client for a device.
Reuses an existing connected client to avoid repeated serial
reconnection (which triggers Arduino bootloader reset on Adalight).
"""
# Prefer a running processor's client (already connected)
active = self._find_active_led_client(device_id)
if active:
return active
# Reuse cached idle client if still connected
cached = self._idle_clients.get(device_id)
if cached and cached.is_connected:
return cached
# Create and cache a new client
ds = self._devices[device_id]
client = create_led_client(
ds.device_type, ds.device_url,
use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate,
)
await client.connect()
self._idle_clients[device_id] = client
return client
async def _close_idle_client(self, device_id: str) -> None:
"""Close and remove the cached idle client for a device."""
client = self._idle_clients.pop(device_id, None)
if client:
try:
await client.close()
except Exception as e:
logger.warning(f"Error closing idle client for {device_id}: {e}")
async def _send_test_pixels(self, device_id: str) -> None:
"""Build and send test pixel array for active test edges."""
ds = self._devices[device_id]
# Require calibration to know which LEDs map to which edges
if ds.test_calibration is None:
logger.debug(f"No calibration for test mode on {device_id}, skipping LED test")
return
pixels = [(0, 0, 0)] * ds.led_count
for edge_name, color in ds.test_mode_edges.items():
for seg in ds.test_calibration.segments:
if seg.edge == edge_name:
for i in range(seg.led_start, seg.led_start + seg.led_count):
if i < ds.led_count:
pixels[i] = color
break
# Apply offset rotation (same as Phase 2 in PixelMapper.map_border_to_leds)
total_leds = ds.test_calibration.get_total_leds()
offset = ds.test_calibration.offset % total_leds if total_leds > 0 else 0
if offset > 0:
pixels = pixels[-offset:] + pixels[:-offset]
await self._send_pixels_to_device(device_id, pixels)
async def _send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to clear LED output."""
ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count
await self._send_pixels_to_device(device_id, pixels)
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
"""Send pixels to a device via cached idle client.
Reuses a cached connection to avoid repeated serial reconnections
(which trigger Arduino bootloader reset on Adalight devices).
"""
try:
client = await self._get_idle_client(device_id)
await client.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to send pixels to {device_id}: {e}")
def _find_active_led_client(self, device_id: str):
"""Find an active LED client for a device (from a running processor)."""
for proc in self._processors.values():
if proc.device_id == device_id and proc.is_running and proc.led_client:
return proc.led_client
return None
# ===== DISPLAY LOCK INFO =====
def is_display_locked(self, display_index: int) -> bool:
"""Check if a display is currently being captured by any target."""
for proc in self._processors.values():
if proc.is_running and proc.get_display_index() == display_index:
return True
return False
def get_display_lock_info(self, display_index: int) -> Optional[str]:
"""Get the device ID that is currently capturing from a display."""
for proc in self._processors.values():
if proc.is_running and proc.get_display_index() == display_index:
return proc.device_id
return None
async def clear_device(self, device_id: str) -> None:
"""Clear LED output on a device (send black / power off)."""
ds = self._devices.get(device_id)
if not ds:
raise ValueError(f"Device {device_id} not found")
try:
await self._send_clear_pixels(device_id)
except Exception as e:
logger.error(f"Failed to clear device {device_id}: {e}")
async def _restore_device_idle_state(self, device_id: str) -> None:
"""Restore a device to its idle state when all targets stop.
- For WLED: do nothing — stop() already restored the snapshot.
- For serial: do nothing — AdalightClient.close() already sent black frame.
"""
ds = self._devices.get(device_id)
if not ds or not ds.auto_shutdown:
return
if self.is_device_processing(device_id):
return
if ds.device_type == "wled":
logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot")
else:
logger.info(f"Auto-restore: {ds.device_type} device {device_id} dark (closed by processor)")
# ===== AUTO-RESTART =====
def _on_task_done(self, target_id: str, task: asyncio.Task) -> None:
"""Task done callback — detects crashes and schedules auto-restart."""
# Ignore graceful cancellation (manual stop)
if task.cancelled():
return
exc = task.exception()
if exc is None:
return # Clean exit (shouldn't happen, but harmless)
rs = self._restart_states.get(target_id)
if not rs or not rs.enabled:
return # Auto-restart disabled (manual stop was called)
now = time.monotonic()
# Reset counter if previous crash window expired
if rs.first_crash_time and (now - rs.first_crash_time) > _RESTART_WINDOW_SEC:
rs.attempts = 0
rs.first_crash_time = 0.0
rs.attempts += 1
rs.last_crash_time = now
if not rs.first_crash_time:
rs.first_crash_time = now
if rs.attempts > _RESTART_MAX_ATTEMPTS:
logger.error(
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
f"in {now - rs.first_crash_time:.0f}s — giving up"
)
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_exhausted": True,
})
return
backoff = min(
_RESTART_BACKOFF_BASE * (2 ** (rs.attempts - 1)),
_RESTART_BACKOFF_MAX,
)
logger.warning(
f"[AUTO-RESTART] Target {target_id} crashed (attempt {rs.attempts}/"
f"{_RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
)
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_in": backoff,
"auto_restart_attempt": rs.attempts,
})
# Schedule the restart (runs in the event loop)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
logger.error(f"[AUTO-RESTART] No running event loop for {target_id}")
return
rs.restart_task = loop.create_task(self._auto_restart(target_id, backoff))
async def _auto_restart(self, target_id: str, delay: float) -> None:
"""Wait for backoff delay, then restart the target processor."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
logger.info(f"[AUTO-RESTART] Restart cancelled for {target_id}")
return
rs = self._restart_states.get(target_id)
if not rs or not rs.enabled:
logger.info(f"[AUTO-RESTART] Restart aborted for {target_id} (disabled)")
return
proc = self._processors.get(target_id)
if proc is None:
logger.warning(f"[AUTO-RESTART] Target {target_id} no longer registered")
return
if proc.is_running:
logger.info(f"[AUTO-RESTART] Target {target_id} already running, skipping")
return
logger.info(f"[AUTO-RESTART] Restarting target {target_id} (attempt {rs.attempts})")
try:
await self.start_processing(target_id)
except Exception as e:
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_error": str(e),
})
# ===== LIFECYCLE =====
async def stop_all(self):
@@ -1055,120 +769,6 @@ class ProcessorManager:
logger.info("Stopped all processors")
# ===== HEALTH MONITORING =====
async def start_health_monitoring(self):
"""Start background health checks for all registered devices."""
self._health_monitoring_active = True
for device_id in self._devices:
self._start_device_health_check(device_id)
await self._metrics_history.start()
logger.info("Started health monitoring for all devices")
async def stop_health_monitoring(self):
"""Stop all background health checks."""
self._health_monitoring_active = False
for device_id in list(self._devices.keys()):
self._stop_device_health_check(device_id)
logger.info("Stopped health monitoring for all devices")
def _start_device_health_check(self, device_id: str):
state = self._devices.get(device_id)
if not state:
return
# Skip periodic health checks for virtual devices (always online)
if "health_check" not in get_device_capabilities(state.device_type):
from datetime import datetime, timezone
state.health = DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
return
if state.health_task and not state.health_task.done():
return
state.health_task = asyncio.create_task(self._health_check_loop(device_id))
def _stop_device_health_check(self, device_id: str):
state = self._devices.get(device_id)
if not state or not state.health_task:
return
state.health_task.cancel()
state.health_task = None
def _device_is_processing(self, device_id: str) -> bool:
"""Check if any target is actively streaming to this device."""
return any(
p.device_id == device_id and p.is_running
for p in self._processors.values()
)
def _is_device_streaming(self, device_id: str) -> bool:
"""Check if any running processor targets this device."""
for proc in self._processors.values():
if getattr(proc, 'device_id', None) == device_id and proc.is_running:
return True
return False
async def _health_check_loop(self, device_id: str):
"""Background loop that periodically checks a device.
Uses adaptive intervals: 10s for actively streaming devices,
60s for idle devices, to balance responsiveness with overhead.
"""
state = self._devices.get(device_id)
if not state:
return
ACTIVE_INTERVAL = 10 # streaming devices — faster detection
IDLE_INTERVAL = 60 # idle devices — less overhead
try:
while self._health_monitoring_active:
await self._check_device_health(device_id)
interval = ACTIVE_INTERVAL if self._is_device_streaming(device_id) else IDLE_INTERVAL
await asyncio.sleep(interval)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
async def _check_device_health(self, device_id: str):
"""Check device health. Also auto-syncs LED count if changed."""
state = self._devices.get(device_id)
if not state:
return
prev_online = state.health.online
client = await self._get_http_client()
state.health = await check_device_health(
state.device_type, state.device_url, client, state.health,
)
# Fire event when online status changes
if state.health.online != prev_online:
self.fire_event({
"type": "device_health_changed",
"device_id": device_id,
"online": state.health.online,
"latency_ms": state.health.latency_ms,
})
# Auto-sync LED count
reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store:
old_count = state.led_count
logger.info(
f"Device {device_id} LED count changed: {old_count}{reported}"
)
try:
self._device_store.update_device(device_id, led_count=reported)
state.led_count = reported
except Exception as e:
logger.error(f"Failed to sync LED count for {device_id}: {e}")
async def force_device_health_check(self, device_id: str) -> dict:
"""Run an immediate health check for a device and return the result."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
await self._check_device_health(device_id)
return self.get_device_health_dict(device_id)
# ===== HELPERS =====
def has_device(self, device_id: str) -> bool:
@@ -1179,10 +779,6 @@ class ProcessorManager:
"""Get device state, returning None if not registered."""
return self._devices.get(device_id)
async def send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to a device (public wrapper)."""
await self._send_clear_pixels(device_id)
def get_processor(self, target_id: str) -> Optional[TargetProcessor]:
"""Look up a processor by target_id, returning None if not found."""
return self._processors.get(target_id)

View File

@@ -16,7 +16,7 @@ from wled_controller import __version__
from wled_controller.api import router
from wled_controller.api.dependencies import init_dependencies
from wled_controller.config import get_config
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.processor_manager import ProcessorDependencies, ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
@@ -72,17 +72,19 @@ cspt_store = ColorStripProcessingTemplateStore(config.storage.color_strip_proces
sync_clock_manager = SyncClockManager(sync_clock_store)
processor_manager = ProcessorManager(
picture_source_store=picture_source_store,
capture_template_store=template_store,
pp_template_store=pp_template_store,
pattern_template_store=pattern_template_store,
device_store=device_store,
color_strip_store=color_strip_store,
audio_source_store=audio_source_store,
value_source_store=value_source_store,
audio_template_store=audio_template_store,
sync_clock_manager=sync_clock_manager,
cspt_store=cspt_store,
ProcessorDependencies(
picture_source_store=picture_source_store,
capture_template_store=template_store,
pp_template_store=pp_template_store,
pattern_template_store=pattern_template_store,
device_store=device_store,
color_strip_store=color_strip_store,
audio_source_store=audio_source_store,
value_source_store=value_source_store,
audio_template_store=audio_template_store,
sync_clock_manager=sync_clock_manager,
cspt_store=cspt_store,
)
)
@@ -128,10 +130,12 @@ async def lifespan(app: FastAPI):
device_store=device_store,
)
# Create auto-backup engine
# Create auto-backup engine — derive paths from storage config so that
# demo mode auto-backups go to data/demo/ instead of data/.
_data_dir = Path(config.storage.devices_file).parent
auto_backup_engine = AutoBackupEngine(
settings_path=Path("data/auto_backup_settings.json"),
backup_dir=Path("data/backups"),
settings_path=_data_dir / "auto_backup_settings.json",
backup_dir=_data_dir / "backups",
store_map=STORE_MAP,
storage_config=config.storage,
)
@@ -314,14 +318,17 @@ templates = Jinja2Templates(directory=str(templates_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)
import uuid
ref_id = uuid.uuid4().hex[:8]
logger.error("Unhandled exception [ref=%s]: %s", ref_id, 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,
"message": "Internal server error",
"ref": ref_id,
},
)

View File

@@ -532,7 +532,6 @@ input:-webkit-autofill:focus {
}
.tag-input-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
@@ -545,6 +544,16 @@ input:-webkit-autofill:focus {
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: opacity var(--duration-fast) ease-out,
transform var(--duration-fast) var(--ease-out);
}
.tag-input-dropdown.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tag-dropdown-item {
@@ -668,11 +677,14 @@ textarea:focus-visible {
z-index: var(--z-lightbox);
overflow: hidden;
opacity: 0;
transition: opacity 0.15s ease;
transform: translateY(-6px) scale(0.97);
transition: opacity var(--duration-fast) ease-out,
transform var(--duration-normal) var(--ease-out);
pointer-events: none;
}
.icon-select-popup.open {
opacity: 1;
transform: translateY(0) scale(1);
overflow-y: auto;
pointer-events: auto;
}
@@ -816,17 +828,26 @@ textarea:focus-visible {
/* ── Entity Palette (command-palette style selector) ─────── */
.entity-palette-overlay {
display: none;
display: flex;
position: fixed;
inset: 0;
z-index: var(--z-lightbox);
background: rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0);
justify-content: center;
align-items: flex-start;
padding-top: min(20vh, 120px);
pointer-events: none;
opacity: 0;
backdrop-filter: blur(0px);
transition: background var(--duration-fast) ease-out,
opacity var(--duration-fast) ease-out,
backdrop-filter var(--duration-fast) ease-out;
}
.entity-palette-overlay.open {
display: flex;
opacity: 1;
pointer-events: auto;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.entity-palette {
width: min(500px, 90vw);
@@ -838,6 +859,14 @@ textarea:focus-visible {
display: flex;
flex-direction: column;
overflow: hidden;
opacity: 0;
transform: translateY(-12px) scale(0.98);
transition: opacity var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out);
}
.entity-palette-overlay.open .entity-palette {
opacity: 1;
transform: translateY(0) scale(1);
}
.entity-palette-search-row {
display: flex;

View File

@@ -5,6 +5,7 @@
// Layer 0: state
import { apiKey, setApiKey, refreshInterval } from './core/state.ts';
import { Modal } from './core/modal.ts';
import { queryEl } from './core/dom-utils.ts';
// Layer 1: api, i18n
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.ts';
@@ -93,8 +94,8 @@ import {
} from './features/automations.ts';
import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, recaptureScenePreset, cloneScenePreset, deleteScenePreset,
addSceneTarget, removeSceneTarget,
activateScenePreset, cloneScenePreset, deleteScenePreset,
addSceneTarget,
} from './features/scene-presets.ts';
// Layer 5: device-discovery, targets
@@ -380,17 +381,15 @@ Object.assign(window, {
deleteAutomation,
copyWebhookUrl,
// scene presets
// scene presets (modal buttons stay on window; card actions migrated to event delegation)
openScenePresetCapture,
editScenePreset,
saveScenePreset,
closeScenePresetEditor,
activateScenePreset,
recaptureScenePreset,
cloneScenePreset,
deleteScenePreset,
addSceneTarget,
removeSceneTarget,
// device-discovery
onDeviceTypeChanged,
@@ -577,13 +576,13 @@ document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Close in order: log overlay > overlay lightboxes > modals via stack
const logOverlay = document.getElementById('log-overlay');
const logOverlay = queryEl('log-overlay');
if (logOverlay && logOverlay.style.display !== 'none') {
closeLogOverlay();
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
closeDisplayPicker(null as any);
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox(null as any);
} else if (queryEl('display-picker-lightbox')?.classList.contains('active')) {
closeDisplayPicker();
} else if (queryEl('image-lightbox')?.classList.contains('active')) {
closeLightbox();
} else {
Modal.closeTopmost();
}
@@ -656,7 +655,8 @@ document.addEventListener('DOMContentLoaded', async () => {
initCommandPalette();
// Setup form handler
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
const addDeviceForm = queryEl('add-device-form');
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
// Always monitor server connection (even before login)
loadServerInfo();

View File

@@ -5,6 +5,7 @@
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
import { t } from './i18n.ts';
import { showToast } from './ui.ts';
import { getEl, queryEl } from './dom-utils.ts';
export const API_BASE = '/api/v1';
@@ -77,7 +78,7 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
throw err;
}
}
return undefined as unknown as Response;
throw new Error('fetchWithAuth: unreachable code — retry loop exhausted');
}
export function escapeHtml(text: string) {
@@ -188,8 +189,10 @@ export async function loadServerInfo() {
const response = await fetch('/health', { signal: AbortSignal.timeout(5000) });
const data = await response.json();
document.getElementById('version-number')!.textContent = `v${data.version}`;
document.getElementById('server-status')!.textContent = '●';
const versionEl = queryEl('version-number');
if (versionEl) versionEl.textContent = `v${data.version}`;
const statusEl = queryEl('server-status');
if (statusEl) statusEl.textContent = '●';
const wasOffline = _serverOnline === false;
_setConnectionState(true);
if (wasOffline) {
@@ -263,11 +266,13 @@ export function configureApiKey() {
if (key === '') {
localStorage.removeItem('wled_api_key');
setApiKey(null);
document.getElementById('api-key-btn')!.style.display = 'none';
const keyBtnHide = queryEl('api-key-btn');
if (keyBtnHide) keyBtnHide.style.display = 'none';
} else {
localStorage.setItem('wled_api_key', key);
setApiKey(key);
document.getElementById('api-key-btn')!.style.display = 'inline-block';
const keyBtnShow = queryEl('api-key-btn');
if (keyBtnShow) keyBtnShow.style.display = 'inline-block';
}
loadServerInfo();

View File

@@ -106,8 +106,8 @@ void main() {
}
`;
let _canvas, _gl, _prog;
let _uTime, _uRes, _uAccent, _uBg, _uLight, _uParticlesBase;
let _canvas: HTMLCanvasElement = undefined as any, _gl: WebGLRenderingContext | null = null, _prog: WebGLProgram | null = null;
let _uTime: WebGLUniformLocation | null, _uRes: WebGLUniformLocation | null, _uAccent: WebGLUniformLocation | null, _uBg: WebGLUniformLocation | null, _uLight: WebGLUniformLocation | null, _uParticlesBase: WebGLUniformLocation | null;
let _particleBuf: Float32Array | null = null; // pre-allocated Float32Array for uniform3fv
let _raf: number | null = null;
let _startTime = 0;
@@ -116,7 +116,8 @@ let _bgColor = [26 / 255, 26 / 255, 26 / 255];
let _isLight = 0.0;
// Particle state (CPU-side, positions in 0..1 UV space)
const _particles = [];
interface Particle { x: number; y: number; vx: number; vy: number; r: number; }
const _particles: Particle[] = [];
function _initParticles(): void {
_particles.length = 0;
@@ -144,6 +145,7 @@ function _updateParticles(): void {
function _compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
const s = gl.createShader(type);
if (!s) return null;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
@@ -162,9 +164,9 @@ function _initGL(): boolean {
const fs = _compile(gl, gl.FRAGMENT_SHADER, FRAG_SRC);
if (!vs || !fs) return false;
_prog = gl.createProgram();
gl.attachShader(_prog, vs);
gl.attachShader(_prog, fs);
_prog = gl.createProgram()!;
gl.attachShader(_prog!, vs);
gl.attachShader(_prog!, fs);
gl.linkProgram(_prog);
if (!gl.getProgramParameter(_prog, gl.LINK_STATUS)) {
console.error('Program link:', gl.getProgramInfoLog(_prog));
@@ -213,14 +215,16 @@ function _draw(time: number): void {
gl.uniform3f(_uBg, _bgColor[0], _bgColor[1], _bgColor[2]);
gl.uniform1f(_uLight, _isLight);
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = _particles[i];
const off = i * 3;
_particleBuf[off] = p.x;
_particleBuf[off + 1] = p.y;
_particleBuf[off + 2] = p.r;
if (_particleBuf) {
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = _particles[i];
const off = i * 3;
_particleBuf[off] = p.x;
_particleBuf[off + 1] = p.y;
_particleBuf[off + 2] = p.r;
}
gl.uniform3fv(_uParticlesBase, _particleBuf);
}
gl.uniform3fv(_uParticlesBase, _particleBuf);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
@@ -259,8 +263,9 @@ export function updateBgAnimTheme(isDark: boolean): void {
}
export function initBgAnim(): void {
_canvas = document.getElementById('bg-anim-canvas');
if (!_canvas) return;
const canvasEl = document.getElementById('bg-anim-canvas') as HTMLCanvasElement | null;
if (!canvasEl) return;
_canvas = canvasEl;
const observer = new MutationObserver(() => {
const on = document.documentElement.getAttribute('data-bg-anim') === 'on';

View File

@@ -72,7 +72,7 @@ function _render() {
`;
// Select All checkbox
el.querySelector('.bulk-select-all-cb').addEventListener('change', (e) => {
el.querySelector('.bulk-select-all-cb')!.addEventListener('change', (e) => {
if ((e.target as HTMLInputElement).checked) section.selectAll();
else section.deselectAll();
});
@@ -83,7 +83,7 @@ function _render() {
});
// Close button
el.querySelector('.bulk-close').addEventListener('click', () => {
el.querySelector('.bulk-close')!.addEventListener('click', () => {
section.exitSelectionMode();
});
@@ -94,7 +94,7 @@ async function _executeAction(actionKey) {
const section = _activeSection;
if (!section) return;
const action = section.bulkActions.find(a => a.key === actionKey);
const action = section.bulkActions!.find(a => a.key === actionKey);
if (!action) return;
const keys = [...section._selected];

View File

@@ -25,7 +25,7 @@ const STORAGE_KEY = 'cardColors';
const DEFAULT_SWATCH = '#808080';
function _getAll(): Record<string, string> {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') || {}; }
catch { return {}; }
}
@@ -66,7 +66,7 @@ export function cardColorButton(entityId: string, cardAttr: string): string {
if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : '';
});
return createColorPicker({ id: pickerId, currentColor: color, onPick: null, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
return createColorPicker({ id: pickerId, currentColor: color, onPick: undefined, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
}
/**

View File

@@ -22,8 +22,8 @@ function _onMove(e) {
_cachedRect = card.getBoundingClientRect();
}
const x = e.clientX - _cachedRect.left;
const y = e.clientY - _cachedRect.top;
const x = e.clientX - _cachedRect!.left;
const y = e.clientY - _cachedRect!.top;
card.style.setProperty('--glare-x', `${x}px`);
card.style.setProperty('--glare-y', `${y}px`);
} else if (_active) {

View File

@@ -343,12 +343,12 @@ export class CardSection {
if (this.keyAttr) {
const existing = [...content.querySelectorAll(`[${this.keyAttr}]`)];
for (const card of existing) {
const key = card.getAttribute(this.keyAttr);
const key = card.getAttribute(this.keyAttr) ?? '';
if (!newMap.has(key)) {
card.remove();
removed.add(key!);
removed.add(key);
} else {
const newHtml = newMap.get(key);
const newHtml = newMap.get(key)!;
if ((card as any)._csHtml !== newHtml) {
const tmp = document.createElement('div');
tmp.innerHTML = newHtml;
@@ -620,7 +620,7 @@ export class CardSection {
const htmlMap = new Map(this._lastItems.map(i => [i.key, i.html]));
const cards = content.querySelectorAll(`[${this.keyAttr}]`);
cards.forEach(card => {
const key = card.getAttribute(this.keyAttr);
const key = card.getAttribute(this.keyAttr) ?? '';
if (htmlMap.has(key)) (card as any)._csHtml = htmlMap.get(key);
});
}

View File

@@ -19,6 +19,8 @@ let _items: any[] = [];
let _filtered: any[] = [];
let _selectedIdx = 0;
let _loading = false;
let _inputDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const _INPUT_DEBOUNCE_MS = 150;
// ─── Entity definitions: endpoint → palette items ───
@@ -35,7 +37,7 @@ function _mapEntities(data: any, mapFn: (item: any) => any) {
function _buildItems(results: any[], states: any = {}) {
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results;
const items = [];
const items: any[] = [];
_mapEntities(devices, d => items.push({
name: d.name, detail: d.device_type, group: 'devices', icon: ICON_DEVICE,
@@ -199,7 +201,7 @@ async function _fetchAllEntities() {
fetchWithAuth(ep as string, { retry: false, timeout: 5000 })
.then((r: any) => r.ok ? r.json() : {})
.then((data: any) => data[key as string] || [])
.catch(() => [])),
.catch((): any[] => [])),
]);
return _buildItems(results, statesData);
}
@@ -316,6 +318,10 @@ export async function openCommandPalette() {
export function closeCommandPalette() {
if (!_isOpen) return;
_isOpen = false;
if (_inputDebounceTimer !== null) {
clearTimeout(_inputDebounceTimer);
_inputDebounceTimer = null;
}
const overlay = document.getElementById('command-palette')!;
overlay.style.display = 'none';
document.documentElement.classList.remove('modal-open');
@@ -326,10 +332,14 @@ export function closeCommandPalette() {
// ─── Event handlers ───
function _onInput() {
const input = document.getElementById('cp-input') as HTMLInputElement;
_filtered = _filterItems(input.value.trim());
_selectedIdx = 0;
_render();
if (_inputDebounceTimer !== null) clearTimeout(_inputDebounceTimer);
_inputDebounceTimer = setTimeout(() => {
_inputDebounceTimer = null;
const input = document.getElementById('cp-input') as HTMLInputElement;
_filtered = _filterItems(input.value.trim());
_selectedIdx = 0;
_render();
}, _INPUT_DEBOUNCE_MS);
}
function _onKeydown(e: KeyboardEvent) {
@@ -355,7 +365,7 @@ function _onKeydown(e: KeyboardEvent) {
function _onClick(e: Event) {
const row = (e.target as HTMLElement).closest('.cp-result') as HTMLElement | null;
if (row) {
_selectedIdx = parseInt(row.dataset.cpIdx, 10);
_selectedIdx = parseInt(row.dataset.cpIdx ?? '0', 10);
_selectCurrent();
return;
}

View File

@@ -0,0 +1,23 @@
/**
* DOM utility functions — safe element access, type-checked selectors.
*/
/**
* Get an element by ID with a meaningful error if not found.
* Use this instead of `document.getElementById('x')!` to get
* a clear error message pointing to the missing element.
*/
export function getEl(id: string): HTMLElement {
const el = document.getElementById(id);
if (!el) throw new Error(`Element #${id} not found in DOM`);
return el;
}
/**
* Get an element by ID, returning null if not found.
* Typed alternative to `document.getElementById` that avoids
* the `HTMLElement | null` ambiguity in non-null assertion contexts.
*/
export function queryEl(id: string): HTMLElement | null {
return document.getElementById(id);
}

View File

@@ -63,7 +63,14 @@ function _invalidateAndReload(entityType) {
if (oldData === newData) return;
if (Array.isArray(oldData) && Array.isArray(newData) &&
oldData.length === newData.length &&
JSON.stringify(oldData) === JSON.stringify(newData)) return;
oldData.every((item, i) => {
const other = newData[i];
return item === other || (
item && other &&
item.id === other.id &&
item.updated_at === other.updated_at
);
})) return;
const loader = ENTITY_LOADER_MAP[entityType];
if (loader) {

View File

@@ -102,8 +102,8 @@ export class EntityPalette {
this._resolve = resolve;
this._items = items || [];
this._currentValue = current;
this._allowNone = allowNone;
this._noneLabel = noneLabel;
this._allowNone = allowNone ?? false;
this._noneLabel = noneLabel ?? '';
this._input.placeholder = placeholder || '';
this._input.value = '';
@@ -219,7 +219,7 @@ export class EntitySelect {
this._select = target;
this._getItems = getItems;
this._placeholder = placeholder || '';
this._onChange = onChange;
this._onChange = onChange ?? null;
this._allowNone = allowNone || false;
this._noneLabel = noneLabel || '—';
this._items = getItems();

View File

@@ -22,7 +22,7 @@ export function startEventsWS() {
if (!apiKey) return;
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${apiKey}`;
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${encodeURIComponent(apiKey)}`;
try {
_ws = new WebSocket(url);

View File

@@ -81,7 +81,7 @@ export class FilterListManager {
const select = document.getElementById(this._selectId) as HTMLSelectElement;
const filterDefs = this._getFilterDefs();
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
const items = [];
const items: { value: string; icon: string; label: string; desc: string }[] = [];
for (const f of filterDefs) {
const name = this._getFilterName(f.filter_id);
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;

View File

@@ -124,7 +124,7 @@ export async function updateConnection(targetId: string, targetKind: string, fie
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
if (!entry) return false;
const url = entry.endpoint.replace('{id}', targetId);
const url = entry.endpoint!.replace('{id}', targetId);
const body = { [field]: newSourceId };
try {

View File

@@ -77,7 +77,7 @@ function _renderEdge(edge: GraphEdge): SVGElement {
const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`;
// Always use port-aware bezier — ELK routes without port knowledge so
// its bend points don't align with actual port positions.
const d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY);
const d = _defaultBezier(fromNode!, toNode!, edge.fromPortY, edge.toPortY);
const path = svgEl('path', {
class: cssClass,
@@ -201,8 +201,8 @@ export function highlightChain(edgeGroup: SVGGElement, nodeId: string, edges: Gr
const chain = new Set([...upstream, ...downstream]);
edgeGroup.querySelectorAll('.graph-edge').forEach(path => {
const from = path.getAttribute('data-from');
const to = path.getAttribute('data-to');
const from = path.getAttribute('data-from') ?? '';
const to = path.getAttribute('data-to') ?? '';
const inChain = chain.has(from) && chain.has(to);
path.classList.toggle('highlighted', inChain);
path.classList.toggle('dimmed', !inChain);
@@ -210,8 +210,8 @@ export function highlightChain(edgeGroup: SVGGElement, nodeId: string, edges: Gr
// Dim flow-dot groups on non-chain edges
edgeGroup.querySelectorAll('.graph-edge-flow').forEach(g => {
const from = g.getAttribute('data-from');
const to = g.getAttribute('data-to');
const from = g.getAttribute('data-from') ?? '';
const to = g.getAttribute('data-to') ?? '';
(g as SVGElement).style.opacity = (chain.has(from) && chain.has(to)) ? '' : '0.12';
});
@@ -257,7 +257,7 @@ export function updateEdgesForNode(group: SVGGElement, nodeId: string, nodeMap:
const toNode = nodeMap.get(edge.to);
if (!fromNode || !toNode) continue;
const d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY);
const d = _defaultBezier(fromNode!, toNode!, edge.fromPortY, edge.toPortY);
group.querySelectorAll(`.graph-edge[data-from="${edge.from}"][data-to="${edge.to}"]`).forEach(pathEl => {
if ((pathEl.getAttribute('data-field') || '') === (edge.field || '')) {
pathEl.setAttribute('d', d);

View File

@@ -101,7 +101,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
const nodeMap = new Map();
const nodeById = new Map(nodeList.map(n => [n.id, n]));
for (const child of layout.children) {
for (const child of layout.children!) {
const src = nodeById.get(child.id);
if (src) {
nodeMap.set(child.id, {
@@ -115,7 +115,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
}
// Build edge paths from layout
const edges = [];
const edges: any[] = [];
for (let i = 0; i < edgeList.length; i++) {
const layoutEdge = layout.edges?.[i];
const srcEdge = edgeList[i];
@@ -123,7 +123,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
const toNode = nodeMap.get(srcEdge.to);
if (!fromNode || !toNode) continue;
let points = null;
let points: any[] | null = null;
if ((layoutEdge as any)?.sections?.[0]) {
const sec = (layoutEdge as any).sections[0];
points = [sec.startPoint, ...(sec.bendPoints || []), sec.endPoint];
@@ -220,8 +220,8 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
function addEdge(from: string, to: string, field: string, label: string = ''): void {
if (!from || !to || !nodeIds.has(from) || !nodeIds.has(to)) return;
const type = edgeType(
nodes.find(n => n.id === from)?.kind,
nodes.find(n => n.id === to)?.kind,
nodes.find(n => n.id === from)?.kind ?? '',
nodes.find(n => n.id === to)?.kind ?? '',
field
);
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable

View File

@@ -176,7 +176,7 @@ function _openNodeColorPicker(node: GraphNode, e: MouseEvent): void {
cpOverlay.innerHTML = createColorPicker({
id: pickerId,
currentColor: curColor || ENTITY_COLORS[node.kind] || '#666',
onPick: null,
onPick: undefined,
anchor: 'left',
showReset: true,
resetColor: '#808080',
@@ -475,7 +475,7 @@ function _createOverlay(node: GraphNode, nodeWidth: number, callbacks: NodeCallb
bg.appendChild(iconG);
} else {
const txt = svgEl('text', { x: bx + btnSize / 2, y: by + btnSize / 2 });
txt.textContent = btn.icon;
txt.textContent = btn.icon ?? '';
bg.appendChild(txt);
}
const btnTip = svgEl('title');
@@ -543,7 +543,7 @@ export function patchNodeRunning(group: SVGGElement, node: GraphNode): void {
/**
* Highlight a single node (add class, scroll to).
*/
export function highlightNode(group: SVGGElement, nodeId: string, cls: string = 'search-match'): Element | null {
export function highlightNode(group: SVGGElement, nodeId: string | null, cls: string = 'search-match'): Element | null {
// Remove existing highlights
group.querySelectorAll(`.graph-node.${cls}`).forEach(n => n.classList.remove(cls));
const node = group.querySelector(`.graph-node[data-id="${nodeId}"]`);
@@ -572,6 +572,6 @@ export function markOrphans(group: SVGGElement, nodeMap: Map<string, GraphNode>,
*/
export function updateSelection(group: SVGGElement, selectedIds: Set<string>): void {
group.querySelectorAll('.graph-node').forEach(n => {
n.classList.toggle('selected', selectedIds.has(n.getAttribute('data-id')));
n.classList.toggle('selected', selectedIds.has(n.getAttribute('data-id') ?? ''));
});
}

View File

@@ -104,7 +104,7 @@ function updateLocaleSelect() {
export function updateAllText() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const key = el.getAttribute('data-i18n')!;
el.textContent = t(key);
});
@@ -119,7 +119,7 @@ export function updateAllText() {
});
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
const key = el.getAttribute('data-i18n-aria-label');
const key = el.getAttribute('data-i18n-aria-label')!;
el.setAttribute('aria-label', t(key));
});

View File

@@ -36,8 +36,8 @@ export function navigateToCard(tab: string, subTab: string | null, sectionKey: s
// Expand section if collapsed
if (sectionKey) {
const content = document.querySelector(`[data-cs-content="${sectionKey}"]`) as HTMLElement | null;
const header = document.querySelector(`[data-cs-toggle="${sectionKey}"]`);
const content = document.querySelector(`[data-cs-content="${CSS.escape(sectionKey)}"]`) as HTMLElement | null;
const header = document.querySelector(`[data-cs-toggle="${CSS.escape(sectionKey)}"]`);
if (content && content.style.display === 'none') {
content.style.display = '';
const chevron = header?.querySelector('.cs-chevron') as HTMLElement | null;
@@ -54,7 +54,7 @@ export function navigateToCard(tab: string, subTab: string | null, sectionKey: s
const scope = tabPanel || document;
// Check if card already exists (data previously loaded)
const existing = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
const existing = scope.querySelector(`[${cardAttr}="${CSS.escape(cardValue)}"]`);
if (existing) {
_highlightCard(existing);
return;
@@ -111,12 +111,12 @@ function _showDimOverlay(duration: number) {
function _waitForCard(cardAttr: string, cardValue: string, timeout: number, scope: Document | HTMLElement = document) {
const root = scope === document ? document.body : scope as HTMLElement;
return new Promise(resolve => {
const card = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
const card = scope.querySelector(`[${cardAttr}="${CSS.escape(cardValue)}"]`);
if (card) { resolve(card); return; }
const timer = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
const observer = new MutationObserver(() => {
const el = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
const el = scope.querySelector(`[${cardAttr}="${CSS.escape(cardValue)}"]`);
if (el) { observer.disconnect(); clearTimeout(timer); resolve(el); }
});
observer.observe(root, { childList: true, subtree: true });

View File

@@ -12,7 +12,7 @@ import type {
ValueSource, AudioSource, PictureSource, ScenePreset,
SyncClock, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
} from '../types.ts';
export let apiKey: string | null = null;
@@ -42,7 +42,7 @@ export let _displayPickerSelectedIndex: number | null = null;
export function set_displayPickerSelectedIndex(v: number | null) { _displayPickerSelectedIndex = v; }
// Calibration
export const calibrationTestState: Record<string, any> = {};
export const calibrationTestState: Record<string, Set<string>> = {};
export const EDGE_TEST_COLORS: Record<string, number[]> = {
top: [255, 0, 0],
@@ -65,8 +65,8 @@ export function updateDeviceBrightness(deviceId: string, value: number) {
export let _discoveryScanRunning = false;
export function set_discoveryScanRunning(v: boolean) { _discoveryScanRunning = v; }
export let _discoveryCache: Record<string, any> = {};
export function set_discoveryCache(v: Record<string, any>) { _discoveryCache = v; }
export let _discoveryCache: Record<string, any[]> = {};
export function set_discoveryCache(v: Record<string, any[]>) { _discoveryCache = v; }
// Streams / templates state
export let _cachedStreams: PictureSource[] = [];
@@ -90,15 +90,15 @@ export let _templateNameManuallyEdited = false;
export function set_templateNameManuallyEdited(v: boolean) { _templateNameManuallyEdited = v; }
// PP template state
export let _modalFilters: any[] = [];
export function set_modalFilters(v: any[]) { _modalFilters = v; }
export let _modalFilters: FilterInstance[] = [];
export function set_modalFilters(v: FilterInstance[]) { _modalFilters = v; }
export let _ppTemplateNameManuallyEdited = false;
export function set_ppTemplateNameManuallyEdited(v: boolean) { _ppTemplateNameManuallyEdited = v; }
// CSPT (Color Strip Processing Template) state
export let _csptModalFilters: any[] = [];
export function set_csptModalFilters(v: any[]) { _csptModalFilters = v; }
export let _csptModalFilters: FilterInstance[] = [];
export function set_csptModalFilters(v: FilterInstance[]) { _csptModalFilters = v; }
export let _csptNameManuallyEdited = false;
export function set_csptNameManuallyEdited(v: boolean) { _csptNameManuallyEdited = v; }
@@ -135,8 +135,17 @@ export const kcWebSockets: Record<string, WebSocket> = {};
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
// Tutorial state
export let activeTutorial: any = null;
export function setActiveTutorial(v: any) { activeTutorial = v; }
export interface TutorialState {
steps: { selector: string; textKey: string; position: string; global?: boolean }[];
overlay: HTMLElement;
mode: string;
step: number;
resolveTarget: (step: { selector: string; textKey: string; position: string; global?: boolean }) => Element | null;
container: Element | null;
onClose: (() => void) | null;
}
export let activeTutorial: TutorialState | null = null;
export function setActiveTutorial(v: TutorialState | null) { activeTutorial = v; }
// Confirm modal
export let confirmResolve: ((value: boolean) => void) | null = null;
@@ -162,8 +171,8 @@ export function setDashboardPollInterval(v: number) {
}
// Pattern template editor state
export let patternEditorRects: any[] = [];
export function setPatternEditorRects(v: any[]) { patternEditorRects = v; }
export let patternEditorRects: KeyColorRectangle[] = [];
export function setPatternEditorRects(v: KeyColorRectangle[]) { patternEditorRects = v; }
export let patternEditorSelectedIdx = -1;
export function setPatternEditorSelectedIdx(v: number) { patternEditorSelectedIdx = v; }
@@ -177,8 +186,8 @@ export function setPatternCanvasDragMode(v: string | null) { patternCanvasDragMo
export let patternCanvasDragStart: { x?: number; y?: number; mx?: number; my?: number } | null = null;
export function setPatternCanvasDragStart(v: { x?: number; y?: number; mx?: number; my?: number } | null) { patternCanvasDragStart = v; }
export let patternCanvasDragOrigRect: any = null;
export function setPatternCanvasDragOrigRect(v: any) { patternCanvasDragOrigRect = v; }
export let patternCanvasDragOrigRect: KeyColorRectangle | null = null;
export function setPatternCanvasDragOrigRect(v: KeyColorRectangle | null) { patternCanvasDragOrigRect = v; }
export let patternEditorHoveredIdx = -1;
export function setPatternEditorHoveredIdx(v: number) { patternEditorHoveredIdx = v; }

View File

@@ -72,8 +72,8 @@ export class TagInput {
* @param {object} [opts]
* @param {string} [opts.placeholder] Placeholder text for input
*/
constructor(container: HTMLElement, opts: any = {}) {
this._container = container;
constructor(container: HTMLElement | null, opts: any = {}) {
this._container = container!;
this._tags = [];
this._placeholder = opts.placeholder || 'Add tag...';
this._dropdownVisible = false;
@@ -212,13 +212,13 @@ export class TagInput {
this._dropdownEl.innerHTML = suggestions.map((tag, i) =>
`<div class="tag-dropdown-item${i === 0 ? ' tag-dropdown-active' : ''}" data-tag="${_escapeHtml(tag)}">${_escapeHtml(tag)}</div>`
).join('');
this._dropdownEl.style.display = 'block';
this._dropdownEl.classList.add('open');
this._dropdownVisible = true;
this._selectedIdx = 0;
}
_hideDropdown() {
this._dropdownEl.style.display = 'none';
this._dropdownEl.classList.remove('open');
this._dropdownVisible = false;
this._selectedIdx = -1;
}

View File

@@ -3,6 +3,7 @@
*/
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.ts';
import { API_BASE, getHeaders } from './api.ts';
import { t } from './i18n.ts';
/** Returns true on touch devices where auto-focus would pop up the virtual keyboard */
@@ -207,7 +208,6 @@ export function closeConfirmModal(result: boolean) {
export async function openFullImageLightbox(imageSource: string) {
try {
const { API_BASE, getHeaders } = await import('./api.js');
const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, {
headers: getHeaders()
});
@@ -323,7 +323,7 @@ export function showOverlaySpinner(text: string, duration = 0) {
progressCircle.style.strokeDashoffset = String(offset);
progressPercentage.textContent = `${percentage}%`;
if (progress >= 1) {
clearInterval(window.overlaySpinnerTimer);
clearInterval(window.overlaySpinnerTimer!);
window.overlaySpinnerTimer = null;
}
}, 100);

View File

@@ -280,7 +280,7 @@ export function selectCalibrationLine(idx: number): void {
const prev = _state.selectedLine;
_state.selectedLine = idx;
// Update selection in-place without rebuilding the list DOM
const container = document.getElementById('advcal-line-list');
const container = document.getElementById('advcal-line-list')!;
const items = container.querySelectorAll('.advcal-line-item');
if (prev >= 0 && prev < items.length) items[prev].classList.remove('selected');
if (idx >= 0 && idx < items.length) items[idx].classList.add('selected');
@@ -361,14 +361,14 @@ function _buildMonitorLayout(psList: any[], cssId: string | null): void {
// Load saved positions from localStorage
const savedKey = `advcal_positions_${cssId}`;
let saved = {};
try { saved = JSON.parse(localStorage.getItem(savedKey)) || {}; } catch { /* ignore */ }
try { saved = JSON.parse(localStorage.getItem(savedKey) ?? '{}') || {}; } catch { /* ignore */ }
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement;
const canvasW = canvas.width;
const canvasH = canvas.height;
// Default layout: arrange monitors in a row
const monitors = [];
const monitors: any[] = [];
const padding = 20;
const maxMonW = (canvasW - padding * 2) / Math.max(psList.length, 1) - 10;
const monH = canvasH * 0.6;
@@ -423,7 +423,7 @@ function _placeNewMonitor(): void {
function _updateTotalLeds(): void {
const used = _state.lines.reduce((s, l) => s + l.led_count, 0);
const el = document.getElementById('advcal-total-leds');
const el = document.getElementById('advcal-total-leds')!;
if (_state.totalLedCount > 0) {
el.textContent = `${used}/${_state.totalLedCount}`;
el.style.color = used > _state.totalLedCount ? 'var(--danger-color, #ff5555)' : '';
@@ -436,7 +436,7 @@ function _updateTotalLeds(): void {
/* ── Line list rendering ────────────────────────────────────── */
function _renderLineList(): void {
const container = document.getElementById('advcal-line-list');
const container = document.getElementById('advcal-line-list')!;
container.innerHTML = '';
_state.lines.forEach((line, i) => {
@@ -470,7 +470,7 @@ function _renderLineList(): void {
}
function _showLineProps(): void {
const propsEl = document.getElementById('advcal-line-props');
const propsEl = document.getElementById('advcal-line-props')!;
const idx = _state.selectedLine;
if (idx < 0 || idx >= _state.lines.length) {
propsEl.style.display = 'none';
@@ -553,7 +553,7 @@ function _fitView(): void {
function _renderCanvas(): void {
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const W = canvas.width;
const H = canvas.height;
@@ -679,7 +679,7 @@ function _drawLineTicks(ctx: CanvasRenderingContext2D, line: CalibrationLine, x1
return (line.reverse ? (1 - f) : f) * edgeLen;
};
const placed = [];
const placed: number[] = [];
// Place intermediate labels at nice steps
for (let i = 0; i < count; i++) {

View File

@@ -439,7 +439,7 @@ function _sizeCanvas(canvas: HTMLCanvasElement) {
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d').scale(dpr, dpr);
canvas.getContext('2d')!.scale(dpr, dpr);
}
function _renderLoop() {
@@ -449,11 +449,40 @@ function _renderLoop() {
}
}
// ── Event delegation for audio source card actions ──
const _audioSourceActions: Record<string, (id: string) => void> = {
'test-audio': testAudioSource,
'clone-audio': cloneAudioSource,
'edit-audio': editAudioSource,
};
export function initAudioSourceDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action || !id) return;
// Only handle audio-source actions (prefixed with audio-)
const handler = _audioSourceActions[action];
if (handler) {
// Verify we're inside an audio source section
const section = btn.closest<HTMLElement>('[data-card-section="audio-multi"], [data-card-section="audio-mono"]');
if (!section) return;
e.stopPropagation();
handler(id);
}
});
}
function _renderAudioSpectrum() {
const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;

View File

@@ -18,7 +18,7 @@ import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { attachProcessPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard } from './scene-presets.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.ts';
let _automationTagsInput: any = null;
@@ -158,7 +158,7 @@ export async function loadAutomations() {
} catch (error: any) {
if (error.isAuth) return;
console.error('Failed to load automations:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`;
container.innerHTML = `<p class="error-message">${escapeHtml(error.message)}</p>`;
} finally {
set_automationsLoading(false);
setTabRefreshing('automations-content', false);
@@ -194,6 +194,9 @@ function renderAutomations(automations: any, sceneMap: any) {
container.innerHTML = panels;
CardSection.bindAll([csAutomations, csScenes]);
// Event delegation for scene preset card actions
initScenePresetDelegation(container);
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_automationsTree.update(treeItems, activeTab);
_automationsTree.observeSections('automations-content', {

View File

@@ -138,7 +138,7 @@ export async function showCalibration(deviceId: any) {
try {
const [response, displays] = await Promise.all([
fetchWithAuth(`/devices/${deviceId}`),
displaysCache.fetch().catch(() => []),
displaysCache.fetch().catch((): any[] => []),
]);
if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; }
@@ -235,7 +235,7 @@ export async function showCSSCalibration(cssId: any) {
try {
const [cssSources, devices] = await Promise.all([
colorStripSourcesCache.fetch(),
devicesCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
]);
const source = cssSources.find((s: any) => s.id === cssId);
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }

View File

@@ -0,0 +1,413 @@
/**
* Color Strip Sources — Composite layer helpers.
* Extracted from color-strips.ts to reduce file size.
*/
import { escapeHtml } from '../core/api.ts';
import { _cachedValueSources, _cachedCSPTemplates } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import {
getColorStripIcon, getValueSourceIcon,
ICON_SPARKLES,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
/* ── Composite layer state ────────────────────────────────────── */
let _compositeLayers: any[] = [];
let _compositeAvailableSources: any[] = []; // non-composite sources for layer dropdowns
let _compositeSourceEntitySelects: any[] = [];
let _compositeBrightnessEntitySelects: any[] = [];
let _compositeBlendIconSelects: any[] = [];
let _compositeCSPTEntitySelects: any[] = [];
/** Return current composite layers array (for dirty-check snapshot). */
export function compositeGetRawLayers() {
return _compositeLayers;
}
export function compositeSetAvailableSources(sources: any[]) {
_compositeAvailableSources = sources;
}
export function compositeGetAvailableSources() {
return _compositeAvailableSources;
}
export function compositeDestroyEntitySelects() {
_compositeSourceEntitySelects.forEach(es => es.destroy());
_compositeSourceEntitySelects = [];
_compositeBrightnessEntitySelects.forEach(es => es.destroy());
_compositeBrightnessEntitySelects = [];
_compositeBlendIconSelects.forEach(is => is.destroy());
_compositeBlendIconSelects = [];
_compositeCSPTEntitySelects.forEach(es => es.destroy());
_compositeCSPTEntitySelects = [];
}
function _getCompositeBlendItems() {
return [
{ value: 'normal', icon: _icon(P.square), label: t('color_strip.composite.blend_mode.normal'), desc: t('color_strip.composite.blend_mode.normal.desc') },
{ value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') },
{ value: 'multiply', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') },
{ value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') },
{ value: 'override', icon: _icon(P.zap), label: t('color_strip.composite.blend_mode.override'), desc: t('color_strip.composite.blend_mode.override.desc') },
];
}
function _getCompositeSourceItems() {
return _compositeAvailableSources.map(s => ({
value: s.id,
label: s.name,
icon: getColorStripIcon(s.source_type),
}));
}
function _getCompositeBrightnessItems() {
return (_cachedValueSources || []).map(v => ({
value: v.id,
label: v.name,
icon: getValueSourceIcon(v.source_type),
}));
}
function _getCompositeCSPTItems() {
return (_cachedCSPTemplates || []).map(tmpl => ({
value: tmpl.id,
label: tmpl.name,
icon: ICON_SPARKLES,
}));
}
export function compositeRenderList() {
const list = document.getElementById('composite-layers-list') as HTMLElement | null;
if (!list) return;
compositeDestroyEntitySelects();
const vsList = _cachedValueSources || [];
list.innerHTML = _compositeLayers.map((layer, i) => {
const srcOptions = _compositeAvailableSources.map(s =>
`<option value="${s.id}"${layer.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
const vsOptions = `<option value="">${t('color_strip.composite.brightness.none')}</option>` +
vsList.map(v =>
`<option value="${v.id}"${layer.brightness_source_id === v.id ? ' selected' : ''}>${escapeHtml(v.name)}</option>`
).join('');
const csptList = _cachedCSPTemplates || [];
const csptOptions = `<option value="">${t('common.none_no_cspt')}</option>` +
csptList.map(tmpl =>
`<option value="${tmpl.id}"${layer.processing_template_id === tmpl.id ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
).join('');
const canRemove = _compositeLayers.length > 1;
return `
<div class="composite-layer-item" data-layer-index="${i}">
<div class="composite-layer-row">
<span class="composite-layer-drag-handle" title="${t('filters.drag_to_reorder')}">&#x2807;</span>
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
<select class="composite-layer-blend" data-idx="${i}">
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
<option value="override"${layer.blend_mode === 'override' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.override')}</option>
</select>
</div>
<div class="composite-layer-row">
<label class="composite-layer-opacity-label">
<span>${t('color_strip.composite.opacity')}:</span>
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
</label>
<input type="range" class="composite-layer-opacity" data-idx="${i}"
min="0" max="1" step="0.05" value="${layer.opacity}">
<label class="settings-toggle composite-layer-toggle">
<input type="checkbox" class="composite-layer-enabled" data-idx="${i}"${layer.enabled ? ' checked' : ''}>
<span class="settings-toggle-slider"></span>
</label>
${canRemove
? `<button type="button" class="btn btn-secondary composite-layer-remove-btn"
onclick="compositeRemoveLayer(${i})">&#x2715;</button>`
: ''}
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
<span>${t('color_strip.composite.brightness')}:</span>
</label>
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
<span>${t('color_strip.composite.processing')}:</span>
</label>
<select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
</div>
</div>
`;
}).join('');
// Wire up live opacity display
list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity').forEach(el => {
el.addEventListener('input', () => {
const val = parseFloat(el.value);
(el.closest('.composite-layer-row')!.querySelector('.composite-opacity-val') as HTMLElement).textContent = val.toFixed(2);
});
});
// Attach IconSelect to each layer's blend mode dropdown
const blendItems = _getCompositeBlendItems();
list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => {
const is = new IconSelect({ target: sel, items: blendItems, columns: 2 });
_compositeBlendIconSelects.push(is);
});
// Attach EntitySelect to each layer's source dropdown
list.querySelectorAll<HTMLSelectElement>('.composite-layer-source').forEach(sel => {
_compositeSourceEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeSourceItems,
placeholder: t('palette.search'),
}));
});
// Attach EntitySelect to each layer's brightness dropdown
list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness').forEach(sel => {
_compositeBrightnessEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeBrightnessItems,
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('color_strip.composite.brightness.none'),
}));
});
// Attach EntitySelect to each layer's CSPT dropdown
list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt').forEach(sel => {
_compositeCSPTEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeCSPTItems,
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('common.none_no_cspt'),
}));
});
_initCompositeLayerDrag(list);
}
export function compositeAddLayer() {
_compositeLayersSyncFromDom();
_compositeLayers.push({
source_id: _compositeAvailableSources.length > 0 ? _compositeAvailableSources[0].id : '',
blend_mode: 'normal',
opacity: 1.0,
enabled: true,
brightness_source_id: null,
processing_template_id: null,
});
compositeRenderList();
}
export function compositeRemoveLayer(i: number) {
_compositeLayersSyncFromDom();
if (_compositeLayers.length <= 1) return;
_compositeLayers.splice(i, 1);
compositeRenderList();
}
function _compositeLayersSyncFromDom() {
const list = document.getElementById('composite-layers-list') as HTMLElement | null;
if (!list) return;
const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source');
const blends = list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend');
const opacities = list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity');
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
if (srcs.length === _compositeLayers.length) {
for (let i = 0; i < srcs.length; i++) {
_compositeLayers[i].source_id = srcs[i].value;
_compositeLayers[i].blend_mode = blends[i].value;
_compositeLayers[i].opacity = parseFloat(opacities[i].value);
_compositeLayers[i].enabled = enableds[i].checked;
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
}
}
}
/* ── Composite layer drag-to-reorder ── */
const _COMPOSITE_DRAG_THRESHOLD = 5;
let _compositeLayerDragState: any = null;
function _initCompositeLayerDrag(list: any) {
// Guard against stacking listeners across re-renders (the list DOM node persists).
if (list._compositeDragBound) return;
list._compositeDragBound = true;
list.addEventListener('pointerdown', (e: any) => {
const handle = e.target.closest('.composite-layer-drag-handle');
if (!handle) return;
const item = handle.closest('.composite-layer-item');
if (!item) return;
e.preventDefault();
e.stopPropagation();
const fromIndex = parseInt(item.dataset.layerIndex, 10);
_compositeLayerDragState = {
item,
list,
startY: e.clientY,
started: false,
clone: null,
placeholder: null,
offsetY: 0,
fromIndex,
scrollRaf: null,
};
const onMove = (ev: any) => _onCompositeLayerDragMove(ev);
const cleanup = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', cleanup);
document.removeEventListener('pointercancel', cleanup);
_onCompositeLayerDragEnd();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', cleanup);
document.addEventListener('pointercancel', cleanup);
}, { capture: false });
}
function _onCompositeLayerDragMove(e: any) {
const ds = _compositeLayerDragState;
if (!ds) return;
if (!ds.started) {
if (Math.abs(e.clientY - ds.startY) < _COMPOSITE_DRAG_THRESHOLD) return;
_startCompositeLayerDrag(ds, e);
}
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
const items = ds.list.querySelectorAll('.composite-layer-item');
for (const it of items) {
if (it.style.display === 'none') continue;
const r = it.getBoundingClientRect();
if (e.clientY >= r.top && e.clientY <= r.bottom) {
const before = e.clientY < r.top + r.height / 2;
if (it === ds.lastTarget && before === ds.lastBefore) break;
ds.lastTarget = it;
ds.lastBefore = before;
if (before) {
ds.list.insertBefore(ds.placeholder, it);
} else {
ds.list.insertBefore(ds.placeholder, it.nextSibling);
}
break;
}
}
// Auto-scroll near modal edges
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
const modal = ds.list.closest('.modal-body');
if (modal) {
const EDGE = 60, SPEED = 12;
const mr = modal.getBoundingClientRect();
let speed = 0;
if (e.clientY < mr.top + EDGE) speed = -SPEED;
else if (e.clientY > mr.bottom - EDGE) speed = SPEED;
if (speed !== 0) {
const scroll = () => { modal.scrollTop += speed; ds.scrollRaf = requestAnimationFrame(scroll); };
ds.scrollRaf = requestAnimationFrame(scroll);
}
}
}
function _startCompositeLayerDrag(ds: any, e: any) {
ds.started = true;
const rect = ds.item.getBoundingClientRect();
const clone = ds.item.cloneNode(true);
clone.className = ds.item.className + ' composite-layer-drag-clone';
clone.style.width = rect.width + 'px';
clone.style.left = rect.left + 'px';
clone.style.top = rect.top + 'px';
document.body.appendChild(clone);
ds.clone = clone;
ds.offsetY = e.clientY - rect.top;
const placeholder = document.createElement('div');
placeholder.className = 'composite-layer-drag-placeholder';
placeholder.style.height = rect.height + 'px';
ds.item.parentNode.insertBefore(placeholder, ds.item);
ds.placeholder = placeholder;
ds.item.style.display = 'none';
document.body.classList.add('composite-layer-dragging');
}
function _onCompositeLayerDragEnd() {
const ds = _compositeLayerDragState;
_compositeLayerDragState = null;
if (!ds || !ds.started) return;
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
// Determine new index from placeholder position
let toIndex = 0;
for (const child of ds.list.children) {
if (child === ds.placeholder) break;
if (child.classList.contains('composite-layer-item') && child.style.display !== 'none') {
toIndex++;
}
}
// Cleanup DOM
ds.item.style.display = '';
ds.placeholder.remove();
ds.clone.remove();
document.body.classList.remove('composite-layer-dragging');
// Sync current DOM values before reordering
_compositeLayersSyncFromDom();
// Reorder array and re-render
if (toIndex !== ds.fromIndex) {
const [moved] = _compositeLayers.splice(ds.fromIndex, 1);
_compositeLayers.splice(toIndex, 0, moved);
compositeRenderList();
}
}
export function compositeGetLayers() {
_compositeLayersSyncFromDom();
return _compositeLayers.map(l => {
const layer: any = {
source_id: l.source_id,
blend_mode: l.blend_mode,
opacity: l.opacity,
enabled: l.enabled,
};
if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id;
if (l.processing_template_id) layer.processing_template_id = l.processing_template_id;
return layer;
});
}
export function loadCompositeState(css: any) {
const raw = css && css.layers;
_compositeLayers = (raw && raw.length > 0)
? raw.map((l: any) => ({
source_id: l.source_id || '',
blend_mode: l.blend_mode || 'normal',
opacity: l.opacity != null ? l.opacity : 1.0,
enabled: l.enabled != null ? l.enabled : true,
brightness_source_id: l.brightness_source_id || null,
processing_template_id: l.processing_template_id || null,
}))
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null }];
compositeRenderList();
}

View File

@@ -0,0 +1,273 @@
/**
* Color Strip Sources — Notification helpers.
* Extracted from color-strips.ts to reduce file size.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import {
ICON_SEARCH,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
import { getBaseOrigin } from './settings.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
/* ── Notification state ───────────────────────────────────────── */
let _notificationAppColors: any[] = []; // [{app: '', color: '#...'}]
/** Return current app colors array (for dirty-check snapshot). */
export function notificationGetRawAppColors() {
return _notificationAppColors;
}
let _notificationEffectIconSelect: any = null;
let _notificationFilterModeIconSelect: any = null;
export function ensureNotificationEffectIconSelect() {
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'flash', icon: _icon(P.zap), label: t('color_strip.notification.effect.flash'), desc: t('color_strip.notification.effect.flash.desc') },
{ value: 'pulse', icon: _icon(P.activity), label: t('color_strip.notification.effect.pulse'), desc: t('color_strip.notification.effect.pulse.desc') },
{ value: 'sweep', icon: _icon(P.fastForward), label: t('color_strip.notification.effect.sweep'), desc: t('color_strip.notification.effect.sweep.desc') },
];
if (_notificationEffectIconSelect) { _notificationEffectIconSelect.updateItems(items); return; }
_notificationEffectIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
export function ensureNotificationFilterModeIconSelect() {
const sel = document.getElementById('css-editor-notification-filter-mode') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'off', icon: _icon(P.globe), label: t('color_strip.notification.filter_mode.off'), desc: t('color_strip.notification.filter_mode.off.desc') },
{ value: 'whitelist', icon: _icon(P.circleCheck), label: t('color_strip.notification.filter_mode.whitelist'), desc: t('color_strip.notification.filter_mode.whitelist.desc') },
{ value: 'blacklist', icon: _icon(P.eyeOff), label: t('color_strip.notification.filter_mode.blacklist'), desc: t('color_strip.notification.filter_mode.blacklist.desc') },
];
if (_notificationFilterModeIconSelect) { _notificationFilterModeIconSelect.updateItems(items); return; }
_notificationFilterModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
export function onNotificationFilterModeChange() {
const mode = (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value;
(document.getElementById('css-editor-notification-filter-list-group') as HTMLElement).style.display = mode === 'off' ? 'none' : '';
}
function _notificationAppColorsRenderList() {
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
if (!list) return;
list.innerHTML = _notificationAppColors.map((entry, i) => `
<div class="notif-app-color-row">
<input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name">
<button type="button" class="notif-app-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="notif-app-color-remove"
onclick="notificationRemoveAppColor(${i})">&#x2715;</button>
</div>
`).join('');
// Wire up browse buttons to open process palette
list.querySelectorAll<HTMLButtonElement>('.notif-app-browse').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx!);
const nameInput = list.querySelector<HTMLInputElement>(`.notif-app-name[data-idx="${idx}"]`);
if (!nameInput) return;
const picked = await NotificationAppPalette.pick({
current: nameInput.value,
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps...',
});
if (picked !== undefined) {
nameInput.value = picked;
_notificationAppColorsSyncFromDom();
}
});
});
}
export function notificationAddAppColor() {
_notificationAppColorsSyncFromDom();
_notificationAppColors.push({ app: '', color: '#ffffff' });
_notificationAppColorsRenderList();
}
export function notificationRemoveAppColor(i: number) {
_notificationAppColorsSyncFromDom();
_notificationAppColors.splice(i, 1);
_notificationAppColorsRenderList();
}
export async function testNotification(sourceId: string) {
try {
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showToast(err.detail || t('color_strip.notification.test.error'), 'error');
return;
}
const data = await resp.json();
if (data.streams_notified > 0) {
showToast(t('color_strip.notification.test.ok'), 'success');
} else {
showToast(t('color_strip.notification.test.no_streams'), 'warning');
}
} catch {
showToast(t('color_strip.notification.test.error'), 'error');
}
}
// ── OS Notification History Modal ─────────────────────────────────────────
export function showNotificationHistory() {
const modal = document.getElementById('notification-history-modal') as HTMLElement | null;
if (!modal) return;
modal.style.display = 'flex';
modal.onclick = (e) => { if (e.target === modal) closeNotificationHistory(); };
_loadNotificationHistory();
}
export function closeNotificationHistory() {
const modal = document.getElementById('notification-history-modal') as HTMLElement | null;
if (modal) modal.style.display = 'none';
}
export async function refreshNotificationHistory() {
await _loadNotificationHistory();
}
async function _loadNotificationHistory() {
const list = document.getElementById('notification-history-list') as HTMLElement | null;
const status = document.getElementById('notification-history-status') as HTMLElement | null;
if (!list) return;
try {
const resp = (await fetchWithAuth('/color-strip-sources/os-notifications/history'))!;
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!data.available) {
list.innerHTML = '';
if (status) {
status.textContent = t('color_strip.notification.history.unavailable');
status.style.display = '';
}
return;
}
if (status) status.style.display = 'none';
const history = data.history || [];
if (history.length === 0) {
list.innerHTML = `<div class="notif-history-empty">${t('color_strip.notification.history.empty')}</div>`;
return;
}
list.innerHTML = history.map((entry: any) => {
const appName = entry.app || t('color_strip.notification.history.unknown_app');
const timeStr = new Date(entry.time * 1000).toLocaleString();
const fired = entry.fired ?? 0;
const filtered = entry.filtered ?? 0;
const firedBadge = fired > 0
? `<span class="notif-history-badge notif-history-badge--fired" title="${t('color_strip.notification.history.fired')}">${fired}</span>`
: '';
const filteredBadge = filtered > 0
? `<span class="notif-history-badge notif-history-badge--filtered" title="${t('color_strip.notification.history.filtered')}">${filtered}</span>`
: '';
return `<div class="notif-history-row">
<div class="notif-history-app" title="${escapeHtml(appName)}">${escapeHtml(appName)}</div>
<div class="notif-history-time">${timeStr}</div>
<div class="notif-history-badges">${firedBadge}${filteredBadge}</div>
</div>`;
}).join('');
} catch (err: any) {
console.error('Failed to load notification history:', err);
if (status) {
status.textContent = t('color_strip.notification.history.error');
status.style.display = '';
}
list.innerHTML = '';
}
}
function _notificationAppColorsSyncFromDom() {
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
if (!list) return;
const names = list.querySelectorAll<HTMLInputElement>('.notif-app-name');
const colors = list.querySelectorAll<HTMLInputElement>('.notif-app-color');
if (names.length === _notificationAppColors.length) {
for (let i = 0; i < names.length; i++) {
_notificationAppColors[i].app = names[i].value;
_notificationAppColors[i].color = colors[i].value;
}
}
}
export function notificationGetAppColorsDict() {
_notificationAppColorsSyncFromDom();
const dict: Record<string, any> = {};
for (const entry of _notificationAppColors) {
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
}
return dict;
}
export function loadNotificationState(css: any) {
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
const dur = css.duration_ms ?? 1500;
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = dur;
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = dur;
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = (css.app_filter_list || []).join('\n');
onNotificationFilterModeChange();
_attachNotificationProcessPicker();
// App colors dict -> list
const ac = css.app_colors || {};
_notificationAppColors = Object.entries(ac).map(([app, color]) => ({ app, color }));
_notificationAppColorsRenderList();
showNotificationEndpoint(css.id);
}
export function resetNotificationState() {
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = 1500 as any;
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = '1500';
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
onNotificationFilterModeChange();
_attachNotificationProcessPicker();
_notificationAppColors = [];
_notificationAppColorsRenderList();
showNotificationEndpoint(null);
}
function _attachNotificationProcessPicker() {
const container = document.getElementById('css-editor-notification-filter-picker-container') as HTMLElement | null;
const textarea = document.getElementById('css-editor-notification-filter-list') as HTMLTextAreaElement | null;
if (container && textarea) attachNotificationAppPicker(container, textarea);
}
export function showNotificationEndpoint(cssId: any) {
const el = document.getElementById('css-editor-notification-endpoint') as HTMLElement | null;
if (!el) return;
if (!cssId) {
el.innerHTML = `<em data-i18n="color_strip.notification.save_first">${t('color_strip.notification.save_first')}</em>`;
return;
}
const base = `${getBaseOrigin()}/api/v1`;
const url = `${base}/color-strip-sources/${cssId}/notify`;
el.innerHTML = `
<small class="endpoint-label">POST</small>
<div class="ws-url-row"><input type="text" value="${url}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">&#x1F4CB;</button></div>
`;
}

View File

@@ -0,0 +1,907 @@
/**
* Color Strip Sources — Test / Preview modal (WebSocket strip renderer).
* Extracted from color-strips.ts to reduce file size.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { colorStripSourcesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import {
getColorStripIcon,
ICON_WARNING, ICON_SUN_DIM,
} from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
/* ── Preview config builder ───────────────────────────────────── */
const _PREVIEW_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight',
]);
function _collectPreviewConfig() {
const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
if (!_PREVIEW_TYPES.has(sourceType)) return null;
let config: any;
if (sourceType === 'static') {
config = { source_type: 'static', color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), animation: _getAnimationPayload() };
} else if (sourceType === 'gradient') {
const stops = getGradientStops();
if (stops.length < 2) return null;
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload() };
} else if (sourceType === 'color_cycle') {
const colors = _colorCycleGetColors();
if (colors.length < 2) return null;
config = { source_type: 'color_cycle', colors };
} else if (sourceType === 'effect') {
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
if (config.effect_type === 'meteor') { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
} else if (sourceType === 'daylight') {
config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value) };
} else if (sourceType === 'candlelight') {
config = { source_type: 'candlelight', color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value), num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value) };
}
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
if (clockEl && clockEl.value) config.clock_id = clockEl.value;
return config;
}
/**
* Open the existing Test Preview modal from the CSS editor.
* For saved sources, uses the normal test endpoint.
* For unsaved/self-contained types, uses the transient preview endpoint.
*/
export function previewCSSFromEditor() {
// Always use transient preview with current form values
const config = _collectPreviewConfig();
if (!config) {
// Non-previewable type (picture, composite, etc.) — fall back to saved source test
const cssId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
if (cssId) { testColorStrip(cssId); return; }
showToast(t('color_strip.preview.unsupported'), 'info');
return;
}
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
_cssTestTransientConfig = config;
const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null;
if (csptGroup) csptGroup.style.display = 'none';
_openTestModal('__preview__');
}
/** Store transient config so _cssTestConnect and applyCssTestSettings can use it. */
let _cssTestTransientConfig: any = null;
/* ── Test / Preview ───────────────────────────────────────────── */
const _CSS_TEST_LED_KEY = 'css_test_led_count';
const _CSS_TEST_FPS_KEY = 'css_test_fps';
let _cssTestWs: WebSocket | null = null;
let _cssTestRaf: number | null = null;
let _cssTestLatestRgb: Uint8Array | null = null;
let _cssTestMeta: any = null;
let _cssTestSourceId: string | null = null;
let _cssTestIsComposite: boolean = false;
let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
let _cssTestGeneration: number = 0; // bumped on each connect to ignore stale WS messages
let _cssTestNotificationIds: string[] = []; // notification source IDs to fire (self or composite layers)
let _cssTestCSPTMode: boolean = false; // true when testing a CSPT template
let _cssTestCSPTId: string | null = null; // CSPT template ID when in CSPT mode
let _cssTestIsApiInput: boolean = false;
let _cssTestFpsTimestamps: number[] = []; // raw timestamps for current-second FPS calculation
let _cssTestFpsActualHistory: number[] = []; // rolling FPS samples for sparkline
let _cssTestFpsChart: any = null;
const _CSS_TEST_FPS_MAX_SAMPLES = 30;
let _csptTestInputEntitySelect: any = null;
function _getCssTestLedCount() {
const stored = parseInt(localStorage.getItem(_CSS_TEST_LED_KEY) ?? '', 10);
return (stored > 0 && stored <= 2000) ? stored : 100;
}
function _getCssTestFps() {
const stored = parseInt(localStorage.getItem(_CSS_TEST_FPS_KEY) ?? '', 10);
return (stored >= 1 && stored <= 60) ? stored : 20;
}
function _populateCssTestSourceSelector(preselectId: any) {
const sources = (colorStripSourcesCache.data || []) as any[];
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
const sel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement;
sel.innerHTML = nonProcessed.map(s =>
`<option value="${s.id}"${s.id === preselectId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
_csptTestInputEntitySelect = new EntitySelect({
target: sel,
getItems: () => ((colorStripSourcesCache.data || []) as any[])
.filter(s => s.source_type !== 'processed')
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
placeholder: t('palette.search'),
});
}
export function testColorStrip(sourceId: string) {
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
// Detect api_input type
const sources = (colorStripSourcesCache.data || []) as any[];
const src = sources.find(s => s.id === sourceId);
_cssTestIsApiInput = src?.source_type === 'api_input';
// Populate input source selector with current source preselected
_populateCssTestSourceSelector(sourceId);
_openTestModal(sourceId);
}
export async function testCSPT(templateId: string) {
_cssTestCSPTMode = true;
_cssTestCSPTId = templateId;
// Populate input source selector
await colorStripSourcesCache.fetch();
_populateCssTestSourceSelector(null);
const sel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement;
const inputId = sel.value;
if (!inputId) {
showToast(t('color_strip.processed.error.no_input'), 'error');
return;
}
_openTestModal(inputId);
}
function _openTestModal(sourceId: string) {
// Clean up any previous session fully
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestIsComposite = false;
_cssTestLayerData = null;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return;
modal.style.display = 'flex';
modal.onclick = (e) => { if (e.target === modal) closeTestCssSourceModal(); };
_cssTestSourceId = sourceId;
// Reset views and clear stale canvas content
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none';
// Clear all test canvases to prevent stale frames from previous sessions
modal.querySelectorAll('canvas').forEach(c => {
const ctx = c.getContext('2d');
if (ctx) ctx.clearRect(0, 0, c.width, c.height);
});
(document.getElementById('css-test-led-group') as HTMLElement).style.display = '';
// Input source selector: shown for both CSS test and CSPT test, hidden for api_input
const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null;
if (csptGroup) csptGroup.style.display = _cssTestIsApiInput ? 'none' : '';
const layersContainer = document.getElementById('css-test-layers') as HTMLElement | null;
if (layersContainer) layersContainer.innerHTML = '';
(document.getElementById('css-test-status') as HTMLElement).style.display = '';
(document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.connecting');
// Reset FPS tracking
_cssTestFpsActualHistory = [];
// For api_input: hide LED/FPS controls, show FPS chart
const ledControlGroup = document.getElementById('css-test-led-fps-group') as HTMLElement | null;
const fpsChartGroup = document.getElementById('css-test-fps-chart-group') as HTMLElement | null;
if (_cssTestIsApiInput) {
if (ledControlGroup) ledControlGroup.style.display = 'none';
if (fpsChartGroup) fpsChartGroup.style.display = '';
_cssTestStartFpsSampling();
// Use large LED count (buffer auto-sizes) and high poll FPS
_cssTestConnect(sourceId, 1000, 60);
} else {
if (ledControlGroup) ledControlGroup.style.display = '';
if (fpsChartGroup) fpsChartGroup.style.display = 'none';
// Restore LED count + FPS + Enter key handlers
const ledCount = _getCssTestLedCount();
const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null;
ledInput!.value = ledCount as any;
ledInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
const fpsVal = _getCssTestFps();
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
fpsInput!.value = fpsVal as any;
fpsInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
_cssTestConnect(sourceId, ledCount, fpsVal);
}
}
function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
// Close existing connection if any
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
// Bump generation so any late messages from old WS are ignored
const gen = ++_cssTestGeneration;
if (!fps) fps = _getCssTestFps();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const apiKey = localStorage.getItem('wled_api_key') || '';
const isTransient = sourceId === '__preview__' && _cssTestTransientConfig;
let wsUrl;
if (isTransient) {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/preview/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
} else if (_cssTestCSPTMode && _cssTestCSPTId) {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`;
} else {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
}
_cssTestWs = new WebSocket(wsUrl);
_cssTestWs.binaryType = 'arraybuffer';
if (isTransient) {
_cssTestWs.onopen = () => {
if (gen !== _cssTestGeneration) return;
_cssTestWs!.send(JSON.stringify(_cssTestTransientConfig));
};
}
_cssTestWs.onmessage = (event) => {
// Ignore messages from a stale connection
if (gen !== _cssTestGeneration) return;
if (typeof event.data === 'string') {
let msg: any;
try {
msg = JSON.parse(event.data);
} catch (e) {
console.error('CSS test WS parse error:', e);
return;
}
// Handle brightness updates for composite layers
if (msg.type === 'brightness') {
_cssTestUpdateBrightness(msg.values);
return;
}
// Handle frame dimensions — render border-width overlay
if (msg.type === 'frame_dims' && _cssTestMeta) {
_cssTestRenderBorderOverlay(msg.width, msg.height);
return;
}
// Initial metadata
_cssTestMeta = msg;
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
_cssTestIsComposite = _cssTestMeta.layers && _cssTestMeta.layers.length > 0;
// Reset FPS timestamps so the initial bootstrap frame
// (sent right after metadata for api_input) isn't counted
if (_cssTestIsApiInput) _cssTestFpsTimestamps = [];
// Show correct view
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = (isPicture || _cssTestIsComposite) ? 'none' : '';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = isPicture ? '' : 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = _cssTestIsComposite ? '' : 'none';
(document.getElementById('css-test-status') as HTMLElement).style.display = 'none';
// Widen modal for picture sources to show the screen rectangle larger
const modalContent = document.querySelector('#test-css-source-modal .modal-content') as HTMLElement | null;
if (modalContent) modalContent.style.maxWidth = isPicture ? '900px' : '';
// Hide LED count control for picture sources (LED count is fixed by calibration)
(document.getElementById('css-test-led-group') as HTMLElement).style.display = isPicture ? 'none' : '';
// Show fire button for notification sources (direct only; composite has per-layer buttons)
const isNotify = _cssTestMeta.source_type === 'notification';
const layerInfos = _cssTestMeta.layer_infos || [];
_cssTestNotificationIds = isNotify
? [_cssTestSourceId]
: layerInfos.filter((li: any) => li.is_notification).map((li: any) => li.id);
const fireBtn = document.getElementById('css-test-fire-btn') as HTMLElement | null;
if (fireBtn) fireBtn.style.display = (isNotify && !_cssTestIsComposite) ? '' : 'none';
// Populate rect screen labels for picture sources
if (isPicture) {
const nameEl = document.getElementById('css-test-rect-name') as HTMLElement | null;
const ledsEl = document.getElementById('css-test-rect-leds') as HTMLElement | null;
if (nameEl) nameEl.textContent = _cssTestMeta.source_name || '';
if (ledsEl) ledsEl.textContent = `${_cssTestMeta.led_count} LEDs`;
// Render tick marks after layout settles
requestAnimationFrame(() => _cssTestRenderTicks(_cssTestMeta.edges));
}
// Build composite layer canvases
if (_cssTestIsComposite) {
_cssTestBuildLayers(_cssTestMeta.layers, _cssTestMeta.source_type, _cssTestMeta.layer_infos);
requestAnimationFrame(() => _cssTestRenderStripAxis('css-test-layers-axis', _cssTestMeta.led_count));
}
// Render strip axis for non-picture, non-composite views
if (!isPicture && !_cssTestIsComposite) {
requestAnimationFrame(() => _cssTestRenderStripAxis('css-test-strip-axis', _cssTestMeta.led_count));
}
} else {
const raw = new Uint8Array(event.data);
// Check JPEG frame preview: [0xFD] [jpeg_bytes]
if (raw.length > 1 && raw[0] === 0xFD) {
const jpegBlob = new Blob([raw.subarray(1)], { type: 'image/jpeg' });
const url = URL.createObjectURL(jpegBlob);
const screen = document.getElementById('css-test-rect-screen') as HTMLElement | null;
if (screen) {
// Preload image to avoid flicker on swap
const img = new Image();
img.onload = () => {
const oldUrl = (screen as any)._blobUrl;
(screen as any)._blobUrl = url;
screen.style.backgroundImage = `url(${url})`;
screen.style.backgroundSize = 'cover';
screen.style.backgroundPosition = 'center';
if (oldUrl) URL.revokeObjectURL(oldUrl);
// Set aspect ratio from first decoded frame
const rect = document.getElementById('css-test-rect') as HTMLElement | null;
if (rect && !(rect as any)._aspectSet && img.naturalWidth && img.naturalHeight) {
(rect as any).style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`;
rect.style.height = 'auto';
(rect as any)._aspectSet = true;
requestAnimationFrame(() => _cssTestRenderTicks(_cssTestMeta?.edges));
}
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
return;
}
// Check composite wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...]
if (raw.length > 3 && raw[0] === 0xFE && _cssTestIsComposite) {
const layerCount = raw[1];
const ledCount = (raw[2] << 8) | raw[3];
const rgbSize = ledCount * 3;
let offset = 4;
const layers: Uint8Array[] = [];
for (let i = 0; i < layerCount; i++) {
layers.push(raw.subarray(offset, offset + rgbSize));
offset += rgbSize;
}
const composite = raw.subarray(offset, offset + rgbSize);
_cssTestLayerData = { layerCount, ledCount, layers, composite };
_cssTestLatestRgb = composite;
} else {
// Standard format: raw RGB
_cssTestLatestRgb = raw;
}
// Track FPS for api_input sources
if (_cssTestIsApiInput) {
_cssTestFpsTimestamps.push(performance.now());
}
}
};
_cssTestWs.onerror = () => {
if (gen !== _cssTestGeneration) return;
(document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error');
};
_cssTestWs.onclose = () => {
if (gen === _cssTestGeneration) _cssTestWs = null;
};
// Start render loop (only once)
if (!_cssTestRaf) _cssTestRenderLoop();
}
const _BELL_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg>';
function _cssTestBuildLayers(layerNames: any[], sourceType: any, layerInfos: any[]) {
const container = document.getElementById('css-test-layers') as HTMLElement | null;
if (!container) return;
// Composite result first, then individual layers
let html = `<div class="css-test-layer css-test-layer-composite">` +
`<canvas class="css-test-layer-canvas" data-layer-idx="composite"></canvas>` +
`<span class="css-test-layer-label">${sourceType === 'composite' ? t('color_strip.test.composite') : ''}</span>` +
`</div>`;
for (let i = 0; i < layerNames.length; i++) {
const info = layerInfos && layerInfos[i];
const isNotify = info && info.is_notification;
const hasBri = info && info.has_brightness;
const fireBtn = isNotify
? `<button class="css-test-fire-btn" onclick="event.stopPropagation(); fireCssTestNotificationLayer('${info.id}')" title="${t('color_strip.notification.test')}">${_BELL_SVG}</button>`
: '';
const briLabel = hasBri
? `<span class="css-test-layer-brightness" data-layer-idx="${i}" style="display:none"></span>`
: '';
let calLabel = '';
if (info && info.is_picture && info.calibration_led_count) {
const mismatch = _cssTestMeta.led_count !== info.calibration_led_count;
calLabel = `<span class="css-test-layer-cal${mismatch ? ' css-test-layer-cal-warn' : ''}" data-layer-idx="${i}">${mismatch ? ICON_WARNING + ' ' : ''}${_cssTestMeta.led_count}/${info.calibration_led_count}</span>`;
}
html += `<div class="css-test-layer css-test-strip-wrap">` +
`<canvas class="css-test-layer-canvas" data-layer-idx="${i}"></canvas>` +
`<span class="css-test-layer-label">${escapeHtml(layerNames[i])}</span>` +
calLabel +
briLabel +
fireBtn +
`</div>`;
}
container.innerHTML = html;
}
function _cssTestUpdateBrightness(values: any) {
if (!values) return;
const container = document.getElementById('css-test-layers') as HTMLElement | null;
if (!container) return;
for (let i = 0; i < values.length; i++) {
const el = container.querySelector(`.css-test-layer-brightness[data-layer-idx="${i}"]`) as HTMLElement | null;
if (!el) continue;
const v = values[i];
if (v != null) {
el.innerHTML = `${ICON_SUN_DIM} ${v}%`;
el.style.display = '';
} else {
el.style.display = 'none';
}
}
}
export function applyCssTestSettings() {
if (!_cssTestSourceId) return;
const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null;
let leds = parseInt(ledInput?.value ?? '', 10);
if (isNaN(leds) || leds < 1) leds = 1;
if (leds > 2000) leds = 2000;
if (ledInput) ledInput.value = leds as any;
localStorage.setItem(_CSS_TEST_LED_KEY, String(leds));
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
let fps = parseInt(fpsInput?.value ?? '', 10);
if (isNaN(fps) || fps < 1) fps = 1;
if (fps > 60) fps = 60;
if (fpsInput) fpsInput.value = fps as any;
localStorage.setItem(_CSS_TEST_FPS_KEY, String(fps));
// Clear frame data but keep views/layout intact to avoid size jump
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestLayerData = null;
// Read selected input source from selector (both CSS and CSPT modes)
const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null;
if (inputSel && inputSel.value) {
_cssTestSourceId = inputSel.value;
// Re-detect api_input when source changes
const sources = (colorStripSourcesCache.data || []) as any[];
const src = sources.find(s => s.id === _cssTestSourceId);
_cssTestIsApiInput = src?.source_type === 'api_input';
}
// Reconnect (generation counter ignores stale frames from old WS)
_cssTestConnect(_cssTestSourceId, leds, fps);
}
function _cssTestRenderLoop() {
_cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
if (!_cssTestMeta) return;
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
if (_cssTestIsComposite && _cssTestLayerData) {
_cssTestRenderLayers(_cssTestLayerData);
} else if (isPicture && _cssTestLatestRgb) {
_cssTestRenderRect(_cssTestLatestRgb, _cssTestMeta.edges);
} else if (_cssTestLatestRgb) {
_cssTestRenderStrip(_cssTestLatestRgb);
}
}
function _cssTestRenderStrip(rgbBytes: Uint8Array) {
const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ledCount = rgbBytes.length / 3;
canvas.width = ledCount;
canvas.height = 1;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(ledCount, 1);
const data = imageData.data;
for (let i = 0; i < ledCount; i++) {
const si = i * 3;
const di = i * 4;
data[di] = rgbBytes[si];
data[di + 1] = rgbBytes[si + 1];
data[di + 2] = rgbBytes[si + 2];
data[di + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
}
function _cssTestRenderLayers(data: any) {
const container = document.getElementById('css-test-layers') as HTMLElement | null;
if (!container) return;
// Composite canvas is first
const compositeCanvas = container.querySelector('[data-layer-idx="composite"]') as HTMLCanvasElement | null;
if (compositeCanvas) _cssTestRenderStripCanvas(compositeCanvas, data.composite);
// Individual layer canvases
for (let i = 0; i < data.layers.length; i++) {
const canvas = container.querySelector(`[data-layer-idx="${i}"]`) as HTMLCanvasElement | null;
if (canvas) _cssTestRenderStripCanvas(canvas, data.layers[i]);
}
}
function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
const ledCount = rgbBytes.length / 3;
if (ledCount <= 0) return;
canvas.width = ledCount;
canvas.height = 1;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(ledCount, 1);
const data = imageData.data;
for (let i = 0; i < ledCount; i++) {
const si = i * 3;
const di = i * 4;
data[di] = rgbBytes[si];
data[di + 1] = rgbBytes[si + 1];
data[di + 2] = rgbBytes[si + 2];
data[di + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
}
function _cssTestRenderRect(rgbBytes: Uint8Array, edges: any[]) {
// edges: [{ edge: "top"|..., indices: [outputIdx, ...] }, ...]
// indices are pre-computed on server: reverse + offset already applied
const edgeMap: Record<string, number[]> = { top: [], right: [], bottom: [], left: [] };
for (const e of edges) {
if (edgeMap[e.edge]) edgeMap[e.edge].push(...e.indices);
}
for (const [edge, indices] of Object.entries(edgeMap)) {
const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null;
if (!canvas) continue;
const count = indices.length;
if (count === 0) { canvas.width = 0; continue; }
const isH = edge === 'top' || edge === 'bottom';
canvas.width = isH ? count : 1;
canvas.height = isH ? 1 : count;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(canvas.width, canvas.height);
const px = imageData.data;
for (let i = 0; i < count; i++) {
const si = indices[i] * 3;
const di = i * 4;
px[di] = rgbBytes[si] || 0;
px[di + 1] = rgbBytes[si + 1] || 0;
px[di + 2] = rgbBytes[si + 2] || 0;
px[di + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
}
}
function _cssTestRenderBorderOverlay(frameW: number, frameH: number) {
const screen = document.getElementById('css-test-rect-screen') as HTMLElement | null;
if (!screen || !_cssTestMeta) return;
// Remove any previous border overlay
screen.querySelectorAll('.css-test-border-overlay').forEach(el => el.remove());
const bw = _cssTestMeta.border_width;
if (!bw || bw <= 0) return;
const edges = _cssTestMeta.edges || [];
const activeEdges = new Set(edges.map((e: any) => e.edge));
// Compute border as percentage of frame dimensions
const bwPctH = (bw / frameH * 100).toFixed(2); // % for top/bottom
const bwPctW = (bw / frameW * 100).toFixed(2); // % for left/right
const overlayStyle = 'position:absolute;pointer-events:none;background:rgba(var(--primary-color-rgb, 76,175,80),0.18);border:1px solid rgba(var(--primary-color-rgb, 76,175,80),0.4);';
if (activeEdges.has('top')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}top:0;left:0;right:0;height:${bwPctH}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
if (activeEdges.has('bottom')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}bottom:0;left:0;right:0;height:${bwPctH}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
if (activeEdges.has('left')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}top:0;bottom:0;left:0;width:${bwPctW}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
if (activeEdges.has('right')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}top:0;bottom:0;right:0;width:${bwPctW}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
// Show border width label
const label = document.createElement('div');
label.className = 'css-test-border-overlay css-test-border-label';
label.textContent = `${bw}px`;
screen.appendChild(label);
}
function _cssTestRenderTicks(edges: any[]) {
const canvas = document.getElementById('css-test-rect-ticks') as HTMLCanvasElement | null;
const rectEl = document.getElementById('css-test-rect') as HTMLElement | null;
if (!canvas || !rectEl) return;
const outer = canvas.parentElement!;
const outerRect = outer.getBoundingClientRect();
const gridRect = rectEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = outerRect.width * dpr;
canvas.height = outerRect.height * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, outerRect.width, outerRect.height);
// Grid offset within outer container (the padding area)
const gx = gridRect.left - outerRect.left;
const gy = gridRect.top - outerRect.top;
const gw = gridRect.width;
const gh = gridRect.height;
const edgeThick = 14; // matches CSS grid-template
// Build edge map with indices
const edgeMap: Record<string, number[]> = { top: [], right: [], bottom: [], left: [] };
for (const e of edges) {
if (edgeMap[e.edge]) edgeMap[e.edge].push(...e.indices);
}
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.35)';
const tickFill = isDark ? 'rgba(255, 255, 255, 0.75)' : 'rgba(0, 0, 0, 0.65)';
ctx.strokeStyle = tickStroke;
ctx.fillStyle = tickFill;
ctx.lineWidth = 1;
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
const edgeGeom: Record<string, any> = {
top: { x1: gx + edgeThick, x2: gx + gw - edgeThick, y: gy, dir: -1, horizontal: true },
bottom: { x1: gx + edgeThick, x2: gx + gw - edgeThick, y: gy + gh, dir: 1, horizontal: true },
left: { y1: gy + edgeThick, y2: gy + gh - edgeThick, x: gx, dir: -1, horizontal: false },
right: { y1: gy + edgeThick, y2: gy + gh - edgeThick, x: gx + gw, dir: 1, horizontal: false },
};
for (const [edge, indices] of Object.entries(edgeMap)) {
const count = indices.length;
if (count === 0) continue;
const geo = edgeGeom[edge];
// Determine which ticks to label
const labelsToShow = new Set([0]);
if (count > 1) labelsToShow.add(count - 1);
if (count > 2) {
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
const maxDigits = String(Math.max(...indices)).length;
const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 20;
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
let step = niceSteps[niceSteps.length - 1];
for (const s of niceSteps) {
if (Math.floor(count / s) <= 4) { step = s; break; }
}
const tickPx = (i: number) => (i / (count - 1)) * edgeLen;
const placed: number[] = [];
// Place boundary ticks first
labelsToShow.forEach(i => placed.push(tickPx(i)));
for (let i = 1; i < count - 1; i++) {
if (indices[i] % step === 0) {
const px = tickPx(i);
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
labelsToShow.add(i);
placed.push(px);
}
}
}
}
const tickLen = 6;
labelsToShow.forEach(idx => {
const label = String(indices[idx]);
const fraction = count > 1 ? idx / (count - 1) : 0.5;
if (geo.horizontal) {
const tx = geo.x1 + fraction * (geo.x2 - geo.x1);
const ty = geo.y;
const tickDir = geo.dir; // -1 for top (tick goes up), +1 for bottom (tick goes down)
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(tx, ty + tickDir * tickLen);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = tickDir < 0 ? 'bottom' : 'top';
ctx.fillText(label, tx, ty + tickDir * (tickLen + 2));
} else {
const ty = geo.y1 + fraction * (geo.y2 - geo.y1);
const tx = geo.x;
const tickDir = geo.dir; // -1 for left (tick goes left), +1 for right (tick goes right)
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(tx + tickDir * tickLen, ty);
ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = tickDir < 0 ? 'right' : 'left';
ctx.fillText(label, tx + tickDir * (tickLen + 2), ty);
}
});
}
}
function _cssTestRenderStripAxis(canvasId: string, ledCount: number) {
const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null;
if (!canvas || ledCount <= 0) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.35)';
const tickFill = isDark ? 'rgba(255, 255, 255, 0.75)' : 'rgba(0, 0, 0, 0.65)';
ctx.strokeStyle = tickStroke;
ctx.fillStyle = tickFill;
ctx.lineWidth = 1;
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textBaseline = 'top';
const tickLen = 5;
// Determine which ticks to label
const labelsToShow = new Set([0]);
if (ledCount > 1) labelsToShow.add(ledCount - 1);
if (ledCount > 2) {
const maxDigits = String(ledCount - 1).length;
const minSpacing = maxDigits * 7 + 8;
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
let step = niceSteps[niceSteps.length - 1];
for (const s of niceSteps) {
if (Math.floor(ledCount / s) <= Math.floor(w / minSpacing)) { step = s; break; }
}
const tickPx = (i: number) => (i / (ledCount - 1)) * w;
const placed: number[] = [];
labelsToShow.forEach(i => placed.push(tickPx(i)));
for (let i = 1; i < ledCount - 1; i++) {
if (i % step === 0) {
const px = tickPx(i);
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
labelsToShow.add(i);
placed.push(px);
}
}
}
}
labelsToShow.forEach(idx => {
const fraction = ledCount > 1 ? idx / (ledCount - 1) : 0.5;
const tx = fraction * w;
ctx.beginPath();
ctx.moveTo(tx, 0);
ctx.lineTo(tx, tickLen);
ctx.stroke();
// Align first tick left, last tick right, others center
if (idx === 0) ctx.textAlign = 'left';
else if (idx === ledCount - 1) ctx.textAlign = 'right';
else ctx.textAlign = 'center';
ctx.fillText(String(idx), tx, tickLen + 1);
});
}
export function fireCssTestNotification() {
for (const id of _cssTestNotificationIds) {
testNotification(id);
}
}
export function fireCssTestNotificationLayer(sourceId: string) {
testNotification(sourceId);
}
let _cssTestFpsSampleInterval: ReturnType<typeof setInterval> | null = null;
function _cssTestStartFpsSampling() {
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
// Sample FPS every 1 second
_cssTestFpsSampleInterval = setInterval(() => {
const now = performance.now();
// Count frames in the last 1 second
const cutoff = now - 1000;
_cssTestFpsTimestamps = _cssTestFpsTimestamps.filter(t => t >= cutoff);
const fps = _cssTestFpsTimestamps.length;
_cssTestFpsActualHistory.push(fps);
if (_cssTestFpsActualHistory.length > _CSS_TEST_FPS_MAX_SAMPLES)
_cssTestFpsActualHistory.shift();
// Update numeric display (match target card format)
const valueEl = document.getElementById('css-test-fps-value') as HTMLElement | null;
if (valueEl) valueEl.textContent = String(fps);
const avgEl = document.getElementById('css-test-fps-avg') as HTMLElement | null;
if (avgEl && _cssTestFpsActualHistory.length > 1) {
const avg = _cssTestFpsActualHistory.reduce((a, b) => a + b, 0) / _cssTestFpsActualHistory.length;
avgEl.textContent = `avg ${avg.toFixed(1)}`;
}
// Create or update chart
if (!_cssTestFpsChart) {
_cssTestFpsChart = createFpsSparkline(
'css-test-fps-chart',
_cssTestFpsActualHistory,
[], // no "current" dataset, just actual
60, // y-axis max
);
}
if (_cssTestFpsChart) {
const ds = _cssTestFpsChart.data.datasets[0].data;
ds.length = 0;
ds.push(..._cssTestFpsActualHistory);
while (_cssTestFpsChart.data.labels.length < ds.length) _cssTestFpsChart.data.labels.push('');
_cssTestFpsChart.data.labels.length = ds.length;
_cssTestFpsChart.update('none');
}
}, 1000);
}
function _cssTestStopFpsSampling() {
if (_cssTestFpsSampleInterval) { clearInterval(_cssTestFpsSampleInterval); _cssTestFpsSampleInterval = null; }
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
}
export function closeTestCssSourceModal() {
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestSourceId = null;
_cssTestIsComposite = false;
_cssTestLayerData = null;
_cssTestNotificationIds = [];
_cssTestIsApiInput = false;
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
// Revoke blob URL for frame preview
const screen = document.getElementById('css-test-rect-screen') as HTMLElement | null;
if (screen && (screen as any)._blobUrl) { URL.revokeObjectURL((screen as any)._blobUrl); (screen as any)._blobUrl = null; screen.style.backgroundImage = ''; }
// Reset aspect ratio for next open
const rect = document.getElementById('css-test-rect') as HTMLElement | null;
if (rect) { (rect as any).style.aspectRatio = ''; rect.style.height = ''; (rect as any)._aspectSet = false; }
// Reset modal width
const modalContent = document.querySelector('#test-css-source-modal .modal-content') as HTMLElement | null;
if (modalContent) modalContent.style.maxWidth = '';
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (modal) { modal.style.display = 'none'; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -222,7 +222,7 @@ function _gradientRenderCanvas(): void {
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
if (canvas.width !== W) canvas.width = W;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const H = canvas.height;
const imgData = ctx.createImageData(W, H);

View File

@@ -12,7 +12,7 @@ import {
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
} from '../core/icons.ts';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.ts';
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
import { cardColorStyle } from '../core/card-colors.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation } from '../types.ts';
@@ -57,7 +57,7 @@ function _getInterpolatedUptime(targetId: string): number | null {
function _cacheUptimeElements(): void {
_uptimeElements = {};
for (const id of _lastRunningIds) {
const el = document.querySelector(`[data-uptime-text="${id}"]`);
const el = document.querySelector(`[data-uptime-text="${CSS.escape(id)}"]`);
if (el) _uptimeElements[id] = el;
}
}
@@ -126,7 +126,7 @@ async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
if (!canvas) continue;
const actualH = _fpsHistory[id] || [];
const currentH = _fpsCurrentHistory[id] || [];
const fpsTarget = parseFloat(canvas.dataset.fpsTarget) || 30;
const fpsTarget = parseFloat(canvas.dataset.fpsTarget ?? '30') || 30;
_fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, actualH, currentH, fpsTarget);
}
@@ -137,9 +137,9 @@ function _cacheMetricsElements(runningIds: string[]): void {
_metricsElements.clear();
for (const id of runningIds) {
_metricsElements.set(id, {
fps: document.querySelector(`[data-fps-text="${id}"]`),
errors: document.querySelector(`[data-errors-text="${id}"]`),
row: document.querySelector(`[data-target-id="${id}"]`),
fps: document.querySelector(`[data-fps-text="${CSS.escape(id)}"]`),
errors: document.querySelector(`[data-errors-text="${CSS.escape(id)}"]`),
row: document.querySelector(`[data-target-id="${CSS.escape(id)}"]`),
});
}
}
@@ -181,7 +181,7 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
// Update text values (use cached refs, fallback to querySelector)
const cached = _metricsElements.get(target.id);
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${target.id}"]`);
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${CSS.escape(target.id)}"]`);
if (fpsEl) {
const effFps = state.fps_effective;
const fpsTargetLabel = (effFps != null && effFps < fpsTarget)
@@ -192,13 +192,13 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
+ `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
}
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${CSS.escape(target.id)}"]`);
if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; (errorsEl as HTMLElement).title = String(errors); }
// Update health dot — prefer streaming reachability when processing
const isLed = target.target_type === 'led' || target.target_type === 'wled';
if (isLed) {
const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`);
const row = cached?.row || document.querySelector(`[data-target-id="${CSS.escape(target.id)}"]`);
if (row) {
const dot = row.querySelector('.health-dot');
if (dot) {
@@ -217,7 +217,7 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
function _updateAutomationsInPlace(automations: Automation[]): void {
for (const a of automations) {
const card = document.querySelector(`[data-automation-id="${a.id}"]`);
const card = document.querySelector(`[data-automation-id="${CSS.escape(a.id)}"]`);
if (!card) continue;
const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped');
if (badge) {
@@ -243,7 +243,7 @@ function _updateAutomationsInPlace(automations: Automation[]): void {
function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
for (const c of syncClocks) {
const card = document.querySelector(`[data-sync-clock-id="${c.id}"]`);
const card = document.querySelector(`[data-sync-clock-id="${CSS.escape(c.id)}"]`);
if (!card) continue;
const speedEl = card.querySelector('.dashboard-clock-speed');
if (speedEl) speedEl.textContent = `${c.speed}x`;
@@ -292,7 +292,7 @@ function _renderPollIntervalSelect(): string {
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
}
let _pollDebounce: ReturnType<typeof setTimeout> | null = null;
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
export function changeDashboardPollInterval(value: string | number): void {
const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`;
@@ -307,7 +307,7 @@ export function changeDashboardPollInterval(value: string | number): void {
}
function _getCollapsedSections(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; }
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; }
catch { return {}; }
}
@@ -315,7 +315,7 @@ export function toggleDashboardSection(sectionKey: string): void {
const collapsed = _getCollapsedSections();
collapsed[sectionKey] = !collapsed[sectionKey];
localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed));
const header = document.querySelector(`[data-dashboard-section="${sectionKey}"]`);
const header = document.querySelector(`[data-dashboard-section="${CSS.escape(sectionKey)}"]`);
if (!header) return;
const content = header.nextElementSibling;
const chevron = header.querySelector('.dashboard-section-chevron');
@@ -379,10 +379,10 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
try {
// Fire all requests in a single batch to avoid sequential RTTs
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/automations').catch(() => null),
devicesCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
fetchWithAuth('/output-targets/batch/states').catch(() => null),
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
loadScenePresets(),
@@ -403,7 +403,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
// Build dynamic HTML (targets, automations)
let dynamicHtml = '';
let runningIds = [];
let runningIds: any[] = [];
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else {
@@ -518,9 +518,11 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
</div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
await initPerfCharts();
// Event delegation for scene preset cards (attached once, works across innerHTML refreshes)
initScenePresetDelegation(container);
} else {
const dynamic = container.querySelector('.dashboard-dynamic');
if (dynamic.innerHTML !== dynamicHtml) {
if (dynamic && dynamic.innerHTML !== dynamicHtml) {
dynamic.innerHTML = dynamicHtml;
}
}
@@ -743,7 +745,7 @@ export async function dashboardStopAll(): Promise<void> {
if (!confirmed) return;
try {
const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/output-targets/batch/states'),
]);
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
@@ -804,7 +806,7 @@ function _isDashboardActive(): boolean {
return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard';
}
let _eventDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let _eventDebounceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
function _debouncedDashboardReload(forceFullRender: boolean = false): void {
if (!_isDashboardActive()) return;
clearTimeout(_eventDebounceTimer);

View File

@@ -215,7 +215,7 @@ export async function turnOffDevice(deviceId: any) {
}
export async function pingDevice(deviceId: any) {
const btn = document.querySelector(`[data-device-id="${deviceId}"] .card-ping-btn`) as HTMLElement | null;
const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null;
if (btn) btn.classList.add('spinning');
try {
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
@@ -403,7 +403,7 @@ export async function showSettings(deviceId: any) {
const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url);
// Set zone mode radio from device
const savedMode = device.zone_mode || 'combined';
const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${savedMode}"]`) as HTMLInputElement | null;
const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${CSS.escape(savedMode)}"]`) as HTMLInputElement | null;
if (modeRadio) modeRadio.checked = true;
_fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => {
// Re-snapshot after zones are loaded so dirty-check baseline includes them
@@ -536,7 +536,7 @@ export async function saveDeviceSettings() {
// Brightness
export function updateBrightnessLabel(deviceId: any, value: any) {
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`) as HTMLElement | null;
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
}
@@ -569,13 +569,13 @@ export async function fetchDeviceBrightness(deviceId: any) {
if (!resp.ok) return;
const data = await resp.json();
updateDeviceBrightness(deviceId, data.brightness);
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`) as HTMLInputElement | null;
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
if (slider) {
slider.value = data.brightness;
slider.title = Math.round(data.brightness / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`) as HTMLElement | null;
const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
if (wrap) wrap.classList.remove('brightness-loading');
} catch (err) {
// Silently fail — device may be offline
@@ -731,10 +731,10 @@ export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) {
}
function _applyZoneCounts(deviceId: any, zones: any, counts: any) {
const card = document.querySelector(`[data-device-id="${deviceId}"]`);
const card = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"]`);
if (!card) return;
for (const zoneName of zones) {
const badge = card.querySelector(`[data-zone-name="${zoneName}"]`);
const badge = card.querySelector(`[data-zone-name="${CSS.escape(zoneName)}"]`);
if (!badge) continue;
const ledCount = counts[zoneName.toLowerCase()];
if (ledCount != null) {

View File

@@ -24,7 +24,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
set_displayPickerCallback(callback);
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
_pickerEngineType = engineType || null;
const lightbox = document.getElementById('display-picker-lightbox');
const lightbox = document.getElementById('display-picker-lightbox')!;
// Use "Select a Device" title for engines with own display lists (camera, scrcpy, etc.)
const titleEl = lightbox.querySelector('.display-picker-title');
@@ -41,7 +41,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
const canvas = document.getElementById('display-picker-canvas');
const canvas = document.getElementById('display-picker-canvas')!;
canvas.innerHTML = '<div class="loading-spinner"></div>';
displaysCache.fetch().then(displays => {
if (displays && displays.length > 0) {
@@ -55,7 +55,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
}
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
const canvas = document.getElementById('display-picker-canvas');
const canvas = document.getElementById('display-picker-canvas')!;
canvas.innerHTML = '<div class="loading-spinner"></div>';
try {
@@ -133,7 +133,7 @@ window._adbConnectFromPicker = async function () {
export function closeDisplayPicker(event?: Event): void {
if (event && event.target && (event.target as HTMLElement).closest('.display-picker-content')) return;
const lightbox = document.getElementById('display-picker-lightbox');
lightbox.classList.remove('active');
lightbox?.classList.remove('active');
set_displayPickerCallback(null);
_pickerEngineType = null;
}
@@ -150,7 +150,7 @@ export function selectDisplay(displayIndex: number): void {
}
export function renderDisplayPickerLayout(displays: any[], engineType: string | null = null): void {
const canvas = document.getElementById('display-picker-canvas');
const canvas = document.getElementById('display-picker-canvas')!;
if (!displays || displays.length === 0) {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;

View File

@@ -269,7 +269,7 @@ function _makeDraggable(el: HTMLElement, handle: HTMLElement, { loadFn, saveFn }
_clampElementInContainer(el, container);
}
let dragStart = null, dragStartPos = null;
let dragStart: { x: number; y: number } | null = null, dragStartPos: { left: number; top: number } | null = null;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
@@ -279,7 +279,7 @@ function _makeDraggable(el: HTMLElement, handle: HTMLElement, { loadFn, saveFn }
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', (e) => {
if (!dragStart) return;
if (!dragStart || !dragStartPos) return;
const cr = container.getBoundingClientRect();
const ew = el.offsetWidth, eh = el.offsetHeight;
let l = dragStartPos.left + (e.clientX - dragStart.x);
@@ -470,10 +470,10 @@ function _applyFilter(query?: string): void {
// Parse structured filters: type:device, tag:foo, running:true
let textPart = q;
const parsedKinds = new Set();
const parsedTags = [];
const parsedKinds = new Set<string>();
const parsedTags: string[] = [];
const tokens = q.split(/\s+/);
const plainTokens = [];
const plainTokens: string[] = [];
for (const tok of tokens) {
if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); }
else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); }
@@ -720,8 +720,8 @@ function _renderGraph(container: HTMLElement): void {
const nodeGroup = svgEl.querySelector('.graph-nodes') as SVGGElement;
const edgeGroup = svgEl.querySelector('.graph-edges') as SVGGElement;
renderEdges(edgeGroup, _edges);
renderNodes(nodeGroup, _nodeMap, {
renderEdges(edgeGroup, _edges!);
renderNodes(nodeGroup, _nodeMap!, {
onNodeClick: _onNodeClick,
onNodeDblClick: _onNodeDblClick,
onEditNode: _onEditNode,
@@ -732,14 +732,14 @@ function _renderGraph(container: HTMLElement): void {
onCloneNode: _onCloneNode,
onActivatePreset: _onActivatePreset,
});
markOrphans(nodeGroup, _nodeMap, _edges);
markOrphans(nodeGroup, _nodeMap!, _edges!);
// Animated flow dots for running nodes
const runningIds = new Set<string>();
for (const node of _nodeMap.values()) {
for (const node of _nodeMap!.values()) {
if (node.running) runningIds.add(node.id);
}
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
// Set bounds for view clamping, then fit
if (_bounds) _canvas.setBounds(_bounds);
@@ -889,6 +889,8 @@ function _renderGraph(container: HTMLElement): void {
}
});
// Remove previous keydown listener to prevent leaks on re-render
container.removeEventListener('keydown', _onKeydown);
container.addEventListener('keydown', _onKeydown);
container.setAttribute('tabindex', '0');
container.style.outline = 'none';
@@ -1039,8 +1041,9 @@ function _initLegendDrag(legendEl: Element | null): void {
/* ── Minimap (draggable header & resize handle) ── */
function _initMinimap(mmEl: HTMLElement | null): void {
if (!mmEl || !_nodeMap || !_bounds) return;
function _initMinimap(mmElArg: HTMLElement | null): void {
if (!mmElArg || !_nodeMap || !_bounds) return;
const mmEl: HTMLElement = mmElArg;
const svg = mmEl.querySelector('svg') as SVGSVGElement | null;
if (!svg) return;
const container = mmEl.closest('.graph-container') as HTMLElement;
@@ -1108,7 +1111,7 @@ function _initMinimap(mmEl: HTMLElement | null): void {
function _initResizeHandle(rh: HTMLElement | null, corner: string): void {
if (!rh) return;
let rs = null, rss = null;
let rs: { x: number; y: number } | null = null, rss: { w: number; h: number; left: number } | null = null;
rh.addEventListener('pointerdown', (e) => {
e.preventDefault(); e.stopPropagation();
rs = { x: e.clientX, y: e.clientY };
@@ -1116,7 +1119,7 @@ function _initMinimap(mmEl: HTMLElement | null): void {
rh.setPointerCapture(e.pointerId);
});
rh.addEventListener('pointermove', (e) => {
if (!rs) return;
if (!rs || !rss) return;
const cr = container.getBoundingClientRect();
const dy = e.clientY - rs.y;
const newH = Math.max(80, Math.min(cr.height - 20, rss.h + dy));
@@ -1274,7 +1277,7 @@ function _initToolbarDrag(tbEl: HTMLElement | null): void {
const dock = saved?.dock || 'tl';
_applyToolbarDock(tbEl, container, dock, false);
let dragStart = null, dragStartPos = null;
let dragStart: { x: number; y: number } | null = null, dragStartPos: { left: number; top: number } | null = null;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
@@ -1289,7 +1292,7 @@ function _initToolbarDrag(tbEl: HTMLElement | null): void {
});
});
handle.addEventListener('pointermove', (e) => {
if (!dragStart) return;
if (!dragStart || !dragStartPos) return;
const cr = container.getBoundingClientRect();
const ew = tbEl.offsetWidth, eh = tbEl.offsetHeight;
let l = dragStartPos.left + (e.clientX - dragStart.x);
@@ -1410,7 +1413,7 @@ async function _bulkDeleteSelected(): Promise<void> {
);
if (!ok) return;
for (const id of _selectedIds) {
const node = _nodeMap.get(id);
const node = _nodeMap?.get(id);
if (node) _onDeleteNode(node);
}
_selectedIds.clear();
@@ -1506,10 +1509,12 @@ function _updateNodeRunning(nodeId: string, running: boolean): void {
// Update flow dots since running set changed
if (edgeGroup) {
const runningIds = new Set<string>();
for (const n of _nodeMap.values()) {
if (n.running) runningIds.add(n.id);
if (_nodeMap) {
for (const n of _nodeMap.values()) {
if (n.running) runningIds.add(n.id);
}
}
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
}
}
@@ -1559,7 +1564,7 @@ function _onKeydown(e: KeyboardEvent): void {
_detachSelectedEdge();
} else if (_selectedIds.size === 1) {
const nodeId = [..._selectedIds][0];
const node = _nodeMap.get(nodeId);
const node = _nodeMap?.get(nodeId);
if (node) _onDeleteNode(node);
} else if (_selectedIds.size > 1) {
_bulkDeleteSelected();
@@ -1614,13 +1619,13 @@ function _navigateDirection(dir: string): void {
if (!_nodeMap || _nodeMap.size === 0) return;
// Get current anchor node
let anchor = null;
let anchor: any = null;
if (_selectedIds.size === 1) {
anchor = _nodeMap.get([..._selectedIds][0]);
}
if (!anchor) {
// Select first visible node (topmost-leftmost)
let best = null;
let best: any = null;
for (const n of _nodeMap.values()) {
if (!best || n.y < best.y || (n.y === best.y && n.x < best.x)) best = n;
}
@@ -1638,7 +1643,7 @@ function _navigateDirection(dir: string): void {
const cx = anchor.x + anchor.width / 2;
const cy = anchor.y + anchor.height / 2;
let bestNode = null;
let bestNode: any = null;
let bestDist = Infinity;
for (const n of _nodeMap.values()) {
@@ -1702,8 +1707,8 @@ function _selectAll(): void {
/* ── Edge click ── */
function _onEdgeClick(edgePath: Element, nodeGroup: SVGGElement | null, edgeGroup: SVGGElement | null): void {
const fromId = edgePath.getAttribute('data-from');
const toId = edgePath.getAttribute('data-to');
const fromId = edgePath.getAttribute('data-from') ?? '';
const toId = edgePath.getAttribute('data-to') ?? '';
const field = edgePath.getAttribute('data-field') || '';
// Track selected edge for Delete key detach
@@ -1819,10 +1824,10 @@ function _onDragPointerMove(e: PointerEvent): void {
node.x = item.startX + gdx;
node.y = item.startY + gdy;
if (item.el) item.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap, _edges);
if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap!, _edges!);
_updateMinimapNode(item.id, node);
}
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null, _nodeMap, _edges);
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null as any, _nodeMap!, _edges!);
} else {
const ds = _dragState as DragStateSingle;
const node = _nodeMap!.get(ds.nodeId);
@@ -1831,8 +1836,8 @@ function _onDragPointerMove(e: PointerEvent): void {
node.y = ds.startNode.y + gdy;
ds.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
if (edgeGroup) {
updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap, _edges);
updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap, _edges);
updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!);
updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!);
}
_updateMinimapNode(ds.nodeId, node);
}
@@ -1867,7 +1872,7 @@ function _onDragPointerUp(): void {
if (edgeGroup && _edges && _nodeMap) {
const runningIds = new Set<string>();
for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); }
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
}
}
@@ -1886,7 +1891,7 @@ function _initRubberBand(svgEl: SVGSVGElement): void {
e.preventDefault();
_rubberBand = {
startGraph: _canvas.screenToGraph(e.clientX, e.clientY),
startGraph: _canvas!.screenToGraph(e.clientX, e.clientY),
startClient: { x: e.clientX, y: e.clientY },
active: false,
};
@@ -1930,10 +1935,10 @@ function _onRubberBandUp(): void {
const rect = document.querySelector('.graph-selection-rect') as SVGElement | null;
if (_rubberBand.active && rect && _nodeMap) {
const rx = parseFloat(rect.getAttribute('x'));
const ry = parseFloat(rect.getAttribute('y'));
const rw = parseFloat(rect.getAttribute('width'));
const rh = parseFloat(rect.getAttribute('height'));
const rx = parseFloat(rect.getAttribute('x') ?? '0');
const ry = parseFloat(rect.getAttribute('y') ?? '0');
const rw = parseFloat(rect.getAttribute('width') ?? '0');
const rh = parseFloat(rect.getAttribute('height') ?? '0');
_selectedIds.clear();
for (const node of _nodeMap.values()) {
@@ -2014,9 +2019,9 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
e.stopPropagation();
e.preventDefault();
const sourceNodeId = port.getAttribute('data-node-id');
const sourceKind = port.getAttribute('data-node-kind');
const portType = port.getAttribute('data-port-type');
const sourceNodeId = port.getAttribute('data-node-id') ?? '';
const sourceKind = port.getAttribute('data-node-kind') ?? '';
const portType = port.getAttribute('data-port-type') ?? '';
const sourceNode = _nodeMap?.get(sourceNodeId);
if (!sourceNode) return;
@@ -2029,7 +2034,7 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
const dragPath = document.createElementNS(SVG_NS, 'path');
dragPath.setAttribute('class', 'graph-drag-edge');
dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
const root = svgEl.querySelector('.graph-root');
const root = svgEl.querySelector('.graph-root')!;
root.appendChild(dragPath);
_connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath };
@@ -2108,9 +2113,9 @@ function _onConnectPointerUp(e: PointerEvent): void {
const elem = document.elementFromPoint(e.clientX, e.clientY);
const targetPort = elem?.closest?.('.graph-port-in');
if (targetPort) {
const targetNodeId = targetPort.getAttribute('data-node-id');
const targetKind = targetPort.getAttribute('data-node-kind');
const targetPortType = targetPort.getAttribute('data-port-type');
const targetNodeId = targetPort.getAttribute('data-node-id') ?? '';
const targetKind = targetPort.getAttribute('data-node-kind') ?? '';
const targetPortType = targetPort.getAttribute('data-port-type') ?? '';
if (targetNodeId !== sourceNodeId) {
// Find the matching connection
@@ -2143,8 +2148,8 @@ async function _doConnect(targetId: string, targetKind: string, field: string, s
/* ── Undo / Redo ── */
const _undoStack = [];
const _redoStack = [];
const _undoStack: UndoAction[] = [];
const _redoStack: UndoAction[] = [];
const _MAX_UNDO = 30;
/** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */
@@ -2167,7 +2172,7 @@ export async function graphRedo(): Promise<void> { await _redo(); }
async function _undo(): Promise<void> {
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; }
const action = _undoStack.pop();
const action = _undoStack.pop()!;
try {
await action.undo();
_redoStack.push(action);
@@ -2182,7 +2187,7 @@ async function _undo(): Promise<void> {
async function _redo(): Promise<void> {
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; }
const action = _redoStack.pop();
const action = _redoStack.pop()!;
try {
await action.redo();
_undoStack.push(action);
@@ -2201,7 +2206,7 @@ let _helpVisible = false;
function _loadHelpPos(): AnchoredRect | null {
try {
const saved = JSON.parse(localStorage.getItem('graph_help_pos'));
const saved = JSON.parse(localStorage.getItem('graph_help_pos')!);
return saved || { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 };
} catch { return { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 }; }
}
@@ -2265,7 +2270,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
const field = edgePath.getAttribute('data-field') || '';
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
const toId = edgePath.getAttribute('data-to');
const toId = edgePath.getAttribute('data-to') ?? '';
const toNode = _nodeMap?.get(toId);
if (!toNode) return;
@@ -2289,7 +2294,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
});
menu.appendChild(btn);
container.querySelector('.graph-container').appendChild(menu);
container.querySelector('.graph-container')!.appendChild(menu);
_edgeContextMenu = menu;
}
@@ -2318,11 +2323,11 @@ async function _detachSelectedEdge(): Promise<void> {
let _hoverTooltip: HTMLDivElement | null = null; // the <div> element, created once per graph render
let _hoverTooltipChart: any = null; // Chart.js instance
let _hoverTimer: ReturnType<typeof setTimeout> | null = null; // 300ms delay timer
let _hoverPollInterval: ReturnType<typeof setInterval> | null = null; // 1s polling interval
let _hoverTimer: ReturnType<typeof setTimeout> | undefined = undefined; // 300ms delay timer
let _hoverPollInterval: ReturnType<typeof setInterval> | undefined = undefined; // 1s polling interval
let _hoverNodeId: string | null = null; // currently shown node id
let _hoverFpsHistory = []; // rolling fps_actual samples
let _hoverFpsCurrentHistory = []; // rolling fps_current samples
let _hoverFpsHistory: number[] = []; // rolling fps_actual samples
let _hoverFpsCurrentHistory: number[] = []; // rolling fps_current samples
const HOVER_DELAY_MS = 300;
const HOVER_HISTORY_LEN = 20;
@@ -2374,7 +2379,7 @@ function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement):
if (related && nodeEl.contains(related)) return;
clearTimeout(_hoverTimer);
_hoverTimer = null;
_hoverTimer = undefined;
const nodeId = nodeEl.getAttribute('data-id');
if (nodeId === _hoverNodeId) {
@@ -2384,7 +2389,7 @@ function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement):
}
function _positionTooltip(_nodeEl: Element | null, container: HTMLElement): void {
if (!_canvas || !_hoverTooltip) return;
if (!_canvas || !_hoverTooltip || !_hoverNodeId) return;
const node = _nodeMap?.get(_hoverNodeId);
if (!node) return;
@@ -2467,7 +2472,7 @@ async function _showNodeTooltip(nodeId: string, nodeEl: Element, container: HTML
function _hideNodeTooltip(): void {
clearInterval(_hoverPollInterval);
_hoverPollInterval = null;
_hoverPollInterval = undefined;
_hoverNodeId = null;
if (_hoverTooltipChart) {
@@ -2478,7 +2483,7 @@ function _hideNodeTooltip(): void {
_hoverTooltip.classList.remove('gnt-fade-in');
_hoverTooltip.classList.add('gnt-fade-out');
_hoverTooltip.addEventListener('animationend', () => {
if (_hoverTooltip.classList.contains('gnt-fade-out')) {
if (_hoverTooltip?.classList.contains('gnt-fade-out')) {
_hoverTooltip.style.display = 'none';
}
}, { once: true });
@@ -2506,6 +2511,7 @@ async function _fetchTooltipMetrics(nodeId: string, container: HTMLElement, node
const uptimeSec = metrics.uptime_seconds ?? 0;
// Update text rows
if (!_hoverTooltip) return;
const fpsEl = _hoverTooltip.querySelector('[data-gnt="fps"]');
const errorsEl = _hoverTooltip.querySelector('[data-gnt="errors"]');
const uptimeEl = _hoverTooltip.querySelector('[data-gnt="uptime"]');

View File

@@ -141,7 +141,7 @@ function _ensureBrightnessEntitySelect() {
}
export function patchKCTargetMetrics(target: any) {
const card = document.querySelector(`[data-kc-target-id="${target.id}"]`);
const card = document.querySelector(`[data-kc-target-id="${CSS.escape(target.id)}"]`);
if (!card) return;
const state = target.state || {};
const metrics = target.metrics || {};
@@ -523,8 +523,8 @@ export async function showKCEditor(targetId: any = null, cloneData: any = null)
try {
// Load sources, pattern templates, and value sources in parallel
const [sources, patTemplates, valueSources] = await Promise.all([
streamsCache.fetch().catch(() => []),
patternTemplatesCache.fetch().catch(() => []),
streamsCache.fetch().catch((): any[] => []),
patternTemplatesCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch(),
]);
@@ -751,7 +751,7 @@ export async function deleteKCTarget(targetId: any) {
// ===== KC BRIGHTNESS =====
export function updateKCBrightnessLabel(targetId: any, value: any) {
const slider = document.querySelector(`[data-kc-brightness="${targetId}"]`) as HTMLElement;
const slider = document.querySelector(`[data-kc-brightness="${CSS.escape(targetId)}"]`) as HTMLElement;
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
}

View File

@@ -87,7 +87,7 @@ export function createPatternTemplateCard(pt: PatternTemplate) {
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null): Promise<void> {
try {
// Load sources for background capture
const sources = await streamsCache.fetch().catch(() => []);
const sources = await streamsCache.fetch().catch((): any[] => []);
const bgSelect = document.getElementById('pattern-bg-source') as HTMLSelectElement;
bgSelect.innerHTML = '';
@@ -116,7 +116,7 @@ export async function showPatternTemplateEditor(templateId: string | null = null
setPatternEditorSelectedIdx(-1);
setPatternCanvasDragMode(null);
let _editorTags = [];
let _editorTags: string[] = [];
if (templateId) {
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
@@ -340,7 +340,7 @@ export function removePatternRect(index: number): void {
export function renderPatternCanvas(): void {
const canvas = document.getElementById('pattern-canvas') as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const w = canvas.width;
const h = canvas.height;
@@ -396,8 +396,8 @@ export function renderPatternCanvas(): void {
ctx.strokeRect(rx, ry, rw, rh);
// Edge highlight
let edgeDir = null;
if (isDragging && patternCanvasDragMode.startsWith('resize-')) {
let edgeDir: string | null = null;
if (isDragging && patternCanvasDragMode?.startsWith('resize-')) {
edgeDir = patternCanvasDragMode.replace('resize-', '');
} else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') {
edgeDir = patternEditorHoverHit;
@@ -586,16 +586,16 @@ function _patternCanvasDragMove(e: MouseEvent | { clientX: number; clientY: numb
const mx = (e.clientX - canvasRect.left) * scaleX;
const my = (e.clientY - canvasRect.top) * scaleY;
const dx = (mx - patternCanvasDragStart.mx) / w;
const dy = (my - patternCanvasDragStart.my) / h;
const orig = patternCanvasDragOrigRect;
const dx = (mx - patternCanvasDragStart!.mx!) / w;
const dy = (my - patternCanvasDragStart!.my!) / h;
const orig = patternCanvasDragOrigRect!;
const r = patternEditorRects[patternEditorSelectedIdx];
if (patternCanvasDragMode === 'move') {
r.x = Math.max(0, Math.min(1 - r.width, orig.x + dx));
r.y = Math.max(0, Math.min(1 - r.height, orig.y + dy));
} else if (patternCanvasDragMode.startsWith('resize-')) {
const dir = patternCanvasDragMode.replace('resize-', '');
} else if (patternCanvasDragMode?.startsWith('resize-')) {
const dir = patternCanvasDragMode!.replace('resize-', '');
let nx = orig.x, ny = orig.y, nw = orig.width, nh = orig.height;
if (dir.includes('w')) { nx = orig.x + dx; nw = orig.width - dx; }
if (dir.includes('e')) { nw = orig.width + dx; }
@@ -631,7 +631,7 @@ function _patternCanvasDragEnd(e: MouseEvent): void {
const my = (e.clientY - canvasRect.top) * scaleY;
let cursor = 'default';
let newHoverIdx = -1;
let newHoverHit = null;
let newHoverHit: string | null = null;
if (e.clientX >= canvasRect.left && e.clientX <= canvasRect.right &&
e.clientY >= canvasRect.top && e.clientY <= canvasRect.bottom) {
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
@@ -717,8 +717,8 @@ function _patternCanvasMouseDown(e: MouseEvent | { offsetX?: number; offsetY?: n
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
const mx = (e.offsetX !== undefined ? e.offsetX : (e.clientX ?? 0) - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : (e.clientY ?? 0) - rect.top) * scaleY;
// Check delete button on hovered or selected rects first
for (const idx of [patternEditorHoveredIdx, patternEditorSelectedIdx]) {
@@ -739,7 +739,7 @@ function _patternCanvasMouseDown(e: MouseEvent | { offsetX?: number; offsetY?: n
// Test all rects; selected rect takes priority so it stays interactive
// even when overlapping with others.
const selIdx = patternEditorSelectedIdx;
const testOrder = [];
const testOrder: number[] = [];
if (selIdx >= 0 && selIdx < patternEditorRects.length) testOrder.push(selIdx);
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
if (i !== selIdx) testOrder.push(i);
@@ -795,15 +795,15 @@ function _patternCanvasMouseMove(e: MouseEvent | { offsetX?: number; offsetY?: n
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
const mx = (e.offsetX !== undefined ? e.offsetX : (e.clientX ?? 0) - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : (e.clientY ?? 0) - rect.top) * scaleY;
let cursor = 'default';
let newHoverIdx = -1;
let newHoverHit = null;
let newHoverHit: string | null = null;
// Selected rect takes priority for hover so edges stay reachable under overlaps
const selIdx = patternEditorSelectedIdx;
const hoverOrder = [];
const hoverOrder: number[] = [];
if (selIdx >= 0 && selIdx < patternEditorRects.length) hoverOrder.push(selIdx);
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
if (i !== selIdx) hoverOrder.push(i);

View File

@@ -55,21 +55,21 @@ export function renderPerfSection(): string {
return `<div class="perf-charts-grid">
<div class="perf-chart-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), onPick: null, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-cpu-value">-</span>
</div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><canvas id="perf-chart-cpu"></canvas></div>
</div>
<div class="perf-chart-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), onPick: null, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-ram-value">-</span>
</div>
<div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div>
</div>
<div class="perf-chart-card" id="perf-gpu-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), onPick: null, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-gpu-value">-</span>
</div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-gpu-name"></span><canvas id="perf-chart-gpu"></canvas></div>

View File

@@ -15,10 +15,11 @@ import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../c
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { cardColorStyle, cardColorButton } from '../core/card-colors.ts';
import { EntityPalette } from '../core/entity-palette.ts';
import { navigateToCard } from '../core/navigation.ts';
import type { ScenePreset } from '../types.ts';
let _editingId: string | null = null;
let _allTargets = []; // fetched on capture open
let _allTargets: any[] = []; // fetched on capture open
let _sceneTagsInput: TagInput | null = null;
class ScenePresetEditorModal extends Modal {
@@ -76,7 +77,7 @@ export function createSceneCard(preset: ScenePreset) {
const colorStyle = cardColorStyle(preset.id);
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}>
<div class="card-top-actions">
<button class="card-remove-btn" onclick="deleteScenePreset('${preset.id}', '${escapeHtml(preset.name)}')" title="${t('scenes.delete')}">&#x2715;</button>
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">&#x2715;</button>
</div>
<div class="card-header">
<div class="card-title" title="${escapeHtml(preset.name)}">${escapeHtml(preset.name)}</div>
@@ -88,10 +89,10 @@ export function createSceneCard(preset: ScenePreset) {
</div>
${renderTagChips(preset.tags)}
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="cloneScenePreset('${preset.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editScenePreset('${preset.id}')" title="${t('scenes.edit')}">${ICON_EDIT}</button>
<button class="btn btn-icon btn-secondary" onclick="recaptureScenePreset('${preset.id}')" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
<button class="btn btn-icon btn-success" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
<button class="btn btn-icon btn-secondary" data-action="clone-scene" data-id="${preset.id}" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit-scene" data-id="${preset.id}" title="${t('scenes.edit')}">${ICON_EDIT}</button>
<button class="btn btn-icon btn-secondary" data-action="recapture-scene" data-id="${preset.id}" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
<button class="btn btn-icon btn-success" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
${cardColorButton(preset.id, 'data-scene-id')}
</div>
</div>`;
@@ -106,7 +107,7 @@ export async function loadScenePresets(): Promise<ScenePreset[]> {
export function renderScenePresetsSection(presets: ScenePreset[]): string | { headerExtra: string; content: string } {
if (!presets || presets.length === 0) return '';
const captureBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); openScenePresetCapture()" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
const captureBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" data-action="capture-scene" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
const cards = presets.map(p => _renderDashboardPresetCard(p)).join('');
return { headerExtra: captureBtn, content: `<div class="dashboard-autostart-grid">${cards}</div>` };
@@ -120,7 +121,7 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
].filter(Boolean).join(' \u00b7 ');
const pStyle = cardColorStyle(preset.id);
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'scenes','data-scene-id','${preset.id}')}"${pStyle ? ` style="${pStyle}"` : ''}>
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" data-action="navigate-scene" data-id="${preset.id}"${pStyle ? ` style="${pStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_SCENE}</span>
<div>
@@ -130,7 +131,7 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn start" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
<button class="dashboard-action-btn start" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
</div>
</div>`;
}
@@ -155,7 +156,7 @@ export async function openScenePresetCapture(): Promise<void> {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
_allTargets = await outputTargetsCache.fetch().catch(() => []);
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
_refreshTargetSelect();
} catch { /* ignore */ }
}
@@ -190,7 +191,7 @@ export async function editScenePreset(presetId: string): Promise<void> {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
_allTargets = await outputTargetsCache.fetch().catch(() => []);
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
// Pre-add targets already in the preset
const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
@@ -200,7 +201,7 @@ export async function editScenePreset(presetId: string): Promise<void> {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = tid;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
@@ -294,7 +295,7 @@ function _addTargetToList(targetId: string, targetName: string): void {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = targetId;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
list.appendChild(item);
_refreshTargetSelect();
}
@@ -320,10 +321,7 @@ export async function addSceneTarget(): Promise<void> {
if (tgt) _addTargetToList(tgt.id, tgt.name);
}
export function removeSceneTarget(btn: HTMLElement): void {
btn.closest('.scene-target-item').remove();
_refreshTargetSelect();
}
// removeSceneTarget is now handled via event delegation on the modal
// ===== Activate =====
@@ -403,7 +401,7 @@ export async function cloneScenePreset(presetId: string): Promise<void> {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
_allTargets = await outputTargetsCache.fetch().catch(() => []);
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
// Pre-add targets from the cloned preset
const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
@@ -413,7 +411,7 @@ export async function cloneScenePreset(presetId: string): Promise<void> {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = tid;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
@@ -456,6 +454,57 @@ export async function deleteScenePreset(presetId: string): Promise<void> {
}
}
// ===== Event delegation for scene preset card actions =====
const _sceneCardActions: Record<string, (id: string) => void> = {
'delete-scene': deleteScenePreset,
'clone-scene': cloneScenePreset,
'edit-scene': editScenePreset,
'recapture-scene': recaptureScenePreset,
'activate-scene': activateScenePreset,
};
export function initScenePresetDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action) return;
if (action === 'capture-scene') {
e.stopPropagation();
openScenePresetCapture();
return;
}
if (action === 'navigate-scene') {
// Only navigate if click wasn't on a child button
if ((e.target as HTMLElement).closest('button')) return;
navigateToCard('automations', null, 'scenes', 'data-scene-id', id);
return;
}
if (action === 'remove-scene-target') {
const item = btn.closest('.scene-target-item');
if (item) {
item.remove();
_refreshTargetSelect();
}
return;
}
if (!id) return;
const handler = _sceneCardActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ===== Helpers =====
function _reloadScenesTab(): void {
@@ -466,3 +515,18 @@ function _reloadScenesTab(): void {
// Also refresh dashboard (scene presets section)
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
}
// ===== Modal event delegation (for target list remove buttons) =====
const _sceneEditorModal = document.getElementById('scene-preset-editor-modal');
if (_sceneEditorModal) {
_sceneEditorModal.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action="remove-scene-target"]');
if (!btn) return;
const item = btn.closest('.scene-target-item');
if (item) {
item.remove();
_refreshTargetSelect();
}
});
}

View File

@@ -0,0 +1,548 @@
/**
* Streams — Audio template CRUD, engine config, test modal.
* Extracted from streams.ts to reduce file size.
*/
import {
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
_cachedAudioTemplates,
audioTemplatesCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, setupBackdropClose } from '../core/ui.ts';
import {
getAudioEngineIcon,
ICON_AUDIO_TEMPLATE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { loadPictureSources } from './streams.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── TagInput instance for audio template modal ──
let _audioTemplateTagsInput: TagInput | null = null;
class AudioTemplateModal extends Modal {
constructor() { super('audio-template-modal'); }
snapshotValues() {
const vals: any = {
name: (document.getElementById('audio-template-name') as HTMLInputElement).value,
description: (document.getElementById('audio-template-description') as HTMLInputElement).value,
engine: (document.getElementById('audio-template-engine') as HTMLSelectElement).value,
tags: JSON.stringify(_audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : []),
};
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach((field: any) => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
setCurrentEditingAudioTemplateId(null);
set_audioTemplateNameManuallyEdited(false);
}
}
const audioTemplateModal = new AudioTemplateModal();
// ===== Audio Templates =====
async function loadAvailableAudioEngines() {
try {
const response = await fetchWithAuth('/audio-engines');
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
const data = await response.json();
setAvailableAudioEngines(data.engines || []);
const select = document.getElementById('audio-template-engine') as HTMLSelectElement;
select.innerHTML = '';
availableAudioEngines.forEach((engine: any) => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = `${engine.type.toUpperCase()}`;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('audio_template.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableAudioEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
// Update icon-grid selector with dynamic engine list
const items = availableAudioEngines
.filter(e => e.available)
.map(e => ({ value: e.type, icon: getAudioEngineIcon(e.type), label: e.type.toUpperCase(), desc: '' }));
if (_audioEngineIconSelect) { _audioEngineIconSelect.updateItems(items); }
else { _audioEngineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
_audioEngineIconSelect.setValue(select.value);
} catch (error) {
console.error('Error loading audio engines:', error);
showToast(t('audio_template.error.engines') + ': ' + error.message, 'error');
}
}
let _audioEngineIconSelect: IconSelect | null = null;
export async function onAudioEngineChange() {
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
if (_audioEngineIconSelect) _audioEngineIconSelect.setValue(engineType);
const configSection = document.getElementById('audio-engine-config-section')!;
const configFields = document.getElementById('audio-engine-config-fields')!;
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableAudioEngines.find((e: any) => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_audioTemplateNameManuallyEdited && !(document.getElementById('audio-template-id') as HTMLInputElement).value) {
(document.getElementById('audio-template-name') as HTMLInputElement).value = engine.type.toUpperCase();
}
const hint = document.getElementById('audio-engine-availability-hint')!;
if (!engine.available) {
hint.textContent = t('audio_template.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '<div class="config-grid">';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
gridHtml += `
<label class="config-grid-label" for="audio-config-${key}">${key}</label>
<div class="config-grid-value">
${typeof value === 'boolean' ? `
<select id="audio-config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : `
<input type="${fieldType}" id="audio-config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
</div>
`;
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
}
configSection.style.display = 'block';
}
function populateAudioEngineConfig(config: any) {
Object.entries(config).forEach(([key, value]: [string, any]) => {
const field = document.getElementById(`audio-config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
function collectAudioEngineConfig() {
const config: any = {};
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach((field: any) => {
const key = field.dataset.configKey;
let value: any = field.value;
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
async function loadAudioTemplates() {
try {
await audioTemplatesCache.fetch();
await loadPictureSources();
} catch (error) {
if (error.isAuth) return;
console.error('Error loading audio templates:', error);
showToast(t('audio_template.error.load'), 'error');
}
}
export async function showAddAudioTemplateModal(cloneData: any = null) {
setCurrentEditingAudioTemplateId(null);
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.add')}`;
(document.getElementById('audio-template-form') as HTMLFormElement).reset();
(document.getElementById('audio-template-id') as HTMLInputElement).value = '';
document.getElementById('audio-engine-config-section')!.style.display = 'none';
document.getElementById('audio-template-error')!.style.display = 'none';
set_audioTemplateNameManuallyEdited(!!cloneData);
(document.getElementById('audio-template-name') as HTMLInputElement).oninput = () => { set_audioTemplateNameManuallyEdited(true); };
await loadAvailableAudioEngines();
if (cloneData) {
(document.getElementById('audio-template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('audio-template-description') as HTMLInputElement).value = cloneData.description || '';
(document.getElementById('audio-template-engine') as HTMLSelectElement).value = cloneData.engine_type;
await onAudioEngineChange();
populateAudioEngineConfig(cloneData.engine_config);
}
// Tags
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
_audioTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
audioTemplateModal.open();
audioTemplateModal.snapshot();
}
export async function editAudioTemplate(templateId: any) {
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
const template = await response.json();
setCurrentEditingAudioTemplateId(templateId);
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`;
(document.getElementById('audio-template-id') as HTMLInputElement).value = templateId;
(document.getElementById('audio-template-name') as HTMLInputElement).value = template.name;
(document.getElementById('audio-template-description') as HTMLInputElement).value = template.description || '';
await loadAvailableAudioEngines();
(document.getElementById('audio-template-engine') as HTMLSelectElement).value = template.engine_type;
await onAudioEngineChange();
populateAudioEngineConfig(template.engine_config);
document.getElementById('audio-template-error')!.style.display = 'none';
// Tags
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
_audioTemplateTagsInput.setValue(template.tags || []);
audioTemplateModal.open();
audioTemplateModal.snapshot();
} catch (error: any) {
console.error('Error loading audio template:', error);
showToast(t('audio_template.error.load') + ': ' + error.message, 'error');
}
}
export async function closeAudioTemplateModal() {
await audioTemplateModal.close();
}
export async function saveAudioTemplate() {
const templateId = currentEditingAudioTemplateId;
const name = (document.getElementById('audio-template-name') as HTMLInputElement).value.trim();
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
if (!name || !engineType) {
showToast(t('audio_template.error.required'), 'error');
return;
}
const description = (document.getElementById('audio-template-description') as HTMLInputElement).value.trim();
const engineConfig = collectAudioEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save audio template');
}
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
audioTemplateModal.forceClose();
audioTemplatesCache.invalidate();
await loadAudioTemplates();
} catch (error) {
console.error('Error saving audio template:', error);
document.getElementById('audio-template-error')!.textContent = (error as any).message;
document.getElementById('audio-template-error')!.style.display = 'block';
}
}
export async function deleteAudioTemplate(templateId: any) {
const confirmed = await showConfirm(t('audio_template.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete audio template');
}
showToast(t('audio_template.deleted'), 'success');
audioTemplatesCache.invalidate();
await loadAudioTemplates();
} catch (error) {
console.error('Error deleting audio template:', error);
showToast(t('audio_template.error.delete') + ': ' + error.message, 'error');
}
}
export async function cloneAudioTemplate(templateId: any) {
try {
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load audio template');
const tmpl = await resp.json();
showAddAudioTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone audio template:', error);
showToast(t('audio_template.error.clone_failed'), 'error');
}
}
// ===== Audio Template Test =====
const NUM_BANDS_TPL = 64;
const TPL_PEAK_DECAY = 0.02;
const TPL_BEAT_FLASH_DECAY = 0.06;
let _tplTestWs: WebSocket | null = null;
let _tplTestAnimFrame: number | null = null;
let _tplTestLatest: any = null;
let _tplTestPeaks = new Float32Array(NUM_BANDS_TPL);
let _tplTestBeatFlash = 0;
let _currentTestAudioTemplateId: string | null = null;
const testAudioTemplateModal = new Modal('test-audio-template-modal', { backdrop: true, lock: true });
export async function showTestAudioTemplateModal(templateId: any) {
_currentTestAudioTemplateId = templateId;
// Find template's engine type so we show the correct device list
const template = _cachedAudioTemplates.find((t: any) => t.id === templateId);
const engineType = template ? template.engine_type : null;
// Load audio devices for picker — filter by engine type
const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement;
try {
const resp = await fetchWithAuth('/audio-devices');
if (resp.ok) {
const data = await resp.json();
// Use engine-specific device list if available, fall back to flat list
const devices = (engineType && data.by_engine && data.by_engine[engineType])
? data.by_engine[engineType]
: (data.devices || []);
deviceSelect.innerHTML = devices.map(d => {
const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
}
} catch {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
// Restore last used device
const lastDevice = localStorage.getItem('lastAudioTestDevice');
if (lastDevice) {
const opt = Array.from(deviceSelect.options).find((o: any) => o.value === lastDevice);
if (opt) deviceSelect.value = lastDevice;
}
// Reset visual state
document.getElementById('audio-template-test-canvas')!.style.display = 'none';
document.getElementById('audio-template-test-stats')!.style.display = 'none';
document.getElementById('audio-template-test-status')!.style.display = 'none';
document.getElementById('test-audio-template-start-btn')!.style.display = '';
_tplCleanupTest();
testAudioTemplateModal.open();
}
export function closeTestAudioTemplateModal() {
_tplCleanupTest();
testAudioTemplateModal.forceClose();
_currentTestAudioTemplateId = null;
}
export function startAudioTemplateTest() {
if (!_currentTestAudioTemplateId) return;
const deviceVal = (document.getElementById('test-audio-template-device') as HTMLSelectElement).value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':');
localStorage.setItem('lastAudioTestDevice', deviceVal);
// Show canvas + stats, hide run button, disable device picker
document.getElementById('audio-template-test-canvas')!.style.display = '';
document.getElementById('audio-template-test-stats')!.style.display = '';
document.getElementById('test-audio-template-start-btn')!.style.display = 'none';
(document.getElementById('test-audio-template-device') as HTMLSelectElement).disabled = true;
const statusEl = document.getElementById('audio-template-test-status')!;
statusEl.textContent = t('audio_source.test.connecting');
statusEl.style.display = '';
// Reset state
_tplTestLatest = null;
_tplTestPeaks.fill(0);
_tplTestBeatFlash = 0;
document.getElementById('audio-template-test-rms')!.textContent = '---';
document.getElementById('audio-template-test-peak')!.textContent = '---';
document.getElementById('audio-template-test-beat-dot')!.classList.remove('active');
// Size canvas
const canvas = document.getElementById('audio-template-test-canvas') as HTMLCanvasElement;
_tplSizeCanvas(canvas);
// Connect WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-templates/${_currentTestAudioTemplateId}/test/ws?token=${encodeURIComponent(apiKey)}&device_index=${devIdx}&is_loopback=${devLoop === '1' ? '1' : '0'}`;
try {
_tplTestWs = new WebSocket(wsUrl);
_tplTestWs.onopen = () => {
statusEl.style.display = 'none';
};
_tplTestWs.onmessage = (event) => {
try { _tplTestLatest = JSON.parse(event.data); } catch {}
};
_tplTestWs.onclose = () => { _tplTestWs = null; };
_tplTestWs.onerror = () => {
showToast(t('audio_source.test.error'), 'error');
_tplCleanupTest();
};
} catch {
showToast(t('audio_source.test.error'), 'error');
_tplCleanupTest();
return;
}
_tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop);
}
function _tplCleanupTest() {
if (_tplTestAnimFrame) {
cancelAnimationFrame(_tplTestAnimFrame);
_tplTestAnimFrame = null;
}
if (_tplTestWs) {
_tplTestWs.onclose = null;
_tplTestWs.close();
_tplTestWs = null;
}
_tplTestLatest = null;
// Re-enable device picker
const devSel = document.getElementById('test-audio-template-device') as HTMLSelectElement | null;
if (devSel) devSel.disabled = false;
}
function _tplSizeCanvas(canvas: HTMLCanvasElement) {
const rect = canvas.parentElement!.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d')!.scale(dpr, dpr);
}
function _tplRenderLoop() {
_tplRenderSpectrum();
if (testAudioTemplateModal.isOpen && _tplTestWs) {
_tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop);
}
}
function _tplRenderSpectrum() {
const canvas = document.getElementById('audio-template-test-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const data = _tplTestLatest;
if (!data || !data.spectrum) return;
const spectrum = data.spectrum;
const gap = 1;
const barWidth = (w - gap * (NUM_BANDS_TPL - 1)) / NUM_BANDS_TPL;
// Beat flash
if (data.beat) _tplTestBeatFlash = Math.min(1.0, data.beat_intensity + 0.3);
if (_tplTestBeatFlash > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${_tplTestBeatFlash * 0.08})`;
ctx.fillRect(0, 0, w, h);
_tplTestBeatFlash = Math.max(0, _tplTestBeatFlash - TPL_BEAT_FLASH_DECAY);
}
for (let i = 0; i < NUM_BANDS_TPL; i++) {
const val = Math.min(1, spectrum[i]);
const barHeight = val * h;
const x = i * (barWidth + gap);
const y = h - barHeight;
const hue = (1 - val) * 120;
ctx.fillStyle = `hsl(${hue}, 85%, 50%)`;
ctx.fillRect(x, y, barWidth, barHeight);
if (val > _tplTestPeaks[i]) {
_tplTestPeaks[i] = val;
} else {
_tplTestPeaks[i] = Math.max(0, _tplTestPeaks[i] - TPL_PEAK_DECAY);
}
const peakY = h - _tplTestPeaks[i] * h;
const peakHue = (1 - _tplTestPeaks[i]) * 120;
ctx.fillStyle = `hsl(${peakHue}, 90%, 70%)`;
ctx.fillRect(x, peakY, barWidth, 2);
}
document.getElementById('audio-template-test-rms')!.textContent = (data.rms * 100).toFixed(1) + '%';
document.getElementById('audio-template-test-peak')!.textContent = (data.peak * 100).toFixed(1) + '%';
const beatDot = document.getElementById('audio-template-test-beat-dot')!;
if (data.beat) {
beatDot.classList.add('active');
} else {
beatDot.classList.remove('active');
}
}

View File

@@ -0,0 +1,585 @@
/**
* Streams — Capture template CRUD, engine config, test modal.
* Extracted from streams.ts to reduce file size.
*/
import {
availableEngines, setAvailableEngines,
currentEditingTemplateId, setCurrentEditingTemplateId,
_templateNameManuallyEdited, set_templateNameManuallyEdited,
currentTestingTemplate, setCurrentTestingTemplate,
_cachedStreams, _cachedDisplays,
captureTemplatesCache, displaysCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts';
import { openDisplayPicker, formatDisplayLabel } from './displays.ts';
import {
getEngineIcon,
ICON_CAPTURE_TEMPLATE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { loadPictureSources } from './streams.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── TagInput instance for capture template modal ──
let _captureTemplateTagsInput: TagInput | null = null;
class CaptureTemplateModal extends Modal {
constructor() { super('template-modal'); }
snapshotValues() {
const vals: any = {
name: (document.getElementById('template-name') as HTMLInputElement).value,
description: (document.getElementById('template-description') as HTMLInputElement).value,
engine: (document.getElementById('template-engine') as HTMLSelectElement).value,
tags: JSON.stringify(_captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : []),
};
document.querySelectorAll('[data-config-key]').forEach((field: any) => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
setCurrentEditingTemplateId(null);
set_templateNameManuallyEdited(false);
}
}
const templateModal = new CaptureTemplateModal();
const testTemplateModal = new Modal('test-template-modal');
// ===== Capture Templates =====
async function loadCaptureTemplates() {
try {
await captureTemplatesCache.fetch();
await loadPictureSources();
} catch (error) {
if (error.isAuth) return;
console.error('Error loading capture templates:', error);
showToast(t('streams.error.load'), 'error');
}
}
export async function showAddTemplateModal(cloneData: any = null) {
setCurrentEditingTemplateId(null);
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.add')}`;
(document.getElementById('template-form') as HTMLFormElement).reset();
(document.getElementById('template-id') as HTMLInputElement).value = '';
document.getElementById('engine-config-section')!.style.display = 'none';
document.getElementById('template-error')!.style.display = 'none';
set_templateNameManuallyEdited(!!cloneData);
(document.getElementById('template-name') as HTMLInputElement).oninput = () => { set_templateNameManuallyEdited(true); };
await loadAvailableEngines();
// Pre-fill from clone data after engines are loaded
if (cloneData) {
(document.getElementById('template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('template-description') as HTMLInputElement).value = cloneData.description || '';
(document.getElementById('template-engine') as HTMLSelectElement).value = cloneData.engine_type;
await onEngineChange();
populateEngineConfig(cloneData.engine_config);
}
// Tags
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
_captureTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
templateModal.open();
templateModal.snapshot();
}
export async function editTemplate(templateId: any) {
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const template = await response.json();
setCurrentEditingTemplateId(templateId);
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
(document.getElementById('template-id') as HTMLInputElement).value = templateId;
(document.getElementById('template-name') as HTMLInputElement).value = template.name;
(document.getElementById('template-description') as HTMLInputElement).value = template.description || '';
await loadAvailableEngines();
(document.getElementById('template-engine') as HTMLSelectElement).value = template.engine_type;
await onEngineChange();
populateEngineConfig(template.engine_config);
await loadDisplaysForTest();
const testResults = document.getElementById('template-test-results');
if (testResults) testResults.style.display = 'none';
document.getElementById('template-error')!.style.display = 'none';
// Tags
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
_captureTemplateTagsInput.setValue(template.tags || []);
templateModal.open();
templateModal.snapshot();
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
}
}
export async function closeTemplateModal() {
await templateModal.close();
}
export function updateCaptureDuration(value: any) {
document.getElementById('test-template-duration-value')!.textContent = value;
localStorage.setItem('capture_duration', value);
}
function restoreCaptureDuration() {
const savedDuration = localStorage.getItem('capture_duration');
if (savedDuration) {
const durationInput = document.getElementById('test-template-duration') as HTMLInputElement;
const durationValue = document.getElementById('test-template-duration-value')!;
durationInput.value = savedDuration;
durationValue.textContent = savedDuration;
}
}
export async function showTestTemplateModal(templateId: any) {
try {
const templates = await captureTemplatesCache.fetch();
const template = templates.find(tp => tp.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
setCurrentTestingTemplate(template);
await loadDisplaysForTest();
restoreCaptureDuration();
testTemplateModal.open();
setupBackdropClose((testTemplateModal as any).el, () => closeTestTemplateModal());
} catch (error) {
if (error.isAuth) return;
showToast(t('templates.error.load'), 'error');
}
}
export function closeTestTemplateModal() {
testTemplateModal.forceClose();
setCurrentTestingTemplate(null);
}
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
const data = await response.json();
setAvailableEngines(data.engines || []);
const select = document.getElementById('template-engine') as HTMLSelectElement;
select.innerHTML = '';
availableEngines.forEach((engine: any) => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = engine.name;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('templates.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
// Update icon-grid selector with dynamic engine list
const items = availableEngines
.filter(e => e.available)
.map(e => ({ value: e.type, icon: getEngineIcon(e.type), label: e.name, desc: t(`templates.engine.${e.type}.desc`) }));
if (_engineIconSelect) { _engineIconSelect.updateItems(items); }
else { _engineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
_engineIconSelect.setValue(select.value);
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
}
}
let _engineIconSelect: IconSelect | null = null;
export async function onEngineChange() {
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
if (_engineIconSelect) _engineIconSelect.setValue(engineType);
const configSection = document.getElementById('engine-config-section')!;
const configFields = document.getElementById('engine-config-fields')!;
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableEngines.find((e: any) => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_templateNameManuallyEdited && !(document.getElementById('template-id') as HTMLInputElement).value) {
(document.getElementById('template-name') as HTMLInputElement).value = engine.name || engineType;
}
const hint = document.getElementById('engine-availability-hint')!;
if (!engine.available) {
hint.textContent = t('templates.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
// Known select options for specific config keys
const CONFIG_SELECT_OPTIONS = {
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
};
// IconSelect definitions for specific config keys
const CONFIG_ICON_SELECT = {
camera_backend: {
columns: 2,
items: [
{ value: 'auto', icon: _icon(P.refreshCw), label: 'Auto', desc: t('templates.config.camera_backend.auto') },
{ value: 'dshow', icon: _icon(P.camera), label: 'DShow', desc: t('templates.config.camera_backend.dshow') },
{ value: 'msmf', icon: _icon(P.film), label: 'MSMF', desc: t('templates.config.camera_backend.msmf') },
{ value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') },
],
},
};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '<div class="config-grid">';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
const selectOptions = CONFIG_SELECT_OPTIONS[key];
gridHtml += `
<label class="config-grid-label" for="config-${key}">${key}</label>
<div class="config-grid-value">
${typeof value === 'boolean' ? `
<select id="config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : selectOptions ? `
<select id="config-${key}" data-config-key="${key}">
${selectOptions.map(opt => `<option value="${opt}" ${opt === String(value) ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
` : `
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
</div>
`;
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
// Apply IconSelect to known config selects
for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) {
const sel = document.getElementById(`config-${key}`);
if (sel) new IconSelect({ target: sel as HTMLSelectElement, items: cfg.items, columns: cfg.columns });
}
}
configSection.style.display = 'block';
}
function populateEngineConfig(config: any) {
Object.entries(config).forEach(([key, value]: [string, any]) => {
const field = document.getElementById(`config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
function collectEngineConfig() {
const config: any = {};
const fields = document.querySelectorAll('[data-config-key]');
fields.forEach((field: any) => {
const key = field.dataset.configKey;
let value: any = field.value;
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
async function loadDisplaysForTest() {
try {
// Use engine-specific display list for engines with own devices (camera, scrcpy)
const engineType = currentTestingTemplate?.engine_type;
const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false;
const url = engineHasOwnDisplays
? `/config/displays?engine_type=${engineType}`
: '/config/displays';
// Always refetch for engines with own displays (devices may change); use cache for desktop
if (!_cachedDisplays || engineHasOwnDisplays) {
const response = await fetchWithAuth(url);
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
displaysCache.update(displaysData.displays || []);
}
let selectedIndex: number | null = null;
const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
if (lastDisplay !== null && _cachedDisplays) {
const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
if (found) selectedIndex = found.index;
}
if (selectedIndex === null && _cachedDisplays) {
const primary = _cachedDisplays.find(d => d.is_primary);
if (primary) selectedIndex = primary.index;
else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index;
}
if (selectedIndex !== null && _cachedDisplays) {
const display = _cachedDisplays.find(d => d.index === selectedIndex);
(window as any).onTestDisplaySelected(selectedIndex, display);
}
} catch (error) {
console.error('Error loading displays:', error);
}
}
export function runTemplateTest() {
if (!currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
const displayIndex = (document.getElementById('test-template-display') as HTMLSelectElement).value;
const captureDuration = parseFloat((document.getElementById('test-template-duration') as HTMLInputElement).value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
return;
}
const template = currentTestingTemplate;
localStorage.setItem('lastTestDisplayIndex', displayIndex);
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
_runTestViaWS(
'/capture-templates/test/ws',
{},
{
engine_type: template.engine_type,
engine_config: template.engine_config,
display_index: parseInt(displayIndex),
capture_duration: captureDuration,
preview_width: previewWidth,
},
captureDuration,
);
}
function buildTestStatsHtml(result: any) {
// Support both REST format (nested) and WS format (flat)
const p = result.performance || result;
const duration = p.capture_duration_s ?? p.elapsed_s ?? 0;
const frameCount = p.frame_count ?? 0;
const fps = p.actual_fps ?? p.fps ?? 0;
const avgMs = p.avg_capture_time_ms ?? p.avg_capture_ms ?? 0;
const w = result.full_capture?.width ?? result.width ?? 0;
const h = result.full_capture?.height ?? result.height ?? 0;
const res = `${w}x${h}`;
let html = `
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${Number(duration).toFixed(2)}s</strong></div>
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${frameCount}</strong></div>`;
if (frameCount > 1) {
html += `
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${Number(fps).toFixed(1)}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${Number(avgMs).toFixed(1)}ms</strong></div>`;
}
html += `
<div class="stat-item"><span>${t('templates.test.results.resolution')}</span> <strong>${res}</strong></div>`;
return html;
}
// ===== Shared WebSocket test helper =====
/**
* Run a capture test via WebSocket, streaming intermediate previews into
* the overlay spinner and opening the lightbox with the final result.
*
* @param {string} wsPath Relative WS path (e.g. '/picture-sources/{id}/test/ws')
* @param {Object} queryParams Extra query params (duration, source_stream_id, etc.)
* @param {Object|null} firstMessage If non-null, sent as JSON after WS opens (for template test)
* @param {number} duration Test duration for overlay progress ring
*/
export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessage: any = null, duration = 5) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Dynamic preview resolution: 80% of viewport width, scaled by DPR, capped at 1920px
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
const params = new URLSearchParams({ token: apiKey, preview_width: String(previewWidth), ...queryParams });
const wsUrl = `${protocol}//${window.location.host}${API_BASE}${wsPath}?${params}`;
showOverlaySpinner(t('streams.test.running'), duration);
let gotResult = false;
let ws;
try {
ws = new WebSocket(wsUrl);
} catch (e) {
hideOverlaySpinner();
showToast(t('streams.test.error.failed') + ': ' + e.message, 'error');
return;
}
// Close WS when user cancels overlay
const patchCloseBtn = () => {
const closeBtn = document.querySelector('.overlay-spinner-close') as HTMLElement | null;
if (closeBtn) {
const origHandler = closeBtn.onclick;
closeBtn.onclick = () => {
if (ws.readyState <= WebSocket.OPEN) ws.close();
if (origHandler) (origHandler as any)();
};
}
};
patchCloseBtn();
// Also close on ESC (overlay ESC handler calls hideOverlaySpinner which aborts)
const origAbort = window._overlayAbortController;
if (origAbort) {
origAbort.signal.addEventListener('abort', () => {
if (ws.readyState <= WebSocket.OPEN) ws.close();
}, { once: true });
}
ws.onopen = () => {
if (firstMessage) {
ws.send(JSON.stringify(firstMessage));
}
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'frame') {
updateOverlayPreview(msg.thumbnail, msg);
} else if (msg.type === 'result') {
gotResult = true;
hideOverlaySpinner();
(window as any).openLightbox(msg.full_image, buildTestStatsHtml(msg));
ws.close();
} else if (msg.type === 'error') {
hideOverlaySpinner();
showToast(msg.detail || 'Test failed', 'error');
ws.close();
}
} catch (e) {
console.error('Error parsing test WS message:', e);
}
};
ws.onerror = () => {
if (!gotResult) {
hideOverlaySpinner();
showToast(t('streams.test.error.failed'), 'error');
}
};
ws.onclose = () => {
if (!gotResult) {
hideOverlaySpinner();
}
};
}
export async function saveTemplate() {
const templateId = (document.getElementById('template-id') as HTMLInputElement).value;
const name = (document.getElementById('template-name') as HTMLInputElement).value.trim();
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
if (!name || !engineType) {
showToast(t('templates.error.required'), 'error');
return;
}
const description = (document.getElementById('template-description') as HTMLInputElement).value.trim();
const engineConfig = collectEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
templateModal.forceClose();
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
} catch (error) {
console.error('Error saving template:', error);
document.getElementById('template-error')!.textContent = (error as any).message;
document.getElementById('template-error')!.style.display = 'block';
}
}
export async function deleteTemplate(templateId: any) {
const confirmed = await showConfirm(t('templates.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('templates.deleted'), 'success');
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
} catch (error) {
console.error('Error deleting template:', error);
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -208,9 +208,7 @@ function _formatElapsed(seconds: number): string {
export function createSyncClockCard(clock: SyncClock) {
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
const toggleAction = clock.is_running
? `pauseSyncClock('${clock.id}')`
: `resumeSyncClock('${clock.id}')`;
const toggleAction = clock.is_running ? 'pause' : 'resume';
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null;
@@ -232,14 +230,46 @@ export function createSyncClockCard(clock: SyncClock) {
${renderTagChips(clock.tags)}
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); resetSyncClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneSyncClock('${clock.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editSyncClock('${clock.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
<button class="btn btn-icon btn-secondary" data-action="${toggleAction}" data-id="${clock.id}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
<button class="btn btn-icon btn-secondary" data-action="reset" data-id="${clock.id}" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
<button class="btn btn-icon btn-secondary" data-action="clone" data-id="${clock.id}" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" data-id="${clock.id}" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Expose to global scope for inline onclick handlers ──
// ── Event delegation for sync-clock card actions ──
const _syncClockActions: Record<string, (id: string) => void> = {
pause: pauseSyncClock,
resume: resumeSyncClock,
reset: resetSyncClock,
clone: cloneSyncClock,
edit: editSyncClock,
};
export function initSyncClockDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
// Only handle actions within a sync-clock card (data-id on card root)
const card = btn.closest<HTMLElement>('[data-id]');
const section = btn.closest<HTMLElement>('[data-card-section="sync-clocks"]');
if (!card || !section) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action || !id) return;
const handler = _syncClockActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ── Expose to global scope for HTML template onclick handlers & graph-editor ──
window.showSyncClockModal = showSyncClockModal;
window.closeSyncClockModal = closeSyncClockModal;

View File

@@ -115,9 +115,9 @@ document.addEventListener('languageChanged', () => {
// --- FPS sparkline history and chart instances for target cards ---
const _TARGET_MAX_FPS_SAMPLES = 30;
const _targetFpsHistory = {}; // fps_actual (rolling avg)
const _targetFpsCurrentHistory = {}; // fps_current (sends/sec)
const _targetFpsCharts = {};
const _targetFpsHistory: Record<string, number[]> = {}; // fps_actual (rolling avg)
const _targetFpsCurrentHistory: Record<string, number[]> = {}; // fps_current (sends/sec)
const _targetFpsCharts: Record<string, any> = {};
function _pushTargetFps(targetId: any, actual: any, current: any) {
if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
@@ -154,7 +154,7 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
}
// --- Editor state ---
let _editorCssSources = []; // populated when editor opens
let _editorCssSources: any[] = []; // populated when editor opens
let _targetTagsInput: TagInput | null = null;
class TargetEditorModal extends Modal {
@@ -343,12 +343,12 @@ function _ensureProtocolIconSelect() {
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
}
export async function showTargetEditor(targetId = null, cloneData = null) {
export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) {
try {
// Load devices, CSS sources, and value sources for dropdowns
const [devices, cssSources] = await Promise.all([
devicesCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch(),
]);
@@ -368,7 +368,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
deviceSelect.appendChild(opt);
});
let _editorTags = [];
let _editorTags: string[] = [];
if (targetId) {
// Editing existing target
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
@@ -598,14 +598,14 @@ export async function loadTargetsTab() {
try {
// Fetch all entities via DataCache
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
devicesCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
patternTemplatesCache.fetch().catch(() => []),
streamsCache.fetch().catch(() => []),
valueSourcesCache.fetch().catch(() => []),
audioSourcesCache.fetch().catch(() => []),
syncClocksCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
outputTargetsCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
patternTemplatesCache.fetch().catch((): any[] => []),
streamsCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch().catch((): any[] => []),
audioSourcesCache.fetch().catch((): any[] => []),
syncClocksCache.fetch().catch((): any[] => []),
]);
const colorStripSourceMap = {};
@@ -698,7 +698,7 @@ export async function loadTargetsTab() {
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
// Track which target cards were replaced/added (need chart re-init)
let changedTargetIds = null;
let changedTargetIds: Set<string> | null = null;
if (csDevices.isMounted()) {
// ── Incremental update: reconcile cards in-place ──
@@ -760,13 +760,13 @@ export async function loadTargetsTab() {
if ((device.capabilities || []).includes('brightness_control')) {
if (device.id in _deviceBrightnessCache) {
const bri = _deviceBrightnessCache[device.id];
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`) as HTMLInputElement | null;
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(device.id)}"]`) as HTMLInputElement | null;
if (slider) {
slider.value = String(bri);
slider.title = Math.round(bri / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`);
const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(device.id)}"]`);
if (wrap) wrap.classList.remove('brightness-loading');
} else {
fetchDeviceBrightness(device.id);
@@ -780,7 +780,7 @@ export async function loadTargetsTab() {
// Patch "Last seen" labels in-place (avoids full card re-render on relative time changes)
for (const device of devicesWithState) {
const el = container.querySelector(`[data-last-seen="${device.id}"]`) as HTMLElement | null;
const el = container.querySelector(`[data-last-seen="${CSS.escape(device.id)}"]`) as HTMLElement | null;
if (el) {
const ts = device.state?.device_last_checked;
const label = ts ? formatRelativeTime(ts) : null;
@@ -918,7 +918,7 @@ function _buildLedTimingHTML(state: any) {
function _patchTargetMetrics(target: any) {
const container = document.getElementById('targets-panel-content');
if (!container) return;
const card = container.querySelector(`[data-target-id="${target.id}"]`);
const card = container.querySelector(`[data-target-id="${CSS.escape(target.id)}"]`);
if (!card) return;
const state = target.state || {};
const metrics = target.metrics || {};
@@ -1141,7 +1141,7 @@ export async function stopAllKCTargets() {
async function _stopAllByType(targetType: any) {
try {
const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/output-targets/batch/states'),
]);
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
@@ -1339,7 +1339,7 @@ function _renderLedStrip(canvas: any, rgbBytes: any) {
canvas.width = ledCount;
canvas.height = 1;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(ledCount, 1);
const data = imageData.data;
@@ -1447,7 +1447,7 @@ function connectLedPreviewWS(targetId: any) {
}
function _setPreviewButtonState(targetId: any, active: boolean) {
const btn = document.querySelector(`[data-led-preview-btn="${targetId}"]`);
const btn = document.querySelector(`[data-led-preview-btn="${CSS.escape(targetId)}"]`);
if (btn) {
btn.classList.toggle('btn-warning', active);
btn.classList.toggle('btn-secondary', !active);

View File

@@ -353,7 +353,7 @@ function showTutorialStep(index: number, direction: number = 1): void {
if (needsScroll) {
// Hide tooltip while scrolling to prevent stale position flash
const tt = overlay.querySelector('.tutorial-tooltip');
const tt = overlay.querySelector('.tutorial-tooltip') as HTMLElement | null;
if (tt) tt.style.visibility = 'hidden';
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
_waitForScrollEnd().then(() => {

View File

@@ -152,7 +152,7 @@ function _drawWaveformPreview(waveformType: any) {
canvas.height = cssH * dpr;
canvas.style.height = cssH + 'px';
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
@@ -552,7 +552,7 @@ const VS_HISTORY_SIZE = 200;
let _testVsWs: WebSocket | null = null;
let _testVsAnimFrame: number | null = null;
let _testVsLatest: any = null;
let _testVsHistory = [];
let _testVsHistory: number[] = [];
let _testVsMinObserved = Infinity;
let _testVsMaxObserved = -Infinity;
@@ -602,7 +602,10 @@ export function testValueSource(sourceId: any) {
}
if (data.value < _testVsMinObserved) _testVsMinObserved = data.value;
if (data.value > _testVsMaxObserved) _testVsMaxObserved = data.value;
} catch {}
} catch (e) {
console.error('Value source test WS parse error:', e);
return;
}
};
_testVsWs.onclose = () => {
@@ -647,7 +650,7 @@ function _sizeVsCanvas(canvas: HTMLCanvasElement) {
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d').scale(dpr, dpr);
canvas.getContext('2d')!.scale(dpr, dpr);
}
function _renderVsTestLoop() {
@@ -661,7 +664,7 @@ function _renderVsChart() {
const canvas = document.getElementById('vs-test-canvas') as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
@@ -885,7 +888,7 @@ export function addSchedulePoint(time: string = '', value: number = 1.0) {
function _getScheduleFromUI() {
const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row');
const schedule = [];
const schedule: { time: string; value: number }[] = [];
rows.forEach(row => {
const time = (row.querySelector('.schedule-time') as HTMLInputElement).value;
const value = parseFloat((row.querySelector('.schedule-value') as HTMLInputElement).value);

View File

@@ -202,11 +202,9 @@ interface Window {
saveScenePreset: (...args: any[]) => any;
closeScenePresetEditor: (...args: any[]) => any;
activateScenePreset: (...args: any[]) => any;
recaptureScenePreset: (...args: any[]) => any;
cloneScenePreset: (...args: any[]) => any;
deleteScenePreset: (...args: any[]) => any;
addSceneTarget: (...args: any[]) => any;
removeSceneTarget: (...args: any[]) => any;
// ─── Device Discovery ───
onDeviceTypeChanged: (...args: any[]) => any;

View File

@@ -12,19 +12,8 @@ const CACHE_NAME = 'ledgrab-v33';
// Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
const PRECACHE_URLS = [
'/static/css/base.css',
'/static/css/layout.css',
'/static/css/components.css',
'/static/css/cards.css',
'/static/css/modal.css',
'/static/css/calibration.css',
'/static/css/advanced-calibration.css',
'/static/css/dashboard.css',
'/static/css/streams.css',
'/static/css/patterns.css',
'/static/css/automations.css',
'/static/css/tutorials.css',
'/static/css/mobile.css',
'/static/dist/app.bundle.css',
'/static/dist/app.bundle.js',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
];

View File

@@ -1,5 +1,6 @@
"""Base class for JSON entity stores — eliminates boilerplate across 12+ stores."""
import asyncio
import json
import threading
from pathlib import Path
@@ -106,6 +107,23 @@ class BaseJsonStore(Generic[T]):
logger.error(f"Failed to save {self._json_key} to {self.file_path}: {e}")
raise
async def _save_async(self) -> None:
"""Async wrapper around ``_save()`` — runs file I/O in a thread.
Use from ``async def`` route handlers to avoid blocking the event loop.
Caller must hold ``self._lock`` (same contract as ``_save``).
"""
await asyncio.to_thread(self._save)
async def async_delete(self, item_id: str) -> None:
"""Async version of ``delete()`` — offloads file I/O to a thread."""
with self._lock:
if item_id not in self._items:
raise EntityNotFoundError(f"{self._entity_name} not found: {item_id}")
del self._items[item_id]
await self._save_async()
logger.info(f"Deleted {self._entity_name}: {item_id}")
# ── Common CRUD ────────────────────────────────────────────────
def get_all(self) -> List[T]:

View File

@@ -177,10 +177,17 @@ class Device:
# Fields that can be updated (all Device.__init__ params except identity/timestamps)
_UPDATABLE_FIELDS = {
k for k in Device.__init__.__code__.co_varnames
if k not in ('self', 'device_id', 'created_at', 'updated_at')
}
_UPDATABLE_FIELDS: frozenset[str] = frozenset({
"name", "url", "led_count", "enabled", "device_type",
"baud_rate", "software_brightness", "auto_shutdown",
"send_latency_ms", "rgbw", "zone_mode", "tags",
"dmx_protocol", "dmx_start_universe", "dmx_start_channel",
"espnow_peer_mac", "espnow_channel",
"hue_username", "hue_client_key", "hue_entertainment_group_id",
"spi_speed_hz", "spi_led_type",
"chroma_device_type", "gamesense_device_type",
"default_css_processing_template_id",
})
class DeviceStore(BaseJsonStore[Device]):
@@ -235,43 +242,46 @@ class DeviceStore(BaseJsonStore[Device]):
gamesense_device_type: str = "keyboard",
) -> Device:
"""Create a new device."""
device_id = f"device_{uuid.uuid4().hex[:8]}"
with self._lock:
self._check_name_unique(name)
# Mock devices use their device ID as the URL authority
if device_type == "mock":
url = f"mock://{device_id}"
device_id = f"device_{uuid.uuid4().hex[:8]}"
device = Device(
device_id=device_id,
name=name,
url=url,
led_count=led_count,
device_type=device_type,
baud_rate=baud_rate,
auto_shutdown=auto_shutdown,
send_latency_ms=send_latency_ms,
rgbw=rgbw,
zone_mode=zone_mode,
tags=tags or [],
dmx_protocol=dmx_protocol,
dmx_start_universe=dmx_start_universe,
dmx_start_channel=dmx_start_channel,
espnow_peer_mac=espnow_peer_mac,
espnow_channel=espnow_channel,
hue_username=hue_username,
hue_client_key=hue_client_key,
hue_entertainment_group_id=hue_entertainment_group_id,
spi_speed_hz=spi_speed_hz,
spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type,
gamesense_device_type=gamesense_device_type,
)
# Mock devices use their device ID as the URL authority
if device_type == "mock":
url = f"mock://{device_id}"
self._items[device_id] = device
self._save()
device = Device(
device_id=device_id,
name=name,
url=url,
led_count=led_count,
device_type=device_type,
baud_rate=baud_rate,
auto_shutdown=auto_shutdown,
send_latency_ms=send_latency_ms,
rgbw=rgbw,
zone_mode=zone_mode,
tags=tags or [],
dmx_protocol=dmx_protocol,
dmx_start_universe=dmx_start_universe,
dmx_start_channel=dmx_start_channel,
espnow_peer_mac=espnow_peer_mac,
espnow_channel=espnow_channel,
hue_username=hue_username,
hue_client_key=hue_client_key,
hue_entertainment_group_id=hue_entertainment_group_id,
spi_speed_hz=spi_speed_hz,
spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type,
gamesense_device_type=gamesense_device_type,
)
logger.info(f"Created device {device_id}: {name}")
return device
self._items[device_id] = device
self._save()
logger.info(f"Created device {device_id}: {name}")
return device
def update_device(self, device_id: str, **kwargs) -> Device:
"""Update device fields.
@@ -279,17 +289,37 @@ class DeviceStore(BaseJsonStore[Device]):
Pass any updatable Device field as a keyword argument.
``None`` values are ignored (no change).
"""
device = self.get(device_id) # raises ValueError if not found
with self._lock:
device = self.get(device_id) # raises ValueError if not found
for key, value in kwargs.items():
if value is not None and key in _UPDATABLE_FIELDS:
setattr(device, key, value)
# Collect updates (ignore None values and unknown fields)
updates = {
key: value
for key, value in kwargs.items()
if value is not None and key in _UPDATABLE_FIELDS
}
device.updated_at = datetime.now(timezone.utc)
self._save()
# Check name uniqueness if name is being changed
new_name = updates.get("name")
if new_name is not None and new_name != device.name:
self._check_name_unique(new_name, exclude_id=device_id)
logger.info(f"Updated device {device_id}")
return device
# Build new Device from existing fields + updates (immutable pattern)
device_fields = device.to_dict()
# Map 'id' back to 'device_id' for the constructor
device_fields["device_id"] = device_fields.pop("id")
# Restore datetime objects (to_dict serializes them as ISO strings)
device_fields["created_at"] = device.created_at
device_fields["updated_at"] = datetime.now(timezone.utc)
# Apply updates
device_fields.update(updates)
new_device = Device(**device_fields)
self._items[device_id] = new_device
self._save()
logger.info(f"Updated device {device_id}")
return new_device
# ── Unique helpers ───────────────────────────────────────────

View File

View File

View File

@@ -0,0 +1,196 @@
"""Tests for device CRUD routes.
These tests exercise the FastAPI route handlers using dependency override
to inject test stores, avoiding real hardware dependencies.
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from wled_controller.api.routes.devices import router
from wled_controller.storage.device_store import Device, DeviceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.api import dependencies as deps
# ---------------------------------------------------------------------------
# App + fixtures (isolated from the real main app)
# ---------------------------------------------------------------------------
def _make_app():
"""Build a minimal FastAPI app with just the devices router + overrides."""
app = FastAPI()
app.include_router(router)
return app
@pytest.fixture
def device_store(tmp_path):
return DeviceStore(tmp_path / "devices.json")
@pytest.fixture
def output_target_store(tmp_path):
return OutputTargetStore(str(tmp_path / "output_targets.json"))
@pytest.fixture
def processor_manager():
"""A mock ProcessorManager — avoids real hardware."""
m = MagicMock(spec=ProcessorManager)
m.add_device = MagicMock()
m.remove_device = AsyncMock()
m.update_device_info = MagicMock()
m.find_device_state = MagicMock(return_value=None)
m.get_all_device_health_dicts = MagicMock(return_value=[])
return m
@pytest.fixture
def client(device_store, output_target_store, processor_manager):
app = _make_app()
# Override auth to always pass
from wled_controller.api.auth import verify_api_key
app.dependency_overrides[verify_api_key] = lambda: "test-user"
# Override stores and manager
app.dependency_overrides[deps.get_device_store] = lambda: device_store
app.dependency_overrides[deps.get_output_target_store] = lambda: output_target_store
app.dependency_overrides[deps.get_processor_manager] = lambda: processor_manager
return TestClient(app, raise_server_exceptions=False)
# ---------------------------------------------------------------------------
# Helper to pre-populate a device
# ---------------------------------------------------------------------------
def _seed_device(store: DeviceStore, name="Test Device", led_count=100) -> Device:
return store.create_device(
name=name,
url="http://192.168.1.100",
led_count=led_count,
)
# ---------------------------------------------------------------------------
# LIST
# ---------------------------------------------------------------------------
class TestListDevices:
def test_list_empty(self, client):
resp = client.get("/api/v1/devices")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["devices"] == []
def test_list_with_devices(self, client, device_store):
_seed_device(device_store, "Dev A")
_seed_device(device_store, "Dev B")
resp = client.get("/api/v1/devices")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 2
# ---------------------------------------------------------------------------
# GET by ID
# ---------------------------------------------------------------------------
class TestGetDevice:
def test_get_existing(self, client, device_store):
d = _seed_device(device_store)
resp = client.get(f"/api/v1/devices/{d.id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == d.id
assert data["name"] == "Test Device"
def test_get_not_found(self, client):
resp = client.get("/api/v1/devices/nonexistent")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# UPDATE
# ---------------------------------------------------------------------------
class TestUpdateDevice:
def test_update_name(self, client, device_store):
d = _seed_device(device_store)
resp = client.put(
f"/api/v1/devices/{d.id}",
json={"name": "Renamed"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "Renamed"
def test_update_led_count(self, client, device_store):
d = _seed_device(device_store, led_count=100)
resp = client.put(
f"/api/v1/devices/{d.id}",
json={"led_count": 300},
)
assert resp.status_code == 200
assert resp.json()["led_count"] == 300
def test_update_not_found(self, client):
resp = client.put(
"/api/v1/devices/missing_id",
json={"name": "X"},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# DELETE
# ---------------------------------------------------------------------------
class TestDeleteDevice:
def test_delete_existing(self, client, device_store):
d = _seed_device(device_store)
resp = client.delete(f"/api/v1/devices/{d.id}")
assert resp.status_code == 204
assert device_store.count() == 0
def test_delete_not_found(self, client):
resp = client.delete("/api/v1/devices/missing_id")
assert resp.status_code == 404
def test_delete_referenced_by_target_returns_409(
self, client, device_store, output_target_store
):
d = _seed_device(device_store)
output_target_store.create_target(
name="Target",
target_type="led",
device_id=d.id,
)
resp = client.delete(f"/api/v1/devices/{d.id}")
assert resp.status_code == 409
assert "referenced" in resp.json()["detail"].lower()
# ---------------------------------------------------------------------------
# Batch states
# ---------------------------------------------------------------------------
class TestBatchStates:
def test_batch_states(self, client):
resp = client.get("/api/v1/devices/batch/states")
assert resp.status_code == 200
assert "states" in resp.json()

View File

@@ -0,0 +1,74 @@
"""Tests for system routes — health, version.
These tests use the FastAPI TestClient against the real app. The health
and version endpoints do NOT require authentication, so we can test them
without setting up the full dependency injection.
"""
import pytest
from fastapi.testclient import TestClient
from wled_controller import __version__
@pytest.fixture(scope="module")
def client():
"""Provide a test client for the main app.
The app module initializes stores from the default config on import,
which is acceptable for read-only endpoints tested here.
"""
from wled_controller.main import app
return TestClient(app, raise_server_exceptions=False)
class TestHealthEndpoint:
def test_health_returns_200(self, client):
resp = client.get("/health")
assert resp.status_code == 200
def test_health_response_structure(self, client):
data = client.get("/health").json()
assert data["status"] == "healthy"
assert data["version"] == __version__
assert "timestamp" in data
def test_health_no_auth_required(self, client):
"""Health endpoint should work without Authorization header."""
resp = client.get("/health")
assert resp.status_code == 200
class TestVersionEndpoint:
def test_version_returns_200(self, client):
resp = client.get("/api/v1/version")
assert resp.status_code == 200
def test_version_response_fields(self, client):
data = client.get("/api/v1/version").json()
assert data["version"] == __version__
assert "python_version" in data
assert data["api_version"] == "v1"
assert "demo_mode" in data
class TestOpenAPIEndpoint:
def test_openapi_available(self, client):
resp = client.get("/openapi.json")
assert resp.status_code == 200
data = resp.json()
assert "info" in data
assert data["info"]["version"] == __version__
def test_swagger_ui(self, client):
resp = client.get("/docs")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
class TestRootEndpoint:
def test_root_returns_html(self, client):
resp = client.get("/")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]

View File

@@ -0,0 +1,67 @@
"""Tests for webhook routes — trigger, validation, rate limiting."""
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from wled_controller.api.routes.webhooks import _check_rate_limit, _rate_hits
# ---------------------------------------------------------------------------
# Rate limiter unit tests (pure function, no HTTP)
# ---------------------------------------------------------------------------
class TestRateLimiter:
def setup_method(self):
"""Clear rate-limit state between tests."""
_rate_hits.clear()
def test_allows_under_limit(self):
for _ in range(29):
_check_rate_limit("1.2.3.4") # should not raise
def test_rejects_at_limit(self):
for _ in range(30):
_check_rate_limit("1.2.3.4")
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
_check_rate_limit("1.2.3.4")
assert exc_info.value.status_code == 429
def test_separate_ips_independent(self):
for _ in range(30):
_check_rate_limit("10.0.0.1")
# Different IP should still be allowed
_check_rate_limit("10.0.0.2") # should not raise
def test_window_expiry(self):
"""Timestamps outside the 60s window are pruned."""
old_time = time.time() - 120 # 2 minutes ago
_rate_hits["1.2.3.4"] = [old_time] * 30
# Old entries should be pruned, allowing new requests
_check_rate_limit("1.2.3.4") # should not raise
# ---------------------------------------------------------------------------
# Webhook payload validation
# ---------------------------------------------------------------------------
class TestWebhookPayload:
def test_valid_payload_model(self):
from wled_controller.api.routes.webhooks import WebhookPayload
p = WebhookPayload(action="activate")
assert p.action == "activate"
p2 = WebhookPayload(action="deactivate")
assert p2.action == "deactivate"
def test_arbitrary_action_accepted_by_model(self):
"""The model accepts any string; validation is in the route handler."""
from wled_controller.api.routes.webhooks import WebhookPayload
p = WebhookPayload(action="bogus")
assert p.action == "bogus"

View File

@@ -1,13 +1,39 @@
"""Pytest configuration and fixtures."""
"""Pytest configuration and shared fixtures."""
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from pathlib import Path
from wled_controller.config import Config, StorageConfig, ServerConfig, AuthConfig
from wled_controller.storage.device_store import Device, DeviceStore
from wled_controller.storage.sync_clock import SyncClock
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.automation import (
Automation,
AlwaysCondition,
WebhookCondition,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.value_source import StaticValueSource, ValueSource
from wled_controller.storage.value_source_store import ValueSourceStore
# ---------------------------------------------------------------------------
# Directory / path fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def test_data_dir(tmp_path):
"""Provide a temporary directory for test data."""
return tmp_path / "data"
d = tmp_path / "data"
d.mkdir(parents=True, exist_ok=True)
return d
@pytest.fixture
@@ -16,6 +42,198 @@ def test_config_dir(tmp_path):
return tmp_path / "config"
@pytest.fixture
def temp_store_dir(tmp_path):
"""Provide a temp directory for JSON store files, cleaned up after tests."""
d = tmp_path / "stores"
d.mkdir(parents=True, exist_ok=True)
return d
# ---------------------------------------------------------------------------
# Config fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def test_config(tmp_path):
"""A Config instance with temp directories for all store files."""
data_dir = tmp_path / "data"
data_dir.mkdir(parents=True, exist_ok=True)
storage = StorageConfig(
devices_file=str(data_dir / "devices.json"),
templates_file=str(data_dir / "capture_templates.json"),
postprocessing_templates_file=str(data_dir / "postprocessing_templates.json"),
picture_sources_file=str(data_dir / "picture_sources.json"),
output_targets_file=str(data_dir / "output_targets.json"),
pattern_templates_file=str(data_dir / "pattern_templates.json"),
color_strip_sources_file=str(data_dir / "color_strip_sources.json"),
audio_sources_file=str(data_dir / "audio_sources.json"),
audio_templates_file=str(data_dir / "audio_templates.json"),
value_sources_file=str(data_dir / "value_sources.json"),
automations_file=str(data_dir / "automations.json"),
scene_presets_file=str(data_dir / "scene_presets.json"),
color_strip_processing_templates_file=str(data_dir / "color_strip_processing_templates.json"),
sync_clocks_file=str(data_dir / "sync_clocks.json"),
)
return Config(
server=ServerConfig(host="127.0.0.1", port=9999),
auth=AuthConfig(api_keys={"test": "test-api-key-12345"}),
storage=storage,
)
# ---------------------------------------------------------------------------
# Store fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def device_store(temp_store_dir):
"""Provide a DeviceStore backed by a temp file."""
return DeviceStore(temp_store_dir / "devices.json")
@pytest.fixture
def sync_clock_store(temp_store_dir):
"""Provide a SyncClockStore backed by a temp file."""
return SyncClockStore(str(temp_store_dir / "sync_clocks.json"))
@pytest.fixture
def output_target_store(temp_store_dir):
"""Provide an OutputTargetStore backed by a temp file."""
return OutputTargetStore(str(temp_store_dir / "output_targets.json"))
@pytest.fixture
def automation_store(temp_store_dir):
"""Provide an AutomationStore backed by a temp file."""
return AutomationStore(str(temp_store_dir / "automations.json"))
@pytest.fixture
def value_source_store(temp_store_dir):
"""Provide a ValueSourceStore backed by a temp file."""
return ValueSourceStore(str(temp_store_dir / "value_sources.json"))
# ---------------------------------------------------------------------------
# Sample entity factories
# ---------------------------------------------------------------------------
@pytest.fixture
def sample_device():
"""Provide a sample device configuration dict."""
return {
"id": "test_device_001",
"name": "Test WLED Device",
"url": "http://192.168.1.100",
"led_count": 150,
"enabled": True,
"device_type": "wled",
}
@pytest.fixture
def make_device():
"""Factory fixture: call make_device(name=..., **overrides) to build a Device."""
_counter = 0
def _factory(name=None, **kwargs):
nonlocal _counter
_counter += 1
defaults = dict(
device_id=f"device_test_{_counter:04d}",
name=name or f"Device {_counter}",
url=f"http://192.168.1.{_counter}",
led_count=150,
)
defaults.update(kwargs)
return Device(**defaults)
return _factory
@pytest.fixture
def make_sync_clock():
"""Factory fixture: call make_sync_clock(name=..., **overrides)."""
_counter = 0
def _factory(name=None, **kwargs):
nonlocal _counter
_counter += 1
now = datetime.now(timezone.utc)
defaults = dict(
id=f"sc_test_{_counter:04d}",
name=name or f"Clock {_counter}",
speed=1.0,
created_at=now,
updated_at=now,
)
defaults.update(kwargs)
return SyncClock(**defaults)
return _factory
@pytest.fixture
def make_automation():
"""Factory fixture: call make_automation(name=..., **overrides)."""
_counter = 0
def _factory(name=None, **kwargs):
nonlocal _counter
_counter += 1
now = datetime.now(timezone.utc)
defaults = dict(
id=f"auto_test_{_counter:04d}",
name=name or f"Automation {_counter}",
enabled=True,
condition_logic="or",
conditions=[],
scene_preset_id=None,
deactivation_mode="none",
deactivation_scene_preset_id=None,
created_at=now,
updated_at=now,
)
defaults.update(kwargs)
return Automation(**defaults)
return _factory
# ---------------------------------------------------------------------------
# Authenticated test client
# ---------------------------------------------------------------------------
@pytest.fixture
def authenticated_client(test_config, monkeypatch):
"""Provide a FastAPI TestClient with auth header pre-set.
Patches global config so the app uses temp storage paths.
"""
import wled_controller.config as config_mod
monkeypatch.setattr(config_mod, "config", test_config)
from fastapi.testclient import TestClient
from wled_controller.main import app
client = TestClient(app, raise_server_exceptions=False)
client.headers["Authorization"] = "Bearer test-api-key-12345"
return client
# ---------------------------------------------------------------------------
# Calibration sample (kept from original conftest)
# ---------------------------------------------------------------------------
@pytest.fixture
def sample_calibration():
"""Provide a sample calibration configuration."""
@@ -29,21 +247,3 @@ def sample_calibration():
{"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,
},
}

View File

View File

@@ -0,0 +1,290 @@
"""Tests for AutomationEngine — condition evaluation in isolation."""
import asyncio
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
Automation,
DisplayStateCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
)
from wled_controller.storage.automation_store import AutomationStore
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_store(tmp_path) -> AutomationStore:
return AutomationStore(str(tmp_path / "auto.json"))
@pytest.fixture
def mock_manager():
m = MagicMock()
m.fire_event = MagicMock()
return m
@pytest.fixture
def engine(mock_store, mock_manager) -> AutomationEngine:
"""Build an AutomationEngine with the PlatformDetector mocked out.
PlatformDetector starts a Windows display-power listener thread that
causes access violations in the test environment, so we replace it
with a simple MagicMock.
"""
with patch(
"wled_controller.core.automations.automation_engine.PlatformDetector"
):
eng = AutomationEngine(
automation_store=mock_store,
processor_manager=mock_manager,
poll_interval=0.1,
)
return eng
# ---------------------------------------------------------------------------
# Condition evaluation (unit-level)
# ---------------------------------------------------------------------------
class TestConditionEvaluation:
"""Test _evaluate_condition for each condition type individually."""
def _make_automation(self, conditions):
now = datetime.now(timezone.utc)
return Automation(
id="test_auto",
name="Test",
enabled=True,
condition_logic="or",
conditions=conditions,
scene_preset_id=None,
deactivation_mode="none",
deactivation_scene_preset_id=None,
created_at=now,
updated_at=now,
)
def _eval(self, engine, condition, **kwargs):
"""Invoke the private _evaluate_condition method."""
defaults = dict(
running_procs=set(),
topmost_proc=None,
topmost_fullscreen=False,
fullscreen_procs=set(),
idle_seconds=None,
display_state=None,
)
defaults.update(kwargs)
return engine._evaluate_condition(
condition,
defaults["running_procs"],
defaults["topmost_proc"],
defaults["topmost_fullscreen"],
defaults["fullscreen_procs"],
defaults["idle_seconds"],
defaults["display_state"],
)
def test_always_true(self, engine):
assert self._eval(engine, AlwaysCondition()) is True
def test_startup_true(self, engine):
assert self._eval(engine, StartupCondition()) is True
def test_application_running_match(self, engine):
cond = ApplicationCondition(apps=["chrome.exe"], match_type="running")
result = self._eval(
engine, cond,
running_procs={"chrome.exe", "explorer.exe"},
)
assert result is True
def test_application_running_no_match(self, engine):
cond = ApplicationCondition(apps=["chrome.exe"], match_type="running")
result = self._eval(
engine, cond,
running_procs={"explorer.exe"},
)
assert result is False
def test_application_topmost_match(self, engine):
cond = ApplicationCondition(apps=["game.exe"], match_type="topmost")
result = self._eval(
engine, cond,
topmost_proc="game.exe",
)
assert result is True
def test_application_topmost_no_match(self, engine):
cond = ApplicationCondition(apps=["game.exe"], match_type="topmost")
result = self._eval(
engine, cond,
topmost_proc="chrome.exe",
)
assert result is False
def test_time_of_day_within_range(self, engine):
cond = TimeOfDayCondition(start_time="00:00", end_time="23:59")
result = self._eval(engine, cond)
assert result is True
def test_system_idle_when_idle(self, engine):
cond = SystemIdleCondition(idle_minutes=5, when_idle=True)
result = self._eval(engine, cond, idle_seconds=600.0) # 10 minutes idle
assert result is True
def test_system_idle_not_idle(self, engine):
cond = SystemIdleCondition(idle_minutes=5, when_idle=True)
result = self._eval(engine, cond, idle_seconds=60.0) # 1 minute idle
assert result is False
def test_system_idle_when_not_idle(self, engine):
"""when_idle=False means active when user is NOT idle."""
cond = SystemIdleCondition(idle_minutes=5, when_idle=False)
result = self._eval(engine, cond, idle_seconds=60.0) # 1 min idle (not yet 5)
assert result is True
def test_display_state_match(self, engine):
cond = DisplayStateCondition(state="on")
result = self._eval(engine, cond, display_state="on")
assert result is True
def test_display_state_no_match(self, engine):
cond = DisplayStateCondition(state="off")
result = self._eval(engine, cond, display_state="on")
assert result is False
def test_webhook_active(self, engine):
cond = WebhookCondition(token="tok123")
engine._webhook_states["tok123"] = True
result = self._eval(engine, cond)
assert result is True
def test_webhook_inactive(self, engine):
cond = WebhookCondition(token="tok123")
# Not in _webhook_states → False
result = self._eval(engine, cond)
assert result is False
# ---------------------------------------------------------------------------
# Condition logic (AND / OR)
# ---------------------------------------------------------------------------
class TestConditionLogic:
def _make_automation(self, conditions, logic="or"):
now = datetime.now(timezone.utc)
return Automation(
id="logic_auto",
name="Logic",
enabled=True,
condition_logic=logic,
conditions=conditions,
scene_preset_id=None,
deactivation_mode="none",
deactivation_scene_preset_id=None,
created_at=now,
updated_at=now,
)
def test_or_any_true(self, engine):
auto = self._make_automation(
[
ApplicationCondition(apps=["missing.exe"], match_type="running"),
AlwaysCondition(),
],
logic="or",
)
result = engine._evaluate_conditions(
auto,
running_procs=set(),
topmost_proc=None,
topmost_fullscreen=False,
fullscreen_procs=set(),
idle_seconds=None,
display_state=None,
)
assert result is True
def test_and_all_must_be_true(self, engine):
auto = self._make_automation(
[
AlwaysCondition(),
ApplicationCondition(apps=["missing.exe"], match_type="running"),
],
logic="and",
)
result = engine._evaluate_conditions(
auto,
running_procs=set(),
topmost_proc=None,
topmost_fullscreen=False,
fullscreen_procs=set(),
idle_seconds=None,
display_state=None,
)
assert result is False
# ---------------------------------------------------------------------------
# Webhook state management
# ---------------------------------------------------------------------------
class TestWebhookState:
@pytest.mark.asyncio
async def test_set_webhook_state_activate(self, engine):
await engine.set_webhook_state("tok_1", True)
assert engine._webhook_states["tok_1"] is True
@pytest.mark.asyncio
async def test_set_webhook_state_deactivate(self, engine):
engine._webhook_states["tok_1"] = True
await engine.set_webhook_state("tok_1", False)
assert engine._webhook_states["tok_1"] is False
# ---------------------------------------------------------------------------
# Start / Stop lifecycle
# ---------------------------------------------------------------------------
class TestEngineLifecycle:
@pytest.mark.asyncio
async def test_start_creates_task(self, engine):
await engine.start()
assert engine._task is not None
await engine.stop()
@pytest.mark.asyncio
async def test_stop_cancels_task(self, engine):
await engine.start()
await engine.stop()
assert engine._task is None
@pytest.mark.asyncio
async def test_double_start_is_safe(self, engine):
await engine.start()
await engine.start() # no-op
await engine.stop()
@pytest.mark.asyncio
async def test_stop_without_start_is_safe(self, engine):
await engine.stop() # no-op

View File

@@ -0,0 +1,185 @@
"""Tests for SyncClockRuntime — thread-safe timing, pause/resume/reset."""
import threading
import time
import pytest
from wled_controller.core.processing.sync_clock_runtime import SyncClockRuntime
class TestSyncClockRuntimeInit:
def test_default_speed(self):
rt = SyncClockRuntime()
assert rt.speed == 1.0
def test_custom_speed(self):
rt = SyncClockRuntime(speed=2.5)
assert rt.speed == 2.5
def test_starts_running(self):
rt = SyncClockRuntime()
assert rt.is_running is True
def test_initial_time_near_zero(self):
rt = SyncClockRuntime()
t = rt.get_time()
assert 0.0 <= t < 0.1 # should be very small right after creation
class TestSyncClockRuntimeSpeed:
def test_set_speed(self):
rt = SyncClockRuntime()
rt.speed = 3.0
assert rt.speed == 3.0
def test_speed_zero(self):
rt = SyncClockRuntime()
rt.speed = 0.0
assert rt.speed == 0.0
def test_speed_negative(self):
"""Negative speed is allowed at the runtime level (clamping is store-level)."""
rt = SyncClockRuntime()
rt.speed = -1.0
assert rt.speed == -1.0
class TestSyncClockRuntimeGetTime:
def test_time_advances(self):
rt = SyncClockRuntime()
t1 = rt.get_time()
time.sleep(0.05)
t2 = rt.get_time()
assert t2 > t1
def test_time_is_real_seconds(self):
"""get_time returns real elapsed seconds, NOT speed-scaled."""
rt = SyncClockRuntime(speed=5.0)
time.sleep(0.1)
t = rt.get_time()
# Should be roughly 0.1s (real time), not 0.5s (speed-scaled)
assert 0.05 < t < 0.5
class TestSyncClockRuntimePauseResume:
def test_pause_freezes_time(self):
rt = SyncClockRuntime()
time.sleep(0.05)
rt.pause()
t1 = rt.get_time()
time.sleep(0.05)
t2 = rt.get_time()
assert t1 == t2 # time should not advance while paused
def test_pause_sets_not_running(self):
rt = SyncClockRuntime()
rt.pause()
assert rt.is_running is False
def test_resume_unfreezes_time(self):
rt = SyncClockRuntime()
rt.pause()
time.sleep(0.02)
rt.resume()
assert rt.is_running is True
t1 = rt.get_time()
time.sleep(0.05)
t2 = rt.get_time()
assert t2 > t1
def test_resume_preserves_offset(self):
"""After pause+resume, time continues from where it was paused."""
rt = SyncClockRuntime()
time.sleep(0.05)
rt.pause()
paused_time = rt.get_time()
time.sleep(0.1)
rt.resume()
resumed_time = rt.get_time()
# Resumed time should be close to paused time (not reset, not including pause gap)
assert abs(resumed_time - paused_time) < 0.05
def test_double_pause_is_safe(self):
rt = SyncClockRuntime()
time.sleep(0.02)
rt.pause()
t1 = rt.get_time()
rt.pause() # second pause should be a no-op
t2 = rt.get_time()
assert t1 == t2
def test_double_resume_is_safe(self):
rt = SyncClockRuntime()
rt.resume() # already running, should be a no-op
assert rt.is_running is True
class TestSyncClockRuntimeReset:
def test_reset_sets_time_to_zero(self):
rt = SyncClockRuntime()
time.sleep(0.05)
rt.reset()
t = rt.get_time()
assert t < 0.02 # should be very close to zero
def test_reset_while_paused(self):
rt = SyncClockRuntime()
time.sleep(0.05)
rt.pause()
rt.reset()
# After reset, offset is 0 but clock is still paused — is_running unchanged
# The reset resets offset and epoch but doesn't change running state
t = rt.get_time()
# Time might be ~0 if paused, or very small if running
assert t < 0.05
def test_reset_preserves_speed(self):
rt = SyncClockRuntime(speed=3.0)
time.sleep(0.05)
rt.reset()
assert rt.speed == 3.0
class TestSyncClockRuntimeThreadSafety:
def test_concurrent_get_time(self):
rt = SyncClockRuntime()
results = []
errors = []
def _read():
try:
for _ in range(100):
t = rt.get_time()
results.append(t)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=_read) for _ in range(8)]
for th in threads:
th.start()
for th in threads:
th.join()
assert len(errors) == 0
assert len(results) == 800
def test_concurrent_pause_resume(self):
rt = SyncClockRuntime()
errors = []
def _toggle():
try:
for _ in range(50):
rt.pause()
rt.resume()
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=_toggle) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
assert len(errors) == 0

View File

@@ -0,0 +1 @@
"""End-to-end API tests for critical user flows."""

View File

@@ -0,0 +1,72 @@
"""Shared fixtures for end-to-end API tests.
Uses the real FastAPI app with a module-scoped TestClient to avoid
repeated lifespan startup/shutdown issues. Each test function gets
fresh, empty stores via the _clear_stores helper.
"""
import pytest
from wled_controller.config import get_config
# Resolve the API key from the real config (same key used in production tests)
_config = get_config()
API_KEY = next(iter(_config.auth.api_keys.values()), "")
AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}
@pytest.fixture(scope="session")
def _test_client():
"""Session-scoped TestClient to avoid lifespan re-entry issues.
The app's lifespan (MQTT, automation engine, health monitoring, etc.)
starts once for the entire e2e test session and shuts down after all
tests complete.
"""
from fastapi.testclient import TestClient
from wled_controller.main import app
with TestClient(app, raise_server_exceptions=False) as c:
yield c
@pytest.fixture
def client(_test_client):
"""Per-test client with auth headers and clean stores.
Clears all entity stores before each test so tests are independent.
"""
_clear_stores()
_test_client.headers["Authorization"] = f"Bearer {API_KEY}"
yield _test_client
# Clean up after test
_clear_stores()
def _clear_stores():
"""Remove all entities from all stores for test isolation."""
from wled_controller.api import dependencies as deps
store_clearers = [
(deps.get_device_store, "get_all_devices", "delete_device"),
(deps.get_output_target_store, "get_all_targets", "delete_target"),
(deps.get_color_strip_store, "get_all_sources", "delete_source"),
(deps.get_value_source_store, "get_all", "delete"),
(deps.get_sync_clock_store, "get_all", "delete"),
(deps.get_automation_store, "get_all", "delete"),
(deps.get_scene_preset_store, "get_all", "delete"),
]
for getter, list_method, delete_method in store_clearers:
try:
store = getter()
items = getattr(store, list_method)()
for item in items:
item_id = getattr(item, "id", getattr(item, "device_id", None))
if item_id:
try:
getattr(store, delete_method)(item_id)
except Exception:
pass
except RuntimeError:
pass # Store not initialized yet

Some files were not shown because too many files have changed in this diff Show More