refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s
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:
33
.gitea/workflows/test.yml
Normal file
33
.gitea/workflows/test.yml
Normal 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
13
.pre-commit-config.yaml
Normal 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
186
CLAUDE.md
@@ -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.
|
**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
|
```bash
|
||||||
# Check if available:
|
ast-index search "Query" # Universal search
|
||||||
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 class "ClassName" # Find class/struct/interface definitions
|
ast-index class "ClassName" # Find class/struct/interface definitions
|
||||||
ast-index usages "SymbolName" # Find all places a symbol is used
|
ast-index usages "SymbolName" # Find all usage sites
|
||||||
ast-index implementations "BaseClass" # Find all subclasses/implementations
|
ast-index symbol "FunctionName" # Find any symbol
|
||||||
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 callers "FunctionName" # Find all call sites
|
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 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
|
- **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.
|
||||||
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.).
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
This is a monorepo containing:
|
- `/server` — Python FastAPI backend (see [server/CLAUDE.md](server/CLAUDE.md))
|
||||||
- `/server` - Python FastAPI backend (see `server/CLAUDE.md` for detailed instructions)
|
- `/contexts` — Context files for Claude (frontend conventions, graph editor, Chrome tools, server ops, demo mode)
|
||||||
- `/client` - Future frontend client (if applicable)
|
|
||||||
|
|
||||||
## Working with Server
|
## Context Files
|
||||||
|
|
||||||
For detailed server-specific instructions (restart policy, testing, etc.), see:
|
| File | When to read |
|
||||||
- `server/CLAUDE.md`
|
| ---- | ------------ |
|
||||||
|
| [contexts/frontend.md](contexts/frontend.md) | HTML, CSS, JS/TS, i18n, modals, icons, bundling |
|
||||||
## Frontend (HTML, CSS, JS, i18n)
|
| [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 |
|
||||||
For all frontend conventions (CSS variables, UI patterns, modals, localization, tutorials), see [`contexts/frontend.md`](contexts/frontend.md).
|
| [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
|
## 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`.
|
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
|
## 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.
|
**Use context7 MCP tools for library/framework documentation lookups** (FastAPI, OpenCV, Pydantic, yt-dlp, etc.) instead of 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`
|
|
||||||
|
|
||||||
## General Guidelines
|
## General Guidelines
|
||||||
|
|
||||||
- Always test changes before marking as complete
|
- Always test changes before marking as complete
|
||||||
- Follow existing code style and patterns
|
- Follow existing code style and patterns
|
||||||
- Update documentation when changing behavior
|
- Update documentation when changing behavior
|
||||||
- Write clear, descriptive commit messages when explicitly instructed
|
|
||||||
- Never make commits or pushes without explicit user approval
|
- Never make commits or pushes without explicit user approval
|
||||||
|
|||||||
85
CONTRIBUTING.md
Normal file
85
CONTRIBUTING.md
Normal 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
|
||||||
470
INSTALLATION.md
470
INSTALLATION.md
@@ -1,281 +1,222 @@
|
|||||||
# Installation Guide
|
# 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
|
## Table of Contents
|
||||||
|
|
||||||
1. [Server Installation](#server-installation)
|
1. [Docker Installation (recommended)](#docker-installation)
|
||||||
2. [Home Assistant Integration](#home-assistant-integration)
|
2. [Manual Installation](#manual-installation)
|
||||||
3. [Quick Start](#quick-start)
|
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:**
|
1. **Clone and start:**
|
||||||
- Python 3.11 or higher
|
|
||||||
- Windows, Linux, or macOS
|
|
||||||
|
|
||||||
**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:**
|
1. **Clone the repository:**
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
```bash
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
|
|
||||||
# Windows
|
# Linux / macOS
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Windows (cmd)
|
||||||
venv\Scripts\activate
|
venv\Scripts\activate
|
||||||
|
|
||||||
# Linux/Mac
|
# Windows (PowerShell)
|
||||||
source venv/bin/activate
|
venv\Scripts\Activate.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Install dependencies:**
|
4. **Install Python dependencies:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install .
|
pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Configure (optional):**
|
Optional extras:
|
||||||
Edit `config/default_config.yaml` to customize settings.
|
|
||||||
|
|
||||||
5. **Run the server:**
|
|
||||||
```bash
|
```bash
|
||||||
# Set PYTHONPATH
|
pip install ".[camera]" # Webcam capture via OpenCV
|
||||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
pip install ".[perf]" # DXCam, BetterCam, WGC (Windows only)
|
||||||
set PYTHONPATH=%CD%\src # Windows
|
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
|
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Verify:**
|
6. **Verify:** open <http://localhost:8080> in your browser.
|
||||||
Open http://localhost:8080/docs in your browser.
|
|
||||||
|
|
||||||
### Option 2: Docker (Recommended for Production)
|
---
|
||||||
|
|
||||||
**Requirements:**
|
## First-Time Setup
|
||||||
- Docker
|
|
||||||
- Docker Compose
|
|
||||||
|
|
||||||
**Steps:**
|
### Change the default API key
|
||||||
|
|
||||||
1. **Clone the repository:**
|
The server ships with a development API key (`development-key-change-in-production`). **Change it before exposing the server on your network.**
|
||||||
```bash
|
|
||||||
git clone https://github.com/yourusername/wled-screen-controller.git
|
|
||||||
cd wled-screen-controller/server
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Start with Docker Compose:**
|
Option A -- edit the config file:
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **View logs:**
|
```yaml
|
||||||
```bash
|
# server/config/default_config.yaml
|
||||||
docker-compose logs -f
|
auth:
|
||||||
```
|
api_keys:
|
||||||
|
main: "your-secure-key-here" # replace the dev key
|
||||||
|
```
|
||||||
|
|
||||||
4. **Verify:**
|
Option B -- set an environment variable:
|
||||||
Open http://localhost:8080/docs in your browser.
|
|
||||||
|
|
||||||
### Option 3: Docker (Manual Build)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
export WLED_AUTH__API_KEYS__main="your-secure-key-here"
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Home Assistant Integration
|
||||||
|
|
||||||
### Option 1: HACS (Recommended)
|
### Option 1: HACS (recommended)
|
||||||
|
|
||||||
1. **Install HACS** if not already installed:
|
1. Install [HACS](https://hacs.xyz/docs/setup/download) if you have not already.
|
||||||
- Follow instructions at https://hacs.xyz/docs/setup/download
|
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:**
|
### Option 2: Manual
|
||||||
- Open HACS in Home Assistant
|
|
||||||
- Click the three dots menu → Custom repositories
|
|
||||||
- Add URL: `https://github.com/yourusername/wled-screen-controller`
|
|
||||||
- Category: Integration
|
|
||||||
- Click Add
|
|
||||||
|
|
||||||
3. **Install Integration:**
|
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.
|
||||||
- In HACS, search for "WLED Screen Controller"
|
|
||||||
- Click Download
|
|
||||||
- Restart Home Assistant
|
|
||||||
|
|
||||||
4. **Configure Integration:**
|
### Automation example
|
||||||
- Go to Settings → Devices & Services
|
|
||||||
- Click "+ Add Integration"
|
|
||||||
- Search for "WLED Screen Controller"
|
|
||||||
- Enter your server URL (e.g., `http://192.168.1.100:8080`)
|
|
||||||
- Click Submit
|
|
||||||
|
|
||||||
### Option 2: Manual Installation
|
|
||||||
|
|
||||||
1. **Download Integration:**
|
|
||||||
```bash
|
|
||||||
cd /config # Your Home Assistant config directory
|
|
||||||
mkdir -p custom_components
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Copy Files:**
|
|
||||||
Copy the `custom_components/wled_screen_controller` folder to your Home Assistant `custom_components` directory.
|
|
||||||
|
|
||||||
3. **Restart Home Assistant**
|
|
||||||
|
|
||||||
4. **Configure Integration:**
|
|
||||||
- Go to Settings → Devices & Services
|
|
||||||
- Click "+ Add Integration"
|
|
||||||
- Search for "WLED Screen Controller"
|
|
||||||
- Enter your server URL
|
|
||||||
- Click Submit
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Start the Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd wled-screen-controller/server
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Attach Your WLED Device
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/v1/devices \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"name": "Living Room TV",
|
|
||||||
"url": "http://192.168.1.100",
|
|
||||||
"led_count": 150
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Configure in Home Assistant
|
|
||||||
|
|
||||||
1. Add the integration (see above)
|
|
||||||
2. Your WLED devices will appear automatically
|
|
||||||
3. Use the switch to turn processing on/off
|
|
||||||
4. Use the select to choose display
|
|
||||||
5. Monitor FPS and status via sensors
|
|
||||||
|
|
||||||
### 4. Start Processing
|
|
||||||
|
|
||||||
Either via API:
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
|
|
||||||
```
|
|
||||||
|
|
||||||
Or via Home Assistant:
|
|
||||||
- Turn on the "{Device Name} Processing" switch
|
|
||||||
|
|
||||||
### 5. Enjoy Ambient Lighting!
|
|
||||||
|
|
||||||
Your WLED strip should now sync with your screen content!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Server Won't Start
|
|
||||||
|
|
||||||
**Check Python version:**
|
|
||||||
```bash
|
|
||||||
python --version # Should be 3.11+
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check dependencies:**
|
|
||||||
```bash
|
|
||||||
pip list | grep fastapi
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check logs:**
|
|
||||||
```bash
|
|
||||||
# Docker
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Python
|
|
||||||
tail -f logs/wled_controller.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Home Assistant Integration Not Appearing
|
|
||||||
|
|
||||||
1. Check HACS installation
|
|
||||||
2. Clear browser cache
|
|
||||||
3. Restart Home Assistant
|
|
||||||
4. Check Home Assistant logs:
|
|
||||||
- Settings → System → Logs
|
|
||||||
- Search for "wled_screen_controller"
|
|
||||||
|
|
||||||
### Can't Connect to Server from Home Assistant
|
|
||||||
|
|
||||||
1. Verify server is running:
|
|
||||||
```bash
|
|
||||||
curl http://YOUR_SERVER_IP:8080/health
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Check firewall rules
|
|
||||||
3. Ensure Home Assistant can reach server IP
|
|
||||||
4. Try http:// not https://
|
|
||||||
|
|
||||||
### WLED Device Not Responding
|
|
||||||
|
|
||||||
1. Check WLED device is powered on
|
|
||||||
2. Verify IP address is correct
|
|
||||||
3. Test WLED directly:
|
|
||||||
```bash
|
|
||||||
curl http://YOUR_WLED_IP/json/info
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Check network connectivity
|
|
||||||
|
|
||||||
### Low FPS / Performance Issues
|
|
||||||
|
|
||||||
1. Reduce target FPS (Settings → Devices)
|
|
||||||
2. Reduce `border_width` in settings
|
|
||||||
3. Check CPU usage on server
|
|
||||||
4. Consider reducing LED count
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Examples
|
|
||||||
|
|
||||||
### Server Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Docker .env file
|
|
||||||
WLED_SERVER__HOST=0.0.0.0
|
|
||||||
WLED_SERVER__PORT=8080
|
|
||||||
WLED_SERVER__LOG_LEVEL=INFO
|
|
||||||
WLED_PROCESSING__DEFAULT_FPS=30
|
|
||||||
WLED_PROCESSING__BORDER_WIDTH=10
|
|
||||||
```
|
|
||||||
|
|
||||||
### Home Assistant Automation Example
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
- alias: "Auto Start WLED on TV On"
|
- alias: "Start ambient lighting when TV turns on"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: media_player.living_room_tv
|
entity_id: media_player.living_room_tv
|
||||||
@@ -285,7 +226,7 @@ automation:
|
|||||||
target:
|
target:
|
||||||
entity_id: switch.living_room_tv_processing
|
entity_id: switch.living_room_tv_processing
|
||||||
|
|
||||||
- alias: "Auto Stop WLED on TV Off"
|
- alias: "Stop ambient lighting when TV turns off"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: media_player.living_room_tv
|
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
|
## Next Steps
|
||||||
|
|
||||||
- [API Documentation](docs/API.md)
|
- [API Documentation](docs/API.md)
|
||||||
- [Calibration Guide](docs/CALIBRATION.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)
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -84,26 +84,42 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
|
|
||||||
```bash
|
```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
|
cd wled-screen-controller/server
|
||||||
|
|
||||||
# Option A: Docker (recommended)
|
# Build the frontend bundle
|
||||||
docker-compose up -d
|
npm ci && npm run build
|
||||||
|
|
||||||
# Option B: Python
|
# Create a virtual environment and install
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
source venv/bin/activate # Linux/Mac
|
source venv/bin/activate # Linux/Mac
|
||||||
# venv\Scripts\activate # Windows
|
# venv\Scripts\activate # Windows
|
||||||
pip install .
|
pip install .
|
||||||
|
|
||||||
|
# Start the server
|
||||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||||
# set PYTHONPATH=%CD%\src # Windows
|
# set PYTHONPATH=%CD%\src # Windows
|
||||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
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
|
## Architecture
|
||||||
|
|
||||||
@@ -146,7 +162,7 @@ wled-screen-controller/
|
|||||||
|
|
||||||
## Configuration
|
## 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
|
```yaml
|
||||||
server:
|
server:
|
||||||
@@ -168,7 +184,7 @@ logging:
|
|||||||
max_size_mb: 100
|
max_size_mb: 100
|
||||||
```
|
```
|
||||||
|
|
||||||
Environment variable override example: `LED_GRAB_SERVER__PORT=9090`.
|
Environment variable override example: `WLED_SERVER__PORT=9090`.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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.
|
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)
|
### 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:
|
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
|
### 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)
|
- **Getting started** (header-level walkthrough of all tabs and controls)
|
||||||
- **Per-tab tutorials** (Dashboard, Targets, Sources, Profiles) triggered by `?` buttons
|
- **Per-tab tutorials** (Dashboard, Targets, Sources, Profiles) triggered by `?` buttons
|
||||||
- **Device card tutorial** and **Calibration tutorial** (context-specific)
|
- **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
|
## Icons
|
||||||
|
|
||||||
**Always use SVG icons from the icon system, never text/emoji/Unicode symbols for buttons and UI controls.**
|
**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 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.js` (e.g. `ICON_START`, `ICON_TRASH`, `ICON_EDIT`)
|
- Icon constants are exported from `static/js/core/icons.ts` (e.g. `ICON_START`, `ICON_TRASH`, `ICON_EDIT`)
|
||||||
- Use `_svg(path)` wrapper from `icons.js` to create new icon constants from paths
|
- Use `_svg(path)` wrapper from `icons.ts` to create new icon constants from paths
|
||||||
|
|
||||||
When you need a new icon:
|
When you need a new icon:
|
||||||
1. Find the Lucide icon at https://lucide.dev
|
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
|
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
|
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).
|
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)
|
## 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 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
|
- 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`)
|
- 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
|
### 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)
|
- **Output:** `static/dist/app.bundle.js` and `static/dist/app.bundle.css` (minified + source maps)
|
||||||
- **Config:** `server/esbuild.mjs`
|
- **Config:** `server/esbuild.mjs`
|
||||||
- **HTML:** `templates/index.html` references the bundles, not individual source files
|
- **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:
|
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`
|
- **Chart.js** — imported in `perf-charts.ts`, exposed as `window.Chart` for `targets.ts` and `dashboard.ts`
|
||||||
- **ELK.js** — imported in `graph-layout.js` for graph auto-layout
|
- **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`
|
- **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.
|
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
|
### 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
|
## Visual Graph Editor
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Visual Graph Editor
|
# 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
|
## Architecture
|
||||||
|
|
||||||
@@ -10,12 +10,12 @@ The graph editor renders all entities (devices, templates, sources, clocks, targ
|
|||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `js/features/graph-editor.js` | Main orchestrator — toolbar, keyboard, search, filter, add-entity menu, port/node drag, minimap |
|
| `js/features/graph-editor.ts` | 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-layout.ts` | 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-nodes.ts` | 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-edges.ts` | SVG edge rendering (bezier curves, arrowheads, flow dots) |
|
||||||
| `js/core/graph-canvas.js` | Pan/zoom controller with `zoomToPoint()` rAF animation |
|
| `js/core/graph-canvas.ts` | Pan/zoom controller with `zoomToPoint()` rAF animation |
|
||||||
| `js/core/graph-connections.js` | CONNECTION_MAP — which fields link entity types, drag-connect/detach logic |
|
| `js/core/graph-connections.ts` | CONNECTION_MAP — which fields link entity types, drag-connect/detach logic |
|
||||||
| `css/graph-editor.css` | All graph-specific styles |
|
| `css/graph-editor.css` | All graph-specific styles |
|
||||||
|
|
||||||
### Data flow
|
### 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
|
### Adding a new entity type
|
||||||
|
|
||||||
1. **`graph-layout.js`** — `ENTITY_COLORS`, `ENTITY_LABELS`, `buildGraph()` (add node loop + edge loops)
|
1. **`graph-layout.ts`** — `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
|
2. **`graph-layout.ts`** — `edgeType()` function if the new type needs a distinct edge color
|
||||||
3. **`graph-nodes.js`** — `KIND_ICONS` (default icon), `SUBTYPE_ICONS` (subtype-specific icons)
|
3. **`graph-nodes.ts`** — `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
|
4. **`graph-nodes.ts`** — `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
|
5. **`graph-connections.ts`** — `CONNECTION_MAP` for drag-connect edge creation
|
||||||
6. **`graph-editor.js`** — `ADD_ENTITY_MAP` (add-entity menu entry with window function)
|
6. **`graph-editor.ts`** — `ADD_ENTITY_MAP` (add-entity menu entry with window function)
|
||||||
7. **`graph-editor.js`** — `ALL_CACHES` array (for new-entity-focus watcher)
|
7. **`graph-editor.ts`** — `ALL_CACHES` array (for new-entity-focus watcher)
|
||||||
8. **`graph-editor.js`** — `_fetchAllEntities()` (add cache fetch + pass to `computeLayout`)
|
8. **`graph-editor.ts`** — `_fetchAllEntities()` (add cache fetch + pass to `computeLayout`)
|
||||||
9. **`core/state.js`** — Add/export the new DataCache
|
9. **`core/state.ts`** — Add/export the new DataCache
|
||||||
10. **`app.js`** — Import and window-export the add/edit/clone functions
|
10. **`app.ts`** — Import and window-export the add/edit/clone functions
|
||||||
|
|
||||||
### Adding a new field/connection to an existing entity
|
### Adding a new field/connection to an existing entity
|
||||||
|
|
||||||
1. **`graph-layout.js`** — `buildGraph()` edges section: add `addEdge()` call
|
1. **`graph-layout.ts`** — `buildGraph()` edges section: add `addEdge()` call
|
||||||
2. **`graph-connections.js`** — `CONNECTION_MAP`: add the field entry
|
2. **`graph-connections.ts`** — `CONNECTION_MAP`: add the field entry
|
||||||
3. **`graph-edges.js`** — `EDGE_COLORS` if a new edge type is needed
|
3. **`graph-edges.ts`** — `EDGE_COLORS` if a new edge type is needed
|
||||||
|
|
||||||
### Adding a new entity subtype
|
### Adding a new entity subtype
|
||||||
|
|
||||||
1. **`graph-nodes.js`** — `SUBTYPE_ICONS[kind]` — add icon for the new subtype
|
1. **`graph-nodes.ts`** — `SUBTYPE_ICONS[kind]` — add icon for the new subtype
|
||||||
2. **`graph-layout.js`** — `buildGraph()` — ensure `subtype` is extracted from the entity data
|
2. **`graph-layout.ts`** — `buildGraph()` — ensure `subtype` is extracted from the entity data
|
||||||
|
|
||||||
## Features & keyboard shortcuts
|
## 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
|
## 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.
|
**Hover events** use `pointerover`/`pointerout` with `relatedTarget` check to prevent flicker when the cursor moves between child SVG elements within the same `<g>` node.
|
||||||
|
|
||||||
|
|||||||
78
contexts/server-operations.md
Normal file
78
contexts/server-operations.md
Normal 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`
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
"codeowners": ["@alexeidolgolyov"],
|
"codeowners": ["@alexeidolgolyov"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"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",
|
"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"],
|
"requirements": ["aiohttp>=3.9.0"],
|
||||||
"version": "0.2.0"
|
"version": "0.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
54
server/.env.example
Normal file
54
server/.env.example
Normal 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
|
||||||
270
server/CLAUDE.md
270
server/CLAUDE.md
@@ -1,212 +1,76 @@
|
|||||||
# Claude Instructions for WLED Screen Controller Server
|
# 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.
|
Each entity follows: dataclass model (`storage/`) + JSON store (`storage/*_store.py`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`).
|
||||||
|
|
||||||
#### 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()">×</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
|
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Server uses API key authentication. Keys are configured in:
|
Server uses API key authentication via Bearer token in `Authorization` header.
|
||||||
- `config/default_config.yaml` under `auth.api_keys`
|
|
||||||
- Or via environment variables: `WLED_AUTH__API_KEYS`
|
|
||||||
|
|
||||||
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).
|
||||||
|
|||||||
@@ -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 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies for screen capture
|
# Install system dependencies for screen capture and health check
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
libxcb1 \
|
libxcb1 \
|
||||||
libxcb-randr0 \
|
libxcb-randr0 \
|
||||||
libxcb-shm0 \
|
libxcb-shm0 \
|
||||||
@@ -14,21 +30,35 @@ RUN apt-get update && apt-get install -y \
|
|||||||
libxcb-shape0 \
|
libxcb-shape0 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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 .
|
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 src/ ./src/
|
||||||
COPY config/ ./config/
|
COPY config/ ./config/
|
||||||
RUN pip install --no-cache-dir ".[notifications]"
|
|
||||||
|
|
||||||
# Create directories for data and logs
|
# Copy built frontend bundle from stage 1
|
||||||
RUN mkdir -p /app/data /app/logs
|
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 API port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
CMD python -c "import httpx; httpx.get('http://localhost:8080/health', timeout=5.0)" || exit 1
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
# Set Python path
|
# Set Python path
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
|
|||||||
@@ -188,4 +188,4 @@ MIT - see [../LICENSE](../LICENSE)
|
|||||||
## Support
|
## Support
|
||||||
|
|
||||||
- 📖 [Full Documentation](../docs/)
|
- 📖 [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)
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ server:
|
|||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 8080
|
port: 8080
|
||||||
log_level: "INFO"
|
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:
|
cors_origins:
|
||||||
- "*"
|
- "http://localhost:8080"
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
# API keys are REQUIRED - authentication is always enforced
|
# API keys are REQUIRED - authentication is always enforced
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ server:
|
|||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 8081
|
port: 8081
|
||||||
log_level: "INFO"
|
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:
|
cors_origins:
|
||||||
- "*"
|
- "http://localhost:8081"
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
api_keys:
|
api_keys:
|
||||||
|
|||||||
@@ -1,41 +1,54 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
wled-controller:
|
wled-controller:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: ledgrab:latest
|
||||||
container_name: wled-screen-controller
|
container_name: wled-screen-controller
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "${WLED_PORT:-8080}:8080"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Persist device data
|
# Persist device data and configuration across restarts
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
# Persist logs
|
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
# Mount configuration (optional override)
|
# Mount configuration for easy editing without rebuild
|
||||||
- ./config:/app/config
|
- ./config:/app/config:ro
|
||||||
# Required for screen capture on Linux
|
# Required for screen capture on Linux (X11)
|
||||||
- /tmp/.X11-unix:/tmp/.X11-unix:ro
|
- /tmp/.X11-unix:/tmp/.X11-unix:ro
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
# Server configuration
|
## Server
|
||||||
|
# Bind address and port (usually no need to change)
|
||||||
- WLED_SERVER__HOST=0.0.0.0
|
- WLED_SERVER__HOST=0.0.0.0
|
||||||
- WLED_SERVER__PORT=8080
|
- WLED_SERVER__PORT=8080
|
||||||
- WLED_SERVER__LOG_LEVEL=INFO
|
- 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}
|
- DISPLAY=${DISPLAY:-:0}
|
||||||
|
|
||||||
# Processing defaults
|
## Processing defaults
|
||||||
- WLED_PROCESSING__DEFAULT_FPS=30
|
#- WLED_PROCESSING__DEFAULT_FPS=30
|
||||||
- WLED_PROCESSING__BORDER_WIDTH=10
|
#- WLED_PROCESSING__BORDER_WIDTH=10
|
||||||
|
|
||||||
# Use host network for screen capture access
|
## MQTT (optional — for Home Assistant auto-discovery)
|
||||||
# network_mode: host # Uncomment for Linux screen capture
|
#- 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:
|
networks:
|
||||||
- wled-network
|
- wled-network
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "wled-screen-controller"
|
name = "wled-screen-controller"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "WLED ambient lighting controller based on screen content"
|
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||||
]
|
]
|
||||||
@@ -75,6 +75,7 @@ perf = [
|
|||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||||
Repository = "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"
|
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from .routes.system import router as system_router
|
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.devices import router as devices_router
|
||||||
from .routes.templates import router as templates_router
|
from .routes.templates import router as templates_router
|
||||||
from .routes.postprocessing import router as postprocessing_router
|
from .routes.postprocessing import router as postprocessing_router
|
||||||
from .routes.picture_sources import router as picture_sources_router
|
from .routes.picture_sources import router as picture_sources_router
|
||||||
from .routes.pattern_templates import router as pattern_templates_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 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.color_strip_sources import router as color_strip_sources_router
|
||||||
from .routes.audio import router as audio_router
|
from .routes.audio import router as audio_router
|
||||||
from .routes.audio_sources import router as audio_sources_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 = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
|
router.include_router(backup_router)
|
||||||
|
router.include_router(system_settings_router)
|
||||||
router.include_router(devices_router)
|
router.include_router(devices_router)
|
||||||
router.include_router(templates_router)
|
router.include_router(templates_router)
|
||||||
router.include_router(postprocessing_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(audio_templates_router)
|
||||||
router.include_router(value_sources_router)
|
router.include_router(value_sources_router)
|
||||||
router.include_router(output_targets_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(automations_router)
|
||||||
router.include_router(scene_presets_router)
|
router.include_router(scene_presets_router)
|
||||||
router.include_router(webhooks_router)
|
router.include_router(webhooks_router)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Shared helpers for WebSocket-based capture test endpoints."""
|
"""Shared helpers for WebSocket-based capture preview endpoints."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
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]
|
scale = max_width / image.shape[1]
|
||||||
new_h = int(image.shape[0] * scale)
|
new_h = int(image.shape[0] * scale)
|
||||||
image = cv2.resize(image, (max_width, new_h), interpolation=cv2.INTER_AREA)
|
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)
|
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
||||||
_, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
_, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
||||||
return buf.tobytes()
|
return buf.tobytes()
|
||||||
@@ -124,7 +124,7 @@ async def stream_capture_test(
|
|||||||
continue
|
continue
|
||||||
total_capture_time += t1 - t0
|
total_capture_time += t1 - t0
|
||||||
frame_count += 1
|
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):
|
if isinstance(capture.image, np.ndarray):
|
||||||
latest_frame = Image.fromarray(capture.image)
|
latest_frame = Image.fromarray(capture.image)
|
||||||
else:
|
else:
|
||||||
395
server/src/wled_controller/api/routes/backup.py
Normal file
395
server/src/wled_controller/api/routes/backup.py
Normal 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}
|
||||||
@@ -16,6 +16,7 @@ from wled_controller.api.dependencies import (
|
|||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.devices import (
|
from wled_controller.api.schemas.devices import (
|
||||||
|
BrightnessRequest,
|
||||||
DeviceCreate,
|
DeviceCreate,
|
||||||
DeviceListResponse,
|
DeviceListResponse,
|
||||||
DeviceResponse,
|
DeviceResponse,
|
||||||
@@ -25,6 +26,7 @@ from wled_controller.api.schemas.devices import (
|
|||||||
DiscoverDevicesResponse,
|
DiscoverDevicesResponse,
|
||||||
OpenRGBZoneResponse,
|
OpenRGBZoneResponse,
|
||||||
OpenRGBZonesResponse,
|
OpenRGBZonesResponse,
|
||||||
|
PowerRequest,
|
||||||
)
|
)
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
@@ -53,18 +55,19 @@ def _device_to_response(device) -> DeviceResponse:
|
|||||||
zone_mode=device.zone_mode,
|
zone_mode=device.zone_mode,
|
||||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||||
tags=device.tags,
|
tags=device.tags,
|
||||||
dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'),
|
dmx_protocol=device.dmx_protocol,
|
||||||
dmx_start_universe=getattr(device, 'dmx_start_universe', 0),
|
dmx_start_universe=device.dmx_start_universe,
|
||||||
dmx_start_channel=getattr(device, 'dmx_start_channel', 1),
|
dmx_start_channel=device.dmx_start_channel,
|
||||||
espnow_peer_mac=getattr(device, 'espnow_peer_mac', ''),
|
espnow_peer_mac=device.espnow_peer_mac,
|
||||||
espnow_channel=getattr(device, 'espnow_channel', 1),
|
espnow_channel=device.espnow_channel,
|
||||||
hue_username=getattr(device, 'hue_username', ''),
|
hue_username=device.hue_username,
|
||||||
hue_client_key=getattr(device, 'hue_client_key', ''),
|
hue_client_key=device.hue_client_key,
|
||||||
hue_entertainment_group_id=getattr(device, 'hue_entertainment_group_id', ''),
|
hue_entertainment_group_id=device.hue_entertainment_group_id,
|
||||||
spi_speed_hz=getattr(device, 'spi_speed_hz', 800000),
|
spi_speed_hz=device.spi_speed_hz,
|
||||||
spi_led_type=getattr(device, 'spi_led_type', 'WS2812B'),
|
spi_led_type=device.spi_led_type,
|
||||||
chroma_device_type=getattr(device, 'chroma_device_type', 'chromalink'),
|
chroma_device_type=device.chroma_device_type,
|
||||||
gamesense_device_type=getattr(device, 'gamesense_device_type', 'keyboard'),
|
gamesense_device_type=device.gamesense_device_type,
|
||||||
|
default_css_processing_template_id=device.default_css_processing_template_id,
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_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"])
|
@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
||||||
async def set_device_brightness(
|
async def set_device_brightness(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
body: dict,
|
body: BrightnessRequest,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
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):
|
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")
|
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||||
|
|
||||||
bri = body.get("brightness")
|
bri = body.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")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
@@ -581,7 +582,7 @@ async def get_device_power(
|
|||||||
@router.put("/api/v1/devices/{device_id}/power", tags=["Settings"])
|
@router.put("/api/v1/devices/{device_id}/power", tags=["Settings"])
|
||||||
async def set_device_power(
|
async def set_device_power(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
body: dict,
|
body: PowerRequest,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
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):
|
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")
|
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
|
||||||
|
|
||||||
on = body.get("on")
|
on = body.power
|
||||||
if on is None or not isinstance(on, bool):
|
|
||||||
raise HTTPException(status_code=400, detail="'on' must be a boolean")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# For serial devices, use the cached idle client to avoid port conflicts
|
# For serial devices, use the cached idle client to avoid port conflicts
|
||||||
|
|||||||
@@ -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 asyncio
|
||||||
import base64
|
|
||||||
import io
|
|
||||||
import time
|
|
||||||
|
|
||||||
import numpy as np
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
from fastapi import Query as QueryParam
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
fire_entity_event,
|
fire_entity_event,
|
||||||
get_color_strip_store,
|
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_pattern_template_store,
|
|
||||||
get_picture_source_store,
|
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_pp_template_store,
|
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
get_template_store,
|
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.output_targets import (
|
from wled_controller.api.schemas.output_targets import (
|
||||||
BulkTargetRequest,
|
|
||||||
BulkTargetResponse,
|
|
||||||
ExtractedColorResponse,
|
|
||||||
KCTestRectangleResponse,
|
|
||||||
KCTestResponse,
|
|
||||||
KeyColorsResponse,
|
|
||||||
KeyColorsSettingsSchema,
|
KeyColorsSettingsSchema,
|
||||||
OutputTargetCreate,
|
OutputTargetCreate,
|
||||||
OutputTargetListResponse,
|
OutputTargetListResponse,
|
||||||
OutputTargetResponse,
|
OutputTargetResponse,
|
||||||
OutputTargetUpdate,
|
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.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 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.wled_output_target import WledOutputTarget
|
||||||
from wled_controller.storage.key_colors_output_target import (
|
from wled_controller.storage.key_colors_output_target import (
|
||||||
KeyColorsSettings,
|
KeyColorsSettings,
|
||||||
@@ -326,7 +295,7 @@ async def update_target(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Device change requires async stop → swap → start cycle
|
# Device change requires async stop -> swap -> start cycle
|
||||||
if data.device_id is not None:
|
if data.device_id is not None:
|
||||||
try:
|
try:
|
||||||
await manager.update_target_device(target_id, target.device_id)
|
await manager.update_target_device(target_id, target.device_id)
|
||||||
@@ -377,795 +346,3 @@ async def delete_target(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete target: {e}")
|
logger.error(f"Failed to delete target: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(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))
|
|
||||||
|
|||||||
341
server/src/wled_controller/api/routes/output_targets_control.py
Normal file
341
server/src/wled_controller/api/routes/output_targets_control.py
Normal 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)
|
||||||
@@ -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)
|
||||||
@@ -584,7 +584,7 @@ async def test_picture_source_ws(
|
|||||||
preview_width: int = Query(0),
|
preview_width: int = Query(0),
|
||||||
):
|
):
|
||||||
"""WebSocket for picture source test with intermediate frame previews."""
|
"""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,
|
authenticate_ws_token,
|
||||||
stream_capture_test,
|
stream_capture_test,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ async def test_pp_template_ws(
|
|||||||
preview_width: int = Query(0),
|
preview_width: int = Query(0),
|
||||||
):
|
):
|
||||||
"""WebSocket for PP template test with intermediate frame previews."""
|
"""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,
|
authenticate_ws_token,
|
||||||
stream_capture_test,
|
stream_capture_test,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 asyncio
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from wled_controller import __version__
|
from wled_controller import __version__
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
get_auto_backup_engine,
|
|
||||||
get_audio_source_store,
|
get_audio_source_store,
|
||||||
get_audio_template_store,
|
get_audio_template_store,
|
||||||
get_automation_store,
|
get_automation_store,
|
||||||
@@ -37,29 +32,22 @@ from wled_controller.api.dependencies import (
|
|||||||
get_value_source_store,
|
get_value_source_store,
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.system import (
|
from wled_controller.api.schemas.system import (
|
||||||
AutoBackupSettings,
|
|
||||||
AutoBackupStatusResponse,
|
|
||||||
BackupFileInfo,
|
|
||||||
BackupListResponse,
|
|
||||||
DisplayInfo,
|
DisplayInfo,
|
||||||
DisplayListResponse,
|
DisplayListResponse,
|
||||||
ExternalUrlRequest,
|
|
||||||
ExternalUrlResponse,
|
|
||||||
GpuInfo,
|
GpuInfo,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
LogLevelRequest,
|
|
||||||
LogLevelResponse,
|
|
||||||
MQTTSettingsRequest,
|
|
||||||
MQTTSettingsResponse,
|
|
||||||
PerformanceResponse,
|
PerformanceResponse,
|
||||||
ProcessListResponse,
|
ProcessListResponse,
|
||||||
RestoreResponse,
|
|
||||||
VersionResponse,
|
VersionResponse,
|
||||||
)
|
)
|
||||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
|
||||||
from wled_controller.config import get_config, is_demo_mode
|
from wled_controller.config import get_config, is_demo_mode
|
||||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
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__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -68,7 +56,6 @@ psutil.cpu_percent(interval=None)
|
|||||||
|
|
||||||
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
# 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.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:
|
def _get_cpu_name() -> str | None:
|
||||||
@@ -113,7 +100,7 @@ async def health_check():
|
|||||||
|
|
||||||
Returns basic health information including status, version, and timestamp.
|
Returns basic health information including status, version, and timestamp.
|
||||||
"""
|
"""
|
||||||
logger.info("Health check requested")
|
logger.debug("Health check requested")
|
||||||
|
|
||||||
return HealthResponse(
|
return HealthResponse(
|
||||||
status="healthy",
|
status="healthy",
|
||||||
@@ -129,7 +116,7 @@ async def get_version():
|
|||||||
|
|
||||||
Returns application version, Python version, and API version.
|
Returns application version, Python version, and API version.
|
||||||
"""
|
"""
|
||||||
logger.info("Version info requested")
|
logger.debug("Version info requested")
|
||||||
|
|
||||||
return VersionResponse(
|
return VersionResponse(
|
||||||
version=__version__,
|
version=__version__,
|
||||||
@@ -308,52 +295,6 @@ async def get_metrics_history(
|
|||||||
return manager.metrics_history.get_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"])
|
@router.get("/api/v1/system/api-keys", tags=["System"])
|
||||||
def list_api_keys(_: AuthRequired):
|
def list_api_keys(_: AuthRequired):
|
||||||
"""List API key labels (read-only; keys are defined in the YAML config file)."""
|
"""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()
|
for label, key in config.auth.api_keys.items()
|
||||||
]
|
]
|
||||||
return {"keys": keys, "count": len(keys)}
|
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)
|
|
||||||
|
|||||||
377
server/src/wled_controller/api/routes/system_settings.py
Normal file
377
server/src/wled_controller/api/routes/system_settings.py
Normal 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)
|
||||||
@@ -403,7 +403,7 @@ async def test_template_ws(
|
|||||||
Config is sent as the first client message (JSON with engine_type,
|
Config is sent as the first client message (JSON with engine_type,
|
||||||
engine_config, display_index, capture_duration).
|
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,
|
authenticate_ws_token,
|
||||||
stream_capture_test,
|
stream_capture_test,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ automations that have a webhook condition. No API-key auth is required —
|
|||||||
the secret token itself authenticates the caller.
|
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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from wled_controller.api.dependencies import get_automation_engine, get_automation_store
|
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__)
|
logger = get_logger(__name__)
|
||||||
router = APIRouter()
|
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):
|
class WebhookPayload(BaseModel):
|
||||||
action: str = Field(description="'activate' or 'deactivate'")
|
action: str = Field(description="'activate' or 'deactivate'")
|
||||||
@@ -30,10 +55,13 @@ class WebhookPayload(BaseModel):
|
|||||||
async def handle_webhook(
|
async def handle_webhook(
|
||||||
token: str,
|
token: str,
|
||||||
body: WebhookPayload,
|
body: WebhookPayload,
|
||||||
|
request: Request,
|
||||||
store: AutomationStore = Depends(get_automation_store),
|
store: AutomationStore = Depends(get_automation_store),
|
||||||
engine: AutomationEngine = Depends(get_automation_engine),
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
):
|
):
|
||||||
"""Receive a webhook call and set the corresponding condition state."""
|
"""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"):
|
if body.action not in ("activate", "deactivate"):
|
||||||
raise HTTPException(status_code=400, detail="action must be 'activate' or 'deactivate'")
|
raise HTTPException(status_code=400, detail="action must be 'activate' or 'deactivate'")
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,18 @@ class CalibrationTestModeResponse(BaseModel):
|
|||||||
device_id: str = Field(description="Device ID")
|
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):
|
class DeviceResponse(BaseModel):
|
||||||
"""Device information response."""
|
"""Device information response."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"""Target processing pipeline."""
|
"""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 (
|
from wled_controller.core.processing.target_processor import (
|
||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
ProcessingMetrics,
|
ProcessingMetrics,
|
||||||
@@ -10,7 +14,9 @@ from wled_controller.core.processing.target_processor import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DeviceInfo",
|
"DeviceInfo",
|
||||||
|
"DeviceState",
|
||||||
"ProcessingMetrics",
|
"ProcessingMetrics",
|
||||||
|
"ProcessorDependencies",
|
||||||
"ProcessorManager",
|
"ProcessorManager",
|
||||||
"TargetContext",
|
"TargetContext",
|
||||||
"TargetProcessor",
|
"TargetProcessor",
|
||||||
|
|||||||
144
server/src/wled_controller/core/processing/auto_restart.py
Normal file
144
server/src/wled_controller/core/processing/auto_restart.py
Normal 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),
|
||||||
|
})
|
||||||
145
server/src/wled_controller/core/processing/device_health.py
Normal file
145
server/src/wled_controller/core/processing/device_health.py
Normal 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)
|
||||||
189
server/src/wled_controller/core/processing/device_test_mode.py
Normal file
189
server/src/wled_controller/core/processing/device_test_mode.py
Normal 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)
|
||||||
@@ -8,13 +8,7 @@ from typing import Dict, List, Optional, Tuple
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from wled_controller.core.capture.calibration import CalibrationConfig
|
from wled_controller.core.capture.calibration import CalibrationConfig
|
||||||
from wled_controller.core.devices.led_client import (
|
from wled_controller.core.devices.led_client import DeviceHealth
|
||||||
DeviceHealth,
|
|
||||||
check_device_health,
|
|
||||||
create_led_client,
|
|
||||||
get_device_capabilities,
|
|
||||||
get_provider,
|
|
||||||
)
|
|
||||||
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
||||||
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
|
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
|
||||||
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
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.wled_target_processor import WledTargetProcessor
|
||||||
from wled_controller.core.processing.kc_target_processor import KCTargetProcessor
|
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
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
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
|
@dataclass
|
||||||
class _RestartState:
|
class ProcessorDependencies:
|
||||||
"""Per-target auto-restart tracking."""
|
"""Bundles all store and manager references needed by ProcessorManager.
|
||||||
attempts: int = 0
|
|
||||||
first_crash_time: float = 0.0
|
Keeps the constructor signature stable when new stores are added.
|
||||||
last_crash_time: float = 0.0
|
"""
|
||||||
restart_task: Optional[asyncio.Task] = None
|
|
||||||
enabled: bool = True # disabled on manual stop
|
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
|
@dataclass
|
||||||
@@ -79,51 +85,58 @@ class DeviceState:
|
|||||||
zone_mode: str = "combined"
|
zone_mode: str = "combined"
|
||||||
|
|
||||||
|
|
||||||
class ProcessorManager:
|
class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin):
|
||||||
"""Manages devices and delegates target processing to TargetProcessor instances.
|
"""Manages devices and delegates target processing to TargetProcessor instances.
|
||||||
|
|
||||||
Devices are registered for health monitoring.
|
Devices are registered for health monitoring.
|
||||||
Targets are registered for processing via polymorphic TargetProcessor subclasses.
|
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):
|
def __init__(self, deps: ProcessorDependencies):
|
||||||
"""Initialize processor manager."""
|
"""Initialize processor manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deps: Bundled store and manager references.
|
||||||
|
"""
|
||||||
self._devices: Dict[str, DeviceState] = {}
|
self._devices: Dict[str, DeviceState] = {}
|
||||||
self._processors: Dict[str, TargetProcessor] = {}
|
self._processors: Dict[str, TargetProcessor] = {}
|
||||||
self._idle_clients: Dict[str, object] = {} # device_id -> cached LEDClient
|
self._idle_clients: Dict[str, object] = {} # device_id -> cached LEDClient
|
||||||
self._health_monitoring_active = False
|
self._health_monitoring_active = False
|
||||||
self._http_client: Optional[httpx.AsyncClient] = None
|
self._http_client: Optional[httpx.AsyncClient] = None
|
||||||
self._picture_source_store = picture_source_store
|
self._picture_source_store = deps.picture_source_store
|
||||||
self._capture_template_store = capture_template_store
|
self._capture_template_store = deps.capture_template_store
|
||||||
self._pp_template_store = pp_template_store
|
self._pp_template_store = deps.pp_template_store
|
||||||
self._pattern_template_store = pattern_template_store
|
self._pattern_template_store = deps.pattern_template_store
|
||||||
self._device_store = device_store
|
self._device_store = deps.device_store
|
||||||
self._color_strip_store = color_strip_store
|
self._color_strip_store = deps.color_strip_store
|
||||||
self._audio_source_store = audio_source_store
|
self._audio_source_store = deps.audio_source_store
|
||||||
self._audio_template_store = audio_template_store
|
self._audio_template_store = deps.audio_template_store
|
||||||
self._value_source_store = value_source_store
|
self._value_source_store = deps.value_source_store
|
||||||
self._cspt_store = cspt_store
|
self._cspt_store = deps.cspt_store
|
||||||
self._live_stream_manager = LiveStreamManager(
|
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._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(
|
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,
|
live_stream_manager=self._live_stream_manager,
|
||||||
audio_capture_manager=self._audio_capture_manager,
|
audio_capture_manager=self._audio_capture_manager,
|
||||||
audio_source_store=audio_source_store,
|
audio_source_store=deps.audio_source_store,
|
||||||
audio_template_store=audio_template_store,
|
audio_template_store=deps.audio_template_store,
|
||||||
sync_clock_manager=sync_clock_manager,
|
sync_clock_manager=deps.sync_clock_manager,
|
||||||
cspt_store=cspt_store,
|
cspt_store=deps.cspt_store,
|
||||||
)
|
)
|
||||||
self._value_stream_manager = ValueStreamManager(
|
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_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,
|
live_stream_manager=self._live_stream_manager,
|
||||||
audio_template_store=audio_template_store,
|
audio_template_store=deps.audio_template_store,
|
||||||
) if value_source_store else None
|
) if deps.value_source_store else None
|
||||||
# Wire value stream manager into CSS stream manager for composite layer brightness
|
# 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._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
|
||||||
self._overlay_manager = OverlayManager()
|
self._overlay_manager = OverlayManager()
|
||||||
@@ -167,70 +180,37 @@ class ProcessorManager:
|
|||||||
get_device_info=self._get_device_info,
|
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]:
|
def _get_device_info(self, device_id: str) -> Optional[DeviceInfo]:
|
||||||
"""Create a DeviceInfo snapshot from the current device state."""
|
"""Create a DeviceInfo snapshot from the current device state."""
|
||||||
ds = self._devices.get(device_id)
|
ds = self._devices.get(device_id)
|
||||||
if ds is None:
|
if ds is None:
|
||||||
return None
|
return None
|
||||||
# Read device-specific fields from persistent storage
|
# Read device-specific fields from persistent storage
|
||||||
send_latency_ms = 0
|
extras = dict(self._DEVICE_FIELD_DEFAULTS)
|
||||||
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"
|
|
||||||
if self._device_store:
|
if self._device_store:
|
||||||
try:
|
try:
|
||||||
dev = self._device_store.get_device(ds.device_id)
|
dev = self._device_store.get_device(ds.device_id)
|
||||||
send_latency_ms = getattr(dev, "send_latency_ms", 0)
|
for key, default in self._DEVICE_FIELD_DEFAULTS.items():
|
||||||
rgbw = getattr(dev, "rgbw", False)
|
extras[key] = getattr(dev, key, default)
|
||||||
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")
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
device_id=ds.device_id,
|
device_id=ds.device_id, device_url=ds.device_url,
|
||||||
device_url=ds.device_url,
|
led_count=ds.led_count, device_type=ds.device_type,
|
||||||
led_count=ds.led_count,
|
baud_rate=ds.baud_rate, software_brightness=ds.software_brightness,
|
||||||
device_type=ds.device_type,
|
test_mode_active=ds.test_mode_active, zone_mode=ds.zone_mode,
|
||||||
baud_rate=ds.baud_rate,
|
auto_shutdown=ds.auto_shutdown, **extras,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== EVENT SYSTEM (state change notifications) =====
|
# ===== EVENT SYSTEM (state change notifications) =====
|
||||||
@@ -260,7 +240,7 @@ class ProcessorManager:
|
|||||||
self._http_client = httpx.AsyncClient(timeout=5)
|
self._http_client = httpx.AsyncClient(timeout=5)
|
||||||
return self._http_client
|
return self._http_client
|
||||||
|
|
||||||
# ===== DEVICE MANAGEMENT (health monitoring) =====
|
# ===== DEVICE MANAGEMENT =====
|
||||||
|
|
||||||
def add_device(
|
def add_device(
|
||||||
self,
|
self,
|
||||||
@@ -475,7 +455,7 @@ class ProcessorManager:
|
|||||||
async def update_target_device(self, target_id: str, device_id: str):
|
async def update_target_device(self, target_id: str, device_id: str):
|
||||||
"""Update the device for a target.
|
"""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.
|
cycle so the new device connection is established properly.
|
||||||
"""
|
"""
|
||||||
proc = self._get_processor(target_id)
|
proc = self._get_processor(target_id)
|
||||||
@@ -495,7 +475,7 @@ class ProcessorManager:
|
|||||||
if was_running:
|
if was_running:
|
||||||
await self.start_processing(target_id)
|
await self.start_processing(target_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Hot-switch complete for target %s → device %s",
|
"Hot-switch complete for target %s -> device %s",
|
||||||
target_id, device_id,
|
target_id, device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -742,272 +722,6 @@ class ProcessorManager:
|
|||||||
if proc:
|
if proc:
|
||||||
proc.remove_led_preview_client(ws)
|
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 =====
|
# ===== LIFECYCLE =====
|
||||||
|
|
||||||
async def stop_all(self):
|
async def stop_all(self):
|
||||||
@@ -1055,120 +769,6 @@ class ProcessorManager:
|
|||||||
|
|
||||||
logger.info("Stopped all processors")
|
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 =====
|
# ===== HELPERS =====
|
||||||
|
|
||||||
def has_device(self, device_id: str) -> bool:
|
def has_device(self, device_id: str) -> bool:
|
||||||
@@ -1179,10 +779,6 @@ class ProcessorManager:
|
|||||||
"""Get device state, returning None if not registered."""
|
"""Get device state, returning None if not registered."""
|
||||||
return self._devices.get(device_id)
|
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]:
|
def get_processor(self, target_id: str) -> Optional[TargetProcessor]:
|
||||||
"""Look up a processor by target_id, returning None if not found."""
|
"""Look up a processor by target_id, returning None if not found."""
|
||||||
return self._processors.get(target_id)
|
return self._processors.get(target_id)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from wled_controller import __version__
|
|||||||
from wled_controller.api import router
|
from wled_controller.api import router
|
||||||
from wled_controller.api.dependencies import init_dependencies
|
from wled_controller.api.dependencies import init_dependencies
|
||||||
from wled_controller.config import get_config
|
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 import DeviceStore
|
||||||
from wled_controller.storage.template_store import TemplateStore
|
from wled_controller.storage.template_store import TemplateStore
|
||||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
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)
|
sync_clock_manager = SyncClockManager(sync_clock_store)
|
||||||
|
|
||||||
processor_manager = ProcessorManager(
|
processor_manager = ProcessorManager(
|
||||||
picture_source_store=picture_source_store,
|
ProcessorDependencies(
|
||||||
capture_template_store=template_store,
|
picture_source_store=picture_source_store,
|
||||||
pp_template_store=pp_template_store,
|
capture_template_store=template_store,
|
||||||
pattern_template_store=pattern_template_store,
|
pp_template_store=pp_template_store,
|
||||||
device_store=device_store,
|
pattern_template_store=pattern_template_store,
|
||||||
color_strip_store=color_strip_store,
|
device_store=device_store,
|
||||||
audio_source_store=audio_source_store,
|
color_strip_store=color_strip_store,
|
||||||
value_source_store=value_source_store,
|
audio_source_store=audio_source_store,
|
||||||
audio_template_store=audio_template_store,
|
value_source_store=value_source_store,
|
||||||
sync_clock_manager=sync_clock_manager,
|
audio_template_store=audio_template_store,
|
||||||
cspt_store=cspt_store,
|
sync_clock_manager=sync_clock_manager,
|
||||||
|
cspt_store=cspt_store,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -128,10 +130,12 @@ async def lifespan(app: FastAPI):
|
|||||||
device_store=device_store,
|
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(
|
auto_backup_engine = AutoBackupEngine(
|
||||||
settings_path=Path("data/auto_backup_settings.json"),
|
settings_path=_data_dir / "auto_backup_settings.json",
|
||||||
backup_dir=Path("data/backups"),
|
backup_dir=_data_dir / "backups",
|
||||||
store_map=STORE_MAP,
|
store_map=STORE_MAP,
|
||||||
storage_config=config.storage,
|
storage_config=config.storage,
|
||||||
)
|
)
|
||||||
@@ -314,14 +318,17 @@ templates = Jinja2Templates(directory=str(templates_path))
|
|||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request, exc):
|
async def global_exception_handler(request, exc):
|
||||||
"""Global exception handler for unhandled errors."""
|
"""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(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={
|
content={
|
||||||
"error": "InternalServerError",
|
"error": "InternalServerError",
|
||||||
"message": "An unexpected error occurred",
|
"message": "Internal server error",
|
||||||
"detail": str(exc) if config.server.log_level == "DEBUG" else None,
|
"ref": ref_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -532,7 +532,6 @@ input:-webkit-autofill:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tag-input-dropdown {
|
.tag-input-dropdown {
|
||||||
display: none;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -545,6 +544,16 @@ input:-webkit-autofill:focus {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
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 {
|
.tag-dropdown-item {
|
||||||
@@ -668,11 +677,14 @@ textarea:focus-visible {
|
|||||||
z-index: var(--z-lightbox);
|
z-index: var(--z-lightbox);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 0;
|
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;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.icon-select-popup.open {
|
.icon-select-popup.open {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -816,17 +828,26 @@ textarea:focus-visible {
|
|||||||
/* ── Entity Palette (command-palette style selector) ─────── */
|
/* ── Entity Palette (command-palette style selector) ─────── */
|
||||||
|
|
||||||
.entity-palette-overlay {
|
.entity-palette-overlay {
|
||||||
display: none;
|
display: flex;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: var(--z-lightbox);
|
z-index: var(--z-lightbox);
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding-top: min(20vh, 120px);
|
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 {
|
.entity-palette-overlay.open {
|
||||||
display: flex;
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
.entity-palette {
|
.entity-palette {
|
||||||
width: min(500px, 90vw);
|
width: min(500px, 90vw);
|
||||||
@@ -838,6 +859,14 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
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 {
|
.entity-palette-search-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Layer 0: state
|
// Layer 0: state
|
||||||
import { apiKey, setApiKey, refreshInterval } from './core/state.ts';
|
import { apiKey, setApiKey, refreshInterval } from './core/state.ts';
|
||||||
import { Modal } from './core/modal.ts';
|
import { Modal } from './core/modal.ts';
|
||||||
|
import { queryEl } from './core/dom-utils.ts';
|
||||||
|
|
||||||
// Layer 1: api, i18n
|
// Layer 1: api, i18n
|
||||||
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.ts';
|
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.ts';
|
||||||
@@ -93,8 +94,8 @@ import {
|
|||||||
} from './features/automations.ts';
|
} from './features/automations.ts';
|
||||||
import {
|
import {
|
||||||
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
|
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
|
||||||
activateScenePreset, recaptureScenePreset, cloneScenePreset, deleteScenePreset,
|
activateScenePreset, cloneScenePreset, deleteScenePreset,
|
||||||
addSceneTarget, removeSceneTarget,
|
addSceneTarget,
|
||||||
} from './features/scene-presets.ts';
|
} from './features/scene-presets.ts';
|
||||||
|
|
||||||
// Layer 5: device-discovery, targets
|
// Layer 5: device-discovery, targets
|
||||||
@@ -380,17 +381,15 @@ Object.assign(window, {
|
|||||||
deleteAutomation,
|
deleteAutomation,
|
||||||
copyWebhookUrl,
|
copyWebhookUrl,
|
||||||
|
|
||||||
// scene presets
|
// scene presets (modal buttons stay on window; card actions migrated to event delegation)
|
||||||
openScenePresetCapture,
|
openScenePresetCapture,
|
||||||
editScenePreset,
|
editScenePreset,
|
||||||
saveScenePreset,
|
saveScenePreset,
|
||||||
closeScenePresetEditor,
|
closeScenePresetEditor,
|
||||||
activateScenePreset,
|
activateScenePreset,
|
||||||
recaptureScenePreset,
|
|
||||||
cloneScenePreset,
|
cloneScenePreset,
|
||||||
deleteScenePreset,
|
deleteScenePreset,
|
||||||
addSceneTarget,
|
addSceneTarget,
|
||||||
removeSceneTarget,
|
|
||||||
|
|
||||||
// device-discovery
|
// device-discovery
|
||||||
onDeviceTypeChanged,
|
onDeviceTypeChanged,
|
||||||
@@ -577,13 +576,13 @@ document.addEventListener('keydown', (e) => {
|
|||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
// Close in order: log overlay > overlay lightboxes > modals via stack
|
// 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') {
|
if (logOverlay && logOverlay.style.display !== 'none') {
|
||||||
closeLogOverlay();
|
closeLogOverlay();
|
||||||
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
} else if (queryEl('display-picker-lightbox')?.classList.contains('active')) {
|
||||||
closeDisplayPicker(null as any);
|
closeDisplayPicker();
|
||||||
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
|
} else if (queryEl('image-lightbox')?.classList.contains('active')) {
|
||||||
closeLightbox(null as any);
|
closeLightbox();
|
||||||
} else {
|
} else {
|
||||||
Modal.closeTopmost();
|
Modal.closeTopmost();
|
||||||
}
|
}
|
||||||
@@ -656,7 +655,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
initCommandPalette();
|
initCommandPalette();
|
||||||
|
|
||||||
// Setup form handler
|
// 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)
|
// Always monitor server connection (even before login)
|
||||||
loadServerInfo();
|
loadServerInfo();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
||||||
import { t } from './i18n.ts';
|
import { t } from './i18n.ts';
|
||||||
import { showToast } from './ui.ts';
|
import { showToast } from './ui.ts';
|
||||||
|
import { getEl, queryEl } from './dom-utils.ts';
|
||||||
|
|
||||||
export const API_BASE = '/api/v1';
|
export const API_BASE = '/api/v1';
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined as unknown as Response;
|
throw new Error('fetchWithAuth: unreachable code — retry loop exhausted');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeHtml(text: string) {
|
export function escapeHtml(text: string) {
|
||||||
@@ -188,8 +189,10 @@ export async function loadServerInfo() {
|
|||||||
const response = await fetch('/health', { signal: AbortSignal.timeout(5000) });
|
const response = await fetch('/health', { signal: AbortSignal.timeout(5000) });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
document.getElementById('version-number')!.textContent = `v${data.version}`;
|
const versionEl = queryEl('version-number');
|
||||||
document.getElementById('server-status')!.textContent = '●';
|
if (versionEl) versionEl.textContent = `v${data.version}`;
|
||||||
|
const statusEl = queryEl('server-status');
|
||||||
|
if (statusEl) statusEl.textContent = '●';
|
||||||
const wasOffline = _serverOnline === false;
|
const wasOffline = _serverOnline === false;
|
||||||
_setConnectionState(true);
|
_setConnectionState(true);
|
||||||
if (wasOffline) {
|
if (wasOffline) {
|
||||||
@@ -263,11 +266,13 @@ export function configureApiKey() {
|
|||||||
if (key === '') {
|
if (key === '') {
|
||||||
localStorage.removeItem('wled_api_key');
|
localStorage.removeItem('wled_api_key');
|
||||||
setApiKey(null);
|
setApiKey(null);
|
||||||
document.getElementById('api-key-btn')!.style.display = 'none';
|
const keyBtnHide = queryEl('api-key-btn');
|
||||||
|
if (keyBtnHide) keyBtnHide.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('wled_api_key', key);
|
localStorage.setItem('wled_api_key', key);
|
||||||
setApiKey(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();
|
loadServerInfo();
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ void main() {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let _canvas, _gl, _prog;
|
let _canvas: HTMLCanvasElement = undefined as any, _gl: WebGLRenderingContext | null = null, _prog: WebGLProgram | null = null;
|
||||||
let _uTime, _uRes, _uAccent, _uBg, _uLight, _uParticlesBase;
|
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 _particleBuf: Float32Array | null = null; // pre-allocated Float32Array for uniform3fv
|
||||||
let _raf: number | null = null;
|
let _raf: number | null = null;
|
||||||
let _startTime = 0;
|
let _startTime = 0;
|
||||||
@@ -116,7 +116,8 @@ let _bgColor = [26 / 255, 26 / 255, 26 / 255];
|
|||||||
let _isLight = 0.0;
|
let _isLight = 0.0;
|
||||||
|
|
||||||
// Particle state (CPU-side, positions in 0..1 UV space)
|
// 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 {
|
function _initParticles(): void {
|
||||||
_particles.length = 0;
|
_particles.length = 0;
|
||||||
@@ -144,6 +145,7 @@ function _updateParticles(): void {
|
|||||||
|
|
||||||
function _compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
|
function _compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
|
||||||
const s = gl.createShader(type);
|
const s = gl.createShader(type);
|
||||||
|
if (!s) return null;
|
||||||
gl.shaderSource(s, src);
|
gl.shaderSource(s, src);
|
||||||
gl.compileShader(s);
|
gl.compileShader(s);
|
||||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
||||||
@@ -162,9 +164,9 @@ function _initGL(): boolean {
|
|||||||
const fs = _compile(gl, gl.FRAGMENT_SHADER, FRAG_SRC);
|
const fs = _compile(gl, gl.FRAGMENT_SHADER, FRAG_SRC);
|
||||||
if (!vs || !fs) return false;
|
if (!vs || !fs) return false;
|
||||||
|
|
||||||
_prog = gl.createProgram();
|
_prog = gl.createProgram()!;
|
||||||
gl.attachShader(_prog, vs);
|
gl.attachShader(_prog!, vs);
|
||||||
gl.attachShader(_prog, fs);
|
gl.attachShader(_prog!, fs);
|
||||||
gl.linkProgram(_prog);
|
gl.linkProgram(_prog);
|
||||||
if (!gl.getProgramParameter(_prog, gl.LINK_STATUS)) {
|
if (!gl.getProgramParameter(_prog, gl.LINK_STATUS)) {
|
||||||
console.error('Program link:', gl.getProgramInfoLog(_prog));
|
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.uniform3f(_uBg, _bgColor[0], _bgColor[1], _bgColor[2]);
|
||||||
gl.uniform1f(_uLight, _isLight);
|
gl.uniform1f(_uLight, _isLight);
|
||||||
|
|
||||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
if (_particleBuf) {
|
||||||
const p = _particles[i];
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||||
const off = i * 3;
|
const p = _particles[i];
|
||||||
_particleBuf[off] = p.x;
|
const off = i * 3;
|
||||||
_particleBuf[off + 1] = p.y;
|
_particleBuf[off] = p.x;
|
||||||
_particleBuf[off + 2] = p.r;
|
_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);
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
}
|
}
|
||||||
@@ -259,8 +263,9 @@ export function updateBgAnimTheme(isDark: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initBgAnim(): void {
|
export function initBgAnim(): void {
|
||||||
_canvas = document.getElementById('bg-anim-canvas');
|
const canvasEl = document.getElementById('bg-anim-canvas') as HTMLCanvasElement | null;
|
||||||
if (!_canvas) return;
|
if (!canvasEl) return;
|
||||||
|
_canvas = canvasEl;
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
const on = document.documentElement.getAttribute('data-bg-anim') === 'on';
|
const on = document.documentElement.getAttribute('data-bg-anim') === 'on';
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function _render() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Select All checkbox
|
// 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();
|
if ((e.target as HTMLInputElement).checked) section.selectAll();
|
||||||
else section.deselectAll();
|
else section.deselectAll();
|
||||||
});
|
});
|
||||||
@@ -83,7 +83,7 @@ function _render() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Close button
|
// Close button
|
||||||
el.querySelector('.bulk-close').addEventListener('click', () => {
|
el.querySelector('.bulk-close')!.addEventListener('click', () => {
|
||||||
section.exitSelectionMode();
|
section.exitSelectionMode();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ async function _executeAction(actionKey) {
|
|||||||
const section = _activeSection;
|
const section = _activeSection;
|
||||||
if (!section) return;
|
if (!section) return;
|
||||||
|
|
||||||
const action = section.bulkActions.find(a => a.key === actionKey);
|
const action = section.bulkActions!.find(a => a.key === actionKey);
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
|
|
||||||
const keys = [...section._selected];
|
const keys = [...section._selected];
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const STORAGE_KEY = 'cardColors';
|
|||||||
const DEFAULT_SWATCH = '#808080';
|
const DEFAULT_SWATCH = '#808080';
|
||||||
|
|
||||||
function _getAll(): Record<string, string> {
|
function _getAll(): Record<string, string> {
|
||||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') || {}; }
|
||||||
catch { return {}; }
|
catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export function cardColorButton(entityId: string, cardAttr: string): string {
|
|||||||
if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : '';
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ function _onMove(e) {
|
|||||||
_cachedRect = card.getBoundingClientRect();
|
_cachedRect = card.getBoundingClientRect();
|
||||||
}
|
}
|
||||||
|
|
||||||
const x = e.clientX - _cachedRect.left;
|
const x = e.clientX - _cachedRect!.left;
|
||||||
const y = e.clientY - _cachedRect.top;
|
const y = e.clientY - _cachedRect!.top;
|
||||||
card.style.setProperty('--glare-x', `${x}px`);
|
card.style.setProperty('--glare-x', `${x}px`);
|
||||||
card.style.setProperty('--glare-y', `${y}px`);
|
card.style.setProperty('--glare-y', `${y}px`);
|
||||||
} else if (_active) {
|
} else if (_active) {
|
||||||
|
|||||||
@@ -343,12 +343,12 @@ export class CardSection {
|
|||||||
if (this.keyAttr) {
|
if (this.keyAttr) {
|
||||||
const existing = [...content.querySelectorAll(`[${this.keyAttr}]`)];
|
const existing = [...content.querySelectorAll(`[${this.keyAttr}]`)];
|
||||||
for (const card of existing) {
|
for (const card of existing) {
|
||||||
const key = card.getAttribute(this.keyAttr);
|
const key = card.getAttribute(this.keyAttr) ?? '';
|
||||||
if (!newMap.has(key)) {
|
if (!newMap.has(key)) {
|
||||||
card.remove();
|
card.remove();
|
||||||
removed.add(key!);
|
removed.add(key);
|
||||||
} else {
|
} else {
|
||||||
const newHtml = newMap.get(key);
|
const newHtml = newMap.get(key)!;
|
||||||
if ((card as any)._csHtml !== newHtml) {
|
if ((card as any)._csHtml !== newHtml) {
|
||||||
const tmp = document.createElement('div');
|
const tmp = document.createElement('div');
|
||||||
tmp.innerHTML = newHtml;
|
tmp.innerHTML = newHtml;
|
||||||
@@ -620,7 +620,7 @@ export class CardSection {
|
|||||||
const htmlMap = new Map(this._lastItems.map(i => [i.key, i.html]));
|
const htmlMap = new Map(this._lastItems.map(i => [i.key, i.html]));
|
||||||
const cards = content.querySelectorAll(`[${this.keyAttr}]`);
|
const cards = content.querySelectorAll(`[${this.keyAttr}]`);
|
||||||
cards.forEach(card => {
|
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);
|
if (htmlMap.has(key)) (card as any)._csHtml = htmlMap.get(key);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ let _items: any[] = [];
|
|||||||
let _filtered: any[] = [];
|
let _filtered: any[] = [];
|
||||||
let _selectedIdx = 0;
|
let _selectedIdx = 0;
|
||||||
let _loading = false;
|
let _loading = false;
|
||||||
|
let _inputDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const _INPUT_DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
// ─── Entity definitions: endpoint → palette items ───
|
// ─── Entity definitions: endpoint → palette items ───
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ function _mapEntities(data: any, mapFn: (item: any) => any) {
|
|||||||
|
|
||||||
function _buildItems(results: any[], states: any = {}) {
|
function _buildItems(results: any[], states: any = {}) {
|
||||||
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results;
|
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({
|
_mapEntities(devices, d => items.push({
|
||||||
name: d.name, detail: d.device_type, group: 'devices', icon: ICON_DEVICE,
|
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 })
|
fetchWithAuth(ep as string, { retry: false, timeout: 5000 })
|
||||||
.then((r: any) => r.ok ? r.json() : {})
|
.then((r: any) => r.ok ? r.json() : {})
|
||||||
.then((data: any) => data[key as string] || [])
|
.then((data: any) => data[key as string] || [])
|
||||||
.catch(() => [])),
|
.catch((): any[] => [])),
|
||||||
]);
|
]);
|
||||||
return _buildItems(results, statesData);
|
return _buildItems(results, statesData);
|
||||||
}
|
}
|
||||||
@@ -316,6 +318,10 @@ export async function openCommandPalette() {
|
|||||||
export function closeCommandPalette() {
|
export function closeCommandPalette() {
|
||||||
if (!_isOpen) return;
|
if (!_isOpen) return;
|
||||||
_isOpen = false;
|
_isOpen = false;
|
||||||
|
if (_inputDebounceTimer !== null) {
|
||||||
|
clearTimeout(_inputDebounceTimer);
|
||||||
|
_inputDebounceTimer = null;
|
||||||
|
}
|
||||||
const overlay = document.getElementById('command-palette')!;
|
const overlay = document.getElementById('command-palette')!;
|
||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
document.documentElement.classList.remove('modal-open');
|
document.documentElement.classList.remove('modal-open');
|
||||||
@@ -326,10 +332,14 @@ export function closeCommandPalette() {
|
|||||||
// ─── Event handlers ───
|
// ─── Event handlers ───
|
||||||
|
|
||||||
function _onInput() {
|
function _onInput() {
|
||||||
const input = document.getElementById('cp-input') as HTMLInputElement;
|
if (_inputDebounceTimer !== null) clearTimeout(_inputDebounceTimer);
|
||||||
_filtered = _filterItems(input.value.trim());
|
_inputDebounceTimer = setTimeout(() => {
|
||||||
_selectedIdx = 0;
|
_inputDebounceTimer = null;
|
||||||
_render();
|
const input = document.getElementById('cp-input') as HTMLInputElement;
|
||||||
|
_filtered = _filterItems(input.value.trim());
|
||||||
|
_selectedIdx = 0;
|
||||||
|
_render();
|
||||||
|
}, _INPUT_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _onKeydown(e: KeyboardEvent) {
|
function _onKeydown(e: KeyboardEvent) {
|
||||||
@@ -355,7 +365,7 @@ function _onKeydown(e: KeyboardEvent) {
|
|||||||
function _onClick(e: Event) {
|
function _onClick(e: Event) {
|
||||||
const row = (e.target as HTMLElement).closest('.cp-result') as HTMLElement | null;
|
const row = (e.target as HTMLElement).closest('.cp-result') as HTMLElement | null;
|
||||||
if (row) {
|
if (row) {
|
||||||
_selectedIdx = parseInt(row.dataset.cpIdx, 10);
|
_selectedIdx = parseInt(row.dataset.cpIdx ?? '0', 10);
|
||||||
_selectCurrent();
|
_selectCurrent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
23
server/src/wled_controller/static/js/core/dom-utils.ts
Normal file
23
server/src/wled_controller/static/js/core/dom-utils.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -63,7 +63,14 @@ function _invalidateAndReload(entityType) {
|
|||||||
if (oldData === newData) return;
|
if (oldData === newData) return;
|
||||||
if (Array.isArray(oldData) && Array.isArray(newData) &&
|
if (Array.isArray(oldData) && Array.isArray(newData) &&
|
||||||
oldData.length === newData.length &&
|
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];
|
const loader = ENTITY_LOADER_MAP[entityType];
|
||||||
if (loader) {
|
if (loader) {
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export class EntityPalette {
|
|||||||
this._resolve = resolve;
|
this._resolve = resolve;
|
||||||
this._items = items || [];
|
this._items = items || [];
|
||||||
this._currentValue = current;
|
this._currentValue = current;
|
||||||
this._allowNone = allowNone;
|
this._allowNone = allowNone ?? false;
|
||||||
this._noneLabel = noneLabel;
|
this._noneLabel = noneLabel ?? '';
|
||||||
|
|
||||||
this._input.placeholder = placeholder || '';
|
this._input.placeholder = placeholder || '';
|
||||||
this._input.value = '';
|
this._input.value = '';
|
||||||
@@ -219,7 +219,7 @@ export class EntitySelect {
|
|||||||
this._select = target;
|
this._select = target;
|
||||||
this._getItems = getItems;
|
this._getItems = getItems;
|
||||||
this._placeholder = placeholder || '';
|
this._placeholder = placeholder || '';
|
||||||
this._onChange = onChange;
|
this._onChange = onChange ?? null;
|
||||||
this._allowNone = allowNone || false;
|
this._allowNone = allowNone || false;
|
||||||
this._noneLabel = noneLabel || '—';
|
this._noneLabel = noneLabel || '—';
|
||||||
this._items = getItems();
|
this._items = getItems();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function startEventsWS() {
|
|||||||
if (!apiKey) return;
|
if (!apiKey) return;
|
||||||
|
|
||||||
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
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 {
|
try {
|
||||||
_ws = new WebSocket(url);
|
_ws = new WebSocket(url);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export class FilterListManager {
|
|||||||
const select = document.getElementById(this._selectId) as HTMLSelectElement;
|
const select = document.getElementById(this._selectId) as HTMLSelectElement;
|
||||||
const filterDefs = this._getFilterDefs();
|
const filterDefs = this._getFilterDefs();
|
||||||
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
|
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) {
|
for (const f of filterDefs) {
|
||||||
const name = this._getFilterName(f.filter_id);
|
const name = this._getFilterName(f.filter_id);
|
||||||
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
|
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
|
||||||
|
|||||||
@@ -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);
|
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
|
|
||||||
const url = entry.endpoint.replace('{id}', targetId);
|
const url = entry.endpoint!.replace('{id}', targetId);
|
||||||
const body = { [field]: newSourceId };
|
const body = { [field]: newSourceId };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function _renderEdge(edge: GraphEdge): SVGElement {
|
|||||||
const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`;
|
const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`;
|
||||||
// Always use port-aware bezier — ELK routes without port knowledge so
|
// Always use port-aware bezier — ELK routes without port knowledge so
|
||||||
// its bend points don't align with actual port positions.
|
// 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', {
|
const path = svgEl('path', {
|
||||||
class: cssClass,
|
class: cssClass,
|
||||||
@@ -201,8 +201,8 @@ export function highlightChain(edgeGroup: SVGGElement, nodeId: string, edges: Gr
|
|||||||
const chain = new Set([...upstream, ...downstream]);
|
const chain = new Set([...upstream, ...downstream]);
|
||||||
|
|
||||||
edgeGroup.querySelectorAll('.graph-edge').forEach(path => {
|
edgeGroup.querySelectorAll('.graph-edge').forEach(path => {
|
||||||
const from = path.getAttribute('data-from');
|
const from = path.getAttribute('data-from') ?? '';
|
||||||
const to = path.getAttribute('data-to');
|
const to = path.getAttribute('data-to') ?? '';
|
||||||
const inChain = chain.has(from) && chain.has(to);
|
const inChain = chain.has(from) && chain.has(to);
|
||||||
path.classList.toggle('highlighted', inChain);
|
path.classList.toggle('highlighted', inChain);
|
||||||
path.classList.toggle('dimmed', !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
|
// Dim flow-dot groups on non-chain edges
|
||||||
edgeGroup.querySelectorAll('.graph-edge-flow').forEach(g => {
|
edgeGroup.querySelectorAll('.graph-edge-flow').forEach(g => {
|
||||||
const from = g.getAttribute('data-from');
|
const from = g.getAttribute('data-from') ?? '';
|
||||||
const to = g.getAttribute('data-to');
|
const to = g.getAttribute('data-to') ?? '';
|
||||||
(g as SVGElement).style.opacity = (chain.has(from) && chain.has(to)) ? '' : '0.12';
|
(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);
|
const toNode = nodeMap.get(edge.to);
|
||||||
if (!fromNode || !toNode) continue;
|
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 => {
|
group.querySelectorAll(`.graph-edge[data-from="${edge.from}"][data-to="${edge.to}"]`).forEach(pathEl => {
|
||||||
if ((pathEl.getAttribute('data-field') || '') === (edge.field || '')) {
|
if ((pathEl.getAttribute('data-field') || '') === (edge.field || '')) {
|
||||||
pathEl.setAttribute('d', d);
|
pathEl.setAttribute('d', d);
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
|
|||||||
|
|
||||||
const nodeMap = new Map();
|
const nodeMap = new Map();
|
||||||
const nodeById = new Map(nodeList.map(n => [n.id, n]));
|
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);
|
const src = nodeById.get(child.id);
|
||||||
if (src) {
|
if (src) {
|
||||||
nodeMap.set(child.id, {
|
nodeMap.set(child.id, {
|
||||||
@@ -115,7 +115,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build edge paths from layout
|
// Build edge paths from layout
|
||||||
const edges = [];
|
const edges: any[] = [];
|
||||||
for (let i = 0; i < edgeList.length; i++) {
|
for (let i = 0; i < edgeList.length; i++) {
|
||||||
const layoutEdge = layout.edges?.[i];
|
const layoutEdge = layout.edges?.[i];
|
||||||
const srcEdge = edgeList[i];
|
const srcEdge = edgeList[i];
|
||||||
@@ -123,7 +123,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
|
|||||||
const toNode = nodeMap.get(srcEdge.to);
|
const toNode = nodeMap.get(srcEdge.to);
|
||||||
if (!fromNode || !toNode) continue;
|
if (!fromNode || !toNode) continue;
|
||||||
|
|
||||||
let points = null;
|
let points: any[] | null = null;
|
||||||
if ((layoutEdge as any)?.sections?.[0]) {
|
if ((layoutEdge as any)?.sections?.[0]) {
|
||||||
const sec = (layoutEdge as any).sections[0];
|
const sec = (layoutEdge as any).sections[0];
|
||||||
points = [sec.startPoint, ...(sec.bendPoints || []), sec.endPoint];
|
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 {
|
function addEdge(from: string, to: string, field: string, label: string = ''): void {
|
||||||
if (!from || !to || !nodeIds.has(from) || !nodeIds.has(to)) return;
|
if (!from || !to || !nodeIds.has(from) || !nodeIds.has(to)) return;
|
||||||
const type = edgeType(
|
const type = edgeType(
|
||||||
nodes.find(n => n.id === from)?.kind,
|
nodes.find(n => n.id === from)?.kind ?? '',
|
||||||
nodes.find(n => n.id === to)?.kind,
|
nodes.find(n => n.id === to)?.kind ?? '',
|
||||||
field
|
field
|
||||||
);
|
);
|
||||||
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
|
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ function _openNodeColorPicker(node: GraphNode, e: MouseEvent): void {
|
|||||||
cpOverlay.innerHTML = createColorPicker({
|
cpOverlay.innerHTML = createColorPicker({
|
||||||
id: pickerId,
|
id: pickerId,
|
||||||
currentColor: curColor || ENTITY_COLORS[node.kind] || '#666',
|
currentColor: curColor || ENTITY_COLORS[node.kind] || '#666',
|
||||||
onPick: null,
|
onPick: undefined,
|
||||||
anchor: 'left',
|
anchor: 'left',
|
||||||
showReset: true,
|
showReset: true,
|
||||||
resetColor: '#808080',
|
resetColor: '#808080',
|
||||||
@@ -475,7 +475,7 @@ function _createOverlay(node: GraphNode, nodeWidth: number, callbacks: NodeCallb
|
|||||||
bg.appendChild(iconG);
|
bg.appendChild(iconG);
|
||||||
} else {
|
} else {
|
||||||
const txt = svgEl('text', { x: bx + btnSize / 2, y: by + btnSize / 2 });
|
const txt = svgEl('text', { x: bx + btnSize / 2, y: by + btnSize / 2 });
|
||||||
txt.textContent = btn.icon;
|
txt.textContent = btn.icon ?? '';
|
||||||
bg.appendChild(txt);
|
bg.appendChild(txt);
|
||||||
}
|
}
|
||||||
const btnTip = svgEl('title');
|
const btnTip = svgEl('title');
|
||||||
@@ -543,7 +543,7 @@ export function patchNodeRunning(group: SVGGElement, node: GraphNode): void {
|
|||||||
/**
|
/**
|
||||||
* Highlight a single node (add class, scroll to).
|
* 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
|
// Remove existing highlights
|
||||||
group.querySelectorAll(`.graph-node.${cls}`).forEach(n => n.classList.remove(cls));
|
group.querySelectorAll(`.graph-node.${cls}`).forEach(n => n.classList.remove(cls));
|
||||||
const node = group.querySelector(`.graph-node[data-id="${nodeId}"]`);
|
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 {
|
export function updateSelection(group: SVGGElement, selectedIds: Set<string>): void {
|
||||||
group.querySelectorAll('.graph-node').forEach(n => {
|
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') ?? ''));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function updateLocaleSelect() {
|
|||||||
|
|
||||||
export function updateAllText() {
|
export function updateAllText() {
|
||||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
const key = el.getAttribute('data-i18n');
|
const key = el.getAttribute('data-i18n')!;
|
||||||
el.textContent = t(key);
|
el.textContent = t(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ export function updateAllText() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
|
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));
|
el.setAttribute('aria-label', t(key));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export function navigateToCard(tab: string, subTab: string | null, sectionKey: s
|
|||||||
|
|
||||||
// Expand section if collapsed
|
// Expand section if collapsed
|
||||||
if (sectionKey) {
|
if (sectionKey) {
|
||||||
const content = document.querySelector(`[data-cs-content="${sectionKey}"]`) as HTMLElement | null;
|
const content = document.querySelector(`[data-cs-content="${CSS.escape(sectionKey)}"]`) as HTMLElement | null;
|
||||||
const header = document.querySelector(`[data-cs-toggle="${sectionKey}"]`);
|
const header = document.querySelector(`[data-cs-toggle="${CSS.escape(sectionKey)}"]`);
|
||||||
if (content && content.style.display === 'none') {
|
if (content && content.style.display === 'none') {
|
||||||
content.style.display = '';
|
content.style.display = '';
|
||||||
const chevron = header?.querySelector('.cs-chevron') as HTMLElement | null;
|
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;
|
const scope = tabPanel || document;
|
||||||
|
|
||||||
// Check if card already exists (data previously loaded)
|
// Check if card already exists (data previously loaded)
|
||||||
const existing = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
|
const existing = scope.querySelector(`[${cardAttr}="${CSS.escape(cardValue)}"]`);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
_highlightCard(existing);
|
_highlightCard(existing);
|
||||||
return;
|
return;
|
||||||
@@ -111,12 +111,12 @@ function _showDimOverlay(duration: number) {
|
|||||||
function _waitForCard(cardAttr: string, cardValue: string, timeout: number, scope: Document | HTMLElement = document) {
|
function _waitForCard(cardAttr: string, cardValue: string, timeout: number, scope: Document | HTMLElement = document) {
|
||||||
const root = scope === document ? document.body : scope as HTMLElement;
|
const root = scope === document ? document.body : scope as HTMLElement;
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const card = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
|
const card = scope.querySelector(`[${cardAttr}="${CSS.escape(cardValue)}"]`);
|
||||||
if (card) { resolve(card); return; }
|
if (card) { resolve(card); return; }
|
||||||
|
|
||||||
const timer = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
|
const timer = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
|
||||||
const observer = new MutationObserver(() => {
|
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); }
|
if (el) { observer.disconnect(); clearTimeout(timer); resolve(el); }
|
||||||
});
|
});
|
||||||
observer.observe(root, { childList: true, subtree: true });
|
observer.observe(root, { childList: true, subtree: true });
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||||
SyncClock, Automation, Display, FilterDef, EngineInfo,
|
SyncClock, Automation, Display, FilterDef, EngineInfo,
|
||||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||||
ColorStripProcessingTemplate,
|
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||||
} from '../types.ts';
|
} from '../types.ts';
|
||||||
|
|
||||||
export let apiKey: string | null = null;
|
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; }
|
export function set_displayPickerSelectedIndex(v: number | null) { _displayPickerSelectedIndex = v; }
|
||||||
|
|
||||||
// Calibration
|
// Calibration
|
||||||
export const calibrationTestState: Record<string, any> = {};
|
export const calibrationTestState: Record<string, Set<string>> = {};
|
||||||
|
|
||||||
export const EDGE_TEST_COLORS: Record<string, number[]> = {
|
export const EDGE_TEST_COLORS: Record<string, number[]> = {
|
||||||
top: [255, 0, 0],
|
top: [255, 0, 0],
|
||||||
@@ -65,8 +65,8 @@ export function updateDeviceBrightness(deviceId: string, value: number) {
|
|||||||
export let _discoveryScanRunning = false;
|
export let _discoveryScanRunning = false;
|
||||||
export function set_discoveryScanRunning(v: boolean) { _discoveryScanRunning = v; }
|
export function set_discoveryScanRunning(v: boolean) { _discoveryScanRunning = v; }
|
||||||
|
|
||||||
export let _discoveryCache: Record<string, any> = {};
|
export let _discoveryCache: Record<string, any[]> = {};
|
||||||
export function set_discoveryCache(v: Record<string, any>) { _discoveryCache = v; }
|
export function set_discoveryCache(v: Record<string, any[]>) { _discoveryCache = v; }
|
||||||
|
|
||||||
// Streams / templates state
|
// Streams / templates state
|
||||||
export let _cachedStreams: PictureSource[] = [];
|
export let _cachedStreams: PictureSource[] = [];
|
||||||
@@ -90,15 +90,15 @@ export let _templateNameManuallyEdited = false;
|
|||||||
export function set_templateNameManuallyEdited(v: boolean) { _templateNameManuallyEdited = v; }
|
export function set_templateNameManuallyEdited(v: boolean) { _templateNameManuallyEdited = v; }
|
||||||
|
|
||||||
// PP template state
|
// PP template state
|
||||||
export let _modalFilters: any[] = [];
|
export let _modalFilters: FilterInstance[] = [];
|
||||||
export function set_modalFilters(v: any[]) { _modalFilters = v; }
|
export function set_modalFilters(v: FilterInstance[]) { _modalFilters = v; }
|
||||||
|
|
||||||
export let _ppTemplateNameManuallyEdited = false;
|
export let _ppTemplateNameManuallyEdited = false;
|
||||||
export function set_ppTemplateNameManuallyEdited(v: boolean) { _ppTemplateNameManuallyEdited = v; }
|
export function set_ppTemplateNameManuallyEdited(v: boolean) { _ppTemplateNameManuallyEdited = v; }
|
||||||
|
|
||||||
// CSPT (Color Strip Processing Template) state
|
// CSPT (Color Strip Processing Template) state
|
||||||
export let _csptModalFilters: any[] = [];
|
export let _csptModalFilters: FilterInstance[] = [];
|
||||||
export function set_csptModalFilters(v: any[]) { _csptModalFilters = v; }
|
export function set_csptModalFilters(v: FilterInstance[]) { _csptModalFilters = v; }
|
||||||
|
|
||||||
export let _csptNameManuallyEdited = false;
|
export let _csptNameManuallyEdited = false;
|
||||||
export function set_csptNameManuallyEdited(v: boolean) { _csptNameManuallyEdited = v; }
|
export function set_csptNameManuallyEdited(v: boolean) { _csptNameManuallyEdited = v; }
|
||||||
@@ -135,8 +135,17 @@ export const kcWebSockets: Record<string, WebSocket> = {};
|
|||||||
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
|
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
|
||||||
|
|
||||||
// Tutorial state
|
// Tutorial state
|
||||||
export let activeTutorial: any = null;
|
export interface TutorialState {
|
||||||
export function setActiveTutorial(v: any) { activeTutorial = v; }
|
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
|
// Confirm modal
|
||||||
export let confirmResolve: ((value: boolean) => void) | null = null;
|
export let confirmResolve: ((value: boolean) => void) | null = null;
|
||||||
@@ -162,8 +171,8 @@ export function setDashboardPollInterval(v: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pattern template editor state
|
// Pattern template editor state
|
||||||
export let patternEditorRects: any[] = [];
|
export let patternEditorRects: KeyColorRectangle[] = [];
|
||||||
export function setPatternEditorRects(v: any[]) { patternEditorRects = v; }
|
export function setPatternEditorRects(v: KeyColorRectangle[]) { patternEditorRects = v; }
|
||||||
|
|
||||||
export let patternEditorSelectedIdx = -1;
|
export let patternEditorSelectedIdx = -1;
|
||||||
export function setPatternEditorSelectedIdx(v: number) { patternEditorSelectedIdx = v; }
|
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 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 function setPatternCanvasDragStart(v: { x?: number; y?: number; mx?: number; my?: number } | null) { patternCanvasDragStart = v; }
|
||||||
|
|
||||||
export let patternCanvasDragOrigRect: any = null;
|
export let patternCanvasDragOrigRect: KeyColorRectangle | null = null;
|
||||||
export function setPatternCanvasDragOrigRect(v: any) { patternCanvasDragOrigRect = v; }
|
export function setPatternCanvasDragOrigRect(v: KeyColorRectangle | null) { patternCanvasDragOrigRect = v; }
|
||||||
|
|
||||||
export let patternEditorHoveredIdx = -1;
|
export let patternEditorHoveredIdx = -1;
|
||||||
export function setPatternEditorHoveredIdx(v: number) { patternEditorHoveredIdx = v; }
|
export function setPatternEditorHoveredIdx(v: number) { patternEditorHoveredIdx = v; }
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ export class TagInput {
|
|||||||
* @param {object} [opts]
|
* @param {object} [opts]
|
||||||
* @param {string} [opts.placeholder] Placeholder text for input
|
* @param {string} [opts.placeholder] Placeholder text for input
|
||||||
*/
|
*/
|
||||||
constructor(container: HTMLElement, opts: any = {}) {
|
constructor(container: HTMLElement | null, opts: any = {}) {
|
||||||
this._container = container;
|
this._container = container!;
|
||||||
this._tags = [];
|
this._tags = [];
|
||||||
this._placeholder = opts.placeholder || 'Add tag...';
|
this._placeholder = opts.placeholder || 'Add tag...';
|
||||||
this._dropdownVisible = false;
|
this._dropdownVisible = false;
|
||||||
@@ -212,13 +212,13 @@ export class TagInput {
|
|||||||
this._dropdownEl.innerHTML = suggestions.map((tag, i) =>
|
this._dropdownEl.innerHTML = suggestions.map((tag, i) =>
|
||||||
`<div class="tag-dropdown-item${i === 0 ? ' tag-dropdown-active' : ''}" data-tag="${_escapeHtml(tag)}">${_escapeHtml(tag)}</div>`
|
`<div class="tag-dropdown-item${i === 0 ? ' tag-dropdown-active' : ''}" data-tag="${_escapeHtml(tag)}">${_escapeHtml(tag)}</div>`
|
||||||
).join('');
|
).join('');
|
||||||
this._dropdownEl.style.display = 'block';
|
this._dropdownEl.classList.add('open');
|
||||||
this._dropdownVisible = true;
|
this._dropdownVisible = true;
|
||||||
this._selectedIdx = 0;
|
this._selectedIdx = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_hideDropdown() {
|
_hideDropdown() {
|
||||||
this._dropdownEl.style.display = 'none';
|
this._dropdownEl.classList.remove('open');
|
||||||
this._dropdownVisible = false;
|
this._dropdownVisible = false;
|
||||||
this._selectedIdx = -1;
|
this._selectedIdx = -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.ts';
|
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.ts';
|
||||||
|
import { API_BASE, getHeaders } from './api.ts';
|
||||||
import { t } from './i18n.ts';
|
import { t } from './i18n.ts';
|
||||||
|
|
||||||
/** Returns true on touch devices where auto-focus would pop up the virtual keyboard */
|
/** 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) {
|
export async function openFullImageLightbox(imageSource: string) {
|
||||||
try {
|
try {
|
||||||
const { API_BASE, getHeaders } = await import('./api.js');
|
|
||||||
const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, {
|
const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, {
|
||||||
headers: getHeaders()
|
headers: getHeaders()
|
||||||
});
|
});
|
||||||
@@ -323,7 +323,7 @@ export function showOverlaySpinner(text: string, duration = 0) {
|
|||||||
progressCircle.style.strokeDashoffset = String(offset);
|
progressCircle.style.strokeDashoffset = String(offset);
|
||||||
progressPercentage.textContent = `${percentage}%`;
|
progressPercentage.textContent = `${percentage}%`;
|
||||||
if (progress >= 1) {
|
if (progress >= 1) {
|
||||||
clearInterval(window.overlaySpinnerTimer);
|
clearInterval(window.overlaySpinnerTimer!);
|
||||||
window.overlaySpinnerTimer = null;
|
window.overlaySpinnerTimer = null;
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export function selectCalibrationLine(idx: number): void {
|
|||||||
const prev = _state.selectedLine;
|
const prev = _state.selectedLine;
|
||||||
_state.selectedLine = idx;
|
_state.selectedLine = idx;
|
||||||
// Update selection in-place without rebuilding the list DOM
|
// 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');
|
const items = container.querySelectorAll('.advcal-line-item');
|
||||||
if (prev >= 0 && prev < items.length) items[prev].classList.remove('selected');
|
if (prev >= 0 && prev < items.length) items[prev].classList.remove('selected');
|
||||||
if (idx >= 0 && idx < items.length) items[idx].classList.add('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
|
// Load saved positions from localStorage
|
||||||
const savedKey = `advcal_positions_${cssId}`;
|
const savedKey = `advcal_positions_${cssId}`;
|
||||||
let saved = {};
|
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 canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement;
|
||||||
const canvasW = canvas.width;
|
const canvasW = canvas.width;
|
||||||
const canvasH = canvas.height;
|
const canvasH = canvas.height;
|
||||||
|
|
||||||
// Default layout: arrange monitors in a row
|
// Default layout: arrange monitors in a row
|
||||||
const monitors = [];
|
const monitors: any[] = [];
|
||||||
const padding = 20;
|
const padding = 20;
|
||||||
const maxMonW = (canvasW - padding * 2) / Math.max(psList.length, 1) - 10;
|
const maxMonW = (canvasW - padding * 2) / Math.max(psList.length, 1) - 10;
|
||||||
const monH = canvasH * 0.6;
|
const monH = canvasH * 0.6;
|
||||||
@@ -423,7 +423,7 @@ function _placeNewMonitor(): void {
|
|||||||
|
|
||||||
function _updateTotalLeds(): void {
|
function _updateTotalLeds(): void {
|
||||||
const used = _state.lines.reduce((s, l) => s + l.led_count, 0);
|
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) {
|
if (_state.totalLedCount > 0) {
|
||||||
el.textContent = `${used}/${_state.totalLedCount}`;
|
el.textContent = `${used}/${_state.totalLedCount}`;
|
||||||
el.style.color = used > _state.totalLedCount ? 'var(--danger-color, #ff5555)' : '';
|
el.style.color = used > _state.totalLedCount ? 'var(--danger-color, #ff5555)' : '';
|
||||||
@@ -436,7 +436,7 @@ function _updateTotalLeds(): void {
|
|||||||
/* ── Line list rendering ────────────────────────────────────── */
|
/* ── Line list rendering ────────────────────────────────────── */
|
||||||
|
|
||||||
function _renderLineList(): void {
|
function _renderLineList(): void {
|
||||||
const container = document.getElementById('advcal-line-list');
|
const container = document.getElementById('advcal-line-list')!;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
_state.lines.forEach((line, i) => {
|
_state.lines.forEach((line, i) => {
|
||||||
@@ -470,7 +470,7 @@ function _renderLineList(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _showLineProps(): void {
|
function _showLineProps(): void {
|
||||||
const propsEl = document.getElementById('advcal-line-props');
|
const propsEl = document.getElementById('advcal-line-props')!;
|
||||||
const idx = _state.selectedLine;
|
const idx = _state.selectedLine;
|
||||||
if (idx < 0 || idx >= _state.lines.length) {
|
if (idx < 0 || idx >= _state.lines.length) {
|
||||||
propsEl.style.display = 'none';
|
propsEl.style.display = 'none';
|
||||||
@@ -553,7 +553,7 @@ function _fitView(): void {
|
|||||||
function _renderCanvas(): void {
|
function _renderCanvas(): void {
|
||||||
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
|
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d')!;
|
||||||
const W = canvas.width;
|
const W = canvas.width;
|
||||||
const H = canvas.height;
|
const H = canvas.height;
|
||||||
|
|
||||||
@@ -679,7 +679,7 @@ function _drawLineTicks(ctx: CanvasRenderingContext2D, line: CalibrationLine, x1
|
|||||||
return (line.reverse ? (1 - f) : f) * edgeLen;
|
return (line.reverse ? (1 - f) : f) * edgeLen;
|
||||||
};
|
};
|
||||||
|
|
||||||
const placed = [];
|
const placed: number[] = [];
|
||||||
|
|
||||||
// Place intermediate labels at nice steps
|
// Place intermediate labels at nice steps
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ function _sizeCanvas(canvas: HTMLCanvasElement) {
|
|||||||
canvas.width = rect.width * dpr;
|
canvas.width = rect.width * dpr;
|
||||||
canvas.height = 200 * dpr;
|
canvas.height = 200 * dpr;
|
||||||
canvas.style.height = '200px';
|
canvas.style.height = '200px';
|
||||||
canvas.getContext('2d').scale(dpr, dpr);
|
canvas.getContext('2d')!.scale(dpr, dpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderLoop() {
|
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() {
|
function _renderAudioSpectrum() {
|
||||||
const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement | null;
|
const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement | null;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d')!;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const w = canvas.width / dpr;
|
const w = canvas.width / dpr;
|
||||||
const h = canvas.height / dpr;
|
const h = canvas.height / dpr;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { IconSelect } from '../core/icon-select.ts';
|
|||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import { attachProcessPicker } from '../core/process-picker.ts';
|
import { attachProcessPicker } from '../core/process-picker.ts';
|
||||||
import { TreeNav } from '../core/tree-nav.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';
|
import type { Automation } from '../types.ts';
|
||||||
|
|
||||||
let _automationTagsInput: any = null;
|
let _automationTagsInput: any = null;
|
||||||
@@ -158,7 +158,7 @@ export async function loadAutomations() {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
console.error('Failed to load automations:', error);
|
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 {
|
} finally {
|
||||||
set_automationsLoading(false);
|
set_automationsLoading(false);
|
||||||
setTabRefreshing('automations-content', false);
|
setTabRefreshing('automations-content', false);
|
||||||
@@ -194,6 +194,9 @@ function renderAutomations(automations: any, sceneMap: any) {
|
|||||||
container.innerHTML = panels;
|
container.innerHTML = panels;
|
||||||
CardSection.bindAll([csAutomations, csScenes]);
|
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.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.update(treeItems, activeTab);
|
||||||
_automationsTree.observeSections('automations-content', {
|
_automationsTree.observeSections('automations-content', {
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export async function showCalibration(deviceId: any) {
|
|||||||
try {
|
try {
|
||||||
const [response, displays] = await Promise.all([
|
const [response, displays] = await Promise.all([
|
||||||
fetchWithAuth(`/devices/${deviceId}`),
|
fetchWithAuth(`/devices/${deviceId}`),
|
||||||
displaysCache.fetch().catch(() => []),
|
displaysCache.fetch().catch((): any[] => []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; }
|
if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; }
|
||||||
@@ -235,7 +235,7 @@ export async function showCSSCalibration(cssId: any) {
|
|||||||
try {
|
try {
|
||||||
const [cssSources, devices] = await Promise.all([
|
const [cssSources, devices] = await Promise.all([
|
||||||
colorStripSourcesCache.fetch(),
|
colorStripSourcesCache.fetch(),
|
||||||
devicesCache.fetch().catch(() => []),
|
devicesCache.fetch().catch((): any[] => []),
|
||||||
]);
|
]);
|
||||||
const source = cssSources.find((s: any) => s.id === cssId);
|
const source = cssSources.find((s: any) => s.id === cssId);
|
||||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||||
|
|||||||
@@ -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')}">⠇</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})">✕</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();
|
||||||
|
}
|
||||||
@@ -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})">✕</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">📋</button></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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
@@ -222,7 +222,7 @@ function _gradientRenderCanvas(): void {
|
|||||||
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
|
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
|
||||||
if (canvas.width !== W) canvas.width = W;
|
if (canvas.width !== W) canvas.width = W;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d')!;
|
||||||
const H = canvas.height;
|
const H = canvas.height;
|
||||||
const imgData = ctx.createImageData(W, H);
|
const imgData = ctx.createImageData(W, H);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
|
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
|
||||||
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
|
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
|
||||||
} from '../core/icons.ts';
|
} 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 { cardColorStyle } from '../core/card-colors.ts';
|
||||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||||
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation } from '../types.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 {
|
function _cacheUptimeElements(): void {
|
||||||
_uptimeElements = {};
|
_uptimeElements = {};
|
||||||
for (const id of _lastRunningIds) {
|
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;
|
if (el) _uptimeElements[id] = el;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
|
|||||||
if (!canvas) continue;
|
if (!canvas) continue;
|
||||||
const actualH = _fpsHistory[id] || [];
|
const actualH = _fpsHistory[id] || [];
|
||||||
const currentH = _fpsCurrentHistory[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);
|
_fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, actualH, currentH, fpsTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,9 +137,9 @@ function _cacheMetricsElements(runningIds: string[]): void {
|
|||||||
_metricsElements.clear();
|
_metricsElements.clear();
|
||||||
for (const id of runningIds) {
|
for (const id of runningIds) {
|
||||||
_metricsElements.set(id, {
|
_metricsElements.set(id, {
|
||||||
fps: document.querySelector(`[data-fps-text="${id}"]`),
|
fps: document.querySelector(`[data-fps-text="${CSS.escape(id)}"]`),
|
||||||
errors: document.querySelector(`[data-errors-text="${id}"]`),
|
errors: document.querySelector(`[data-errors-text="${CSS.escape(id)}"]`),
|
||||||
row: document.querySelector(`[data-target-id="${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)
|
// Update text values (use cached refs, fallback to querySelector)
|
||||||
const cached = _metricsElements.get(target.id);
|
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) {
|
if (fpsEl) {
|
||||||
const effFps = state.fps_effective;
|
const effFps = state.fps_effective;
|
||||||
const fpsTargetLabel = (effFps != null && effFps < fpsTarget)
|
const fpsTargetLabel = (effFps != null && effFps < fpsTarget)
|
||||||
@@ -192,13 +192,13 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
|
|||||||
+ `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
|
+ `<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); }
|
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
|
// Update health dot — prefer streaming reachability when processing
|
||||||
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
||||||
if (isLed) {
|
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) {
|
if (row) {
|
||||||
const dot = row.querySelector('.health-dot');
|
const dot = row.querySelector('.health-dot');
|
||||||
if (dot) {
|
if (dot) {
|
||||||
@@ -217,7 +217,7 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
|
|||||||
|
|
||||||
function _updateAutomationsInPlace(automations: Automation[]): void {
|
function _updateAutomationsInPlace(automations: Automation[]): void {
|
||||||
for (const a of automations) {
|
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;
|
if (!card) continue;
|
||||||
const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped');
|
const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
@@ -243,7 +243,7 @@ function _updateAutomationsInPlace(automations: Automation[]): void {
|
|||||||
|
|
||||||
function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
|
function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
|
||||||
for (const c of syncClocks) {
|
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;
|
if (!card) continue;
|
||||||
const speedEl = card.querySelector('.dashboard-clock-speed');
|
const speedEl = card.querySelector('.dashboard-clock-speed');
|
||||||
if (speedEl) speedEl.textContent = `${c.speed}x`;
|
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>`;
|
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 {
|
export function changeDashboardPollInterval(value: string | number): void {
|
||||||
const label = document.querySelector('.dashboard-poll-value');
|
const label = document.querySelector('.dashboard-poll-value');
|
||||||
if (label) label.textContent = `${value}s`;
|
if (label) label.textContent = `${value}s`;
|
||||||
@@ -307,7 +307,7 @@ export function changeDashboardPollInterval(value: string | number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _getCollapsedSections(): Record<string, boolean> {
|
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 {}; }
|
catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ export function toggleDashboardSection(sectionKey: string): void {
|
|||||||
const collapsed = _getCollapsedSections();
|
const collapsed = _getCollapsedSections();
|
||||||
collapsed[sectionKey] = !collapsed[sectionKey];
|
collapsed[sectionKey] = !collapsed[sectionKey];
|
||||||
localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed));
|
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;
|
if (!header) return;
|
||||||
const content = header.nextElementSibling;
|
const content = header.nextElementSibling;
|
||||||
const chevron = header.querySelector('.dashboard-section-chevron');
|
const chevron = header.querySelector('.dashboard-section-chevron');
|
||||||
@@ -379,10 +379,10 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
try {
|
try {
|
||||||
// Fire all requests in a single batch to avoid sequential RTTs
|
// Fire all requests in a single batch to avoid sequential RTTs
|
||||||
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
|
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
|
||||||
outputTargetsCache.fetch().catch(() => []),
|
outputTargetsCache.fetch().catch((): any[] => []),
|
||||||
fetchWithAuth('/automations').catch(() => null),
|
fetchWithAuth('/automations').catch(() => null),
|
||||||
devicesCache.fetch().catch(() => []),
|
devicesCache.fetch().catch((): any[] => []),
|
||||||
colorStripSourcesCache.fetch().catch(() => []),
|
colorStripSourcesCache.fetch().catch((): any[] => []),
|
||||||
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
||||||
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
||||||
loadScenePresets(),
|
loadScenePresets(),
|
||||||
@@ -403,7 +403,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
|
|
||||||
// Build dynamic HTML (targets, automations)
|
// Build dynamic HTML (targets, automations)
|
||||||
let dynamicHtml = '';
|
let dynamicHtml = '';
|
||||||
let runningIds = [];
|
let runningIds: any[] = [];
|
||||||
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) {
|
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) {
|
||||||
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
@@ -518,9 +518,11 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
</div>
|
</div>
|
||||||
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
|
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
|
||||||
await initPerfCharts();
|
await initPerfCharts();
|
||||||
|
// Event delegation for scene preset cards (attached once, works across innerHTML refreshes)
|
||||||
|
initScenePresetDelegation(container);
|
||||||
} else {
|
} else {
|
||||||
const dynamic = container.querySelector('.dashboard-dynamic');
|
const dynamic = container.querySelector('.dashboard-dynamic');
|
||||||
if (dynamic.innerHTML !== dynamicHtml) {
|
if (dynamic && dynamic.innerHTML !== dynamicHtml) {
|
||||||
dynamic.innerHTML = dynamicHtml;
|
dynamic.innerHTML = dynamicHtml;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -743,7 +745,7 @@ export async function dashboardStopAll(): Promise<void> {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const [allTargets, statesResp] = await Promise.all([
|
const [allTargets, statesResp] = await Promise.all([
|
||||||
outputTargetsCache.fetch().catch(() => []),
|
outputTargetsCache.fetch().catch((): any[] => []),
|
||||||
fetchWithAuth('/output-targets/batch/states'),
|
fetchWithAuth('/output-targets/batch/states'),
|
||||||
]);
|
]);
|
||||||
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
|
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
|
||||||
@@ -804,7 +806,7 @@ function _isDashboardActive(): boolean {
|
|||||||
return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard';
|
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 {
|
function _debouncedDashboardReload(forceFullRender: boolean = false): void {
|
||||||
if (!_isDashboardActive()) return;
|
if (!_isDashboardActive()) return;
|
||||||
clearTimeout(_eventDebounceTimer);
|
clearTimeout(_eventDebounceTimer);
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export async function turnOffDevice(deviceId: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pingDevice(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');
|
if (btn) btn.classList.add('spinning');
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
|
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);
|
const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url);
|
||||||
// Set zone mode radio from device
|
// Set zone mode radio from device
|
||||||
const savedMode = device.zone_mode || 'combined';
|
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;
|
if (modeRadio) modeRadio.checked = true;
|
||||||
_fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => {
|
_fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => {
|
||||||
// Re-snapshot after zones are loaded so dirty-check baseline includes them
|
// Re-snapshot after zones are loaded so dirty-check baseline includes them
|
||||||
@@ -536,7 +536,7 @@ export async function saveDeviceSettings() {
|
|||||||
|
|
||||||
// Brightness
|
// Brightness
|
||||||
export function updateBrightnessLabel(deviceId: any, value: any) {
|
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) + '%';
|
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,13 +569,13 @@ export async function fetchDeviceBrightness(deviceId: any) {
|
|||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
updateDeviceBrightness(deviceId, data.brightness);
|
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) {
|
if (slider) {
|
||||||
slider.value = data.brightness;
|
slider.value = data.brightness;
|
||||||
slider.title = Math.round(data.brightness / 255 * 100) + '%';
|
slider.title = Math.round(data.brightness / 255 * 100) + '%';
|
||||||
slider.disabled = false;
|
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');
|
if (wrap) wrap.classList.remove('brightness-loading');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Silently fail — device may be offline
|
// 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) {
|
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;
|
if (!card) return;
|
||||||
for (const zoneName of zones) {
|
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;
|
if (!badge) continue;
|
||||||
const ledCount = counts[zoneName.toLowerCase()];
|
const ledCount = counts[zoneName.toLowerCase()];
|
||||||
if (ledCount != null) {
|
if (ledCount != null) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
|
|||||||
set_displayPickerCallback(callback);
|
set_displayPickerCallback(callback);
|
||||||
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
|
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
|
||||||
_pickerEngineType = engineType || 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.)
|
// Use "Select a Device" title for engines with own display lists (camera, scrcpy, etc.)
|
||||||
const titleEl = lightbox.querySelector('.display-picker-title');
|
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) {
|
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
|
||||||
renderDisplayPickerLayout(_cachedDisplays);
|
renderDisplayPickerLayout(_cachedDisplays);
|
||||||
} else {
|
} else {
|
||||||
const canvas = document.getElementById('display-picker-canvas');
|
const canvas = document.getElementById('display-picker-canvas')!;
|
||||||
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||||
displaysCache.fetch().then(displays => {
|
displaysCache.fetch().then(displays => {
|
||||||
if (displays && displays.length > 0) {
|
if (displays && displays.length > 0) {
|
||||||
@@ -55,7 +55,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
|
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>';
|
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -133,7 +133,7 @@ window._adbConnectFromPicker = async function () {
|
|||||||
export function closeDisplayPicker(event?: Event): void {
|
export function closeDisplayPicker(event?: Event): void {
|
||||||
if (event && event.target && (event.target as HTMLElement).closest('.display-picker-content')) return;
|
if (event && event.target && (event.target as HTMLElement).closest('.display-picker-content')) return;
|
||||||
const lightbox = document.getElementById('display-picker-lightbox');
|
const lightbox = document.getElementById('display-picker-lightbox');
|
||||||
lightbox.classList.remove('active');
|
lightbox?.classList.remove('active');
|
||||||
set_displayPickerCallback(null);
|
set_displayPickerCallback(null);
|
||||||
_pickerEngineType = null;
|
_pickerEngineType = null;
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ export function selectDisplay(displayIndex: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderDisplayPickerLayout(displays: any[], engineType: string | null = null): 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) {
|
if (!displays || displays.length === 0) {
|
||||||
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ function _makeDraggable(el: HTMLElement, handle: HTMLElement, { loadFn, saveFn }
|
|||||||
_clampElementInContainer(el, container);
|
_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) => {
|
handle.addEventListener('pointerdown', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -279,7 +279,7 @@ function _makeDraggable(el: HTMLElement, handle: HTMLElement, { loadFn, saveFn }
|
|||||||
handle.setPointerCapture(e.pointerId);
|
handle.setPointerCapture(e.pointerId);
|
||||||
});
|
});
|
||||||
handle.addEventListener('pointermove', (e) => {
|
handle.addEventListener('pointermove', (e) => {
|
||||||
if (!dragStart) return;
|
if (!dragStart || !dragStartPos) return;
|
||||||
const cr = container.getBoundingClientRect();
|
const cr = container.getBoundingClientRect();
|
||||||
const ew = el.offsetWidth, eh = el.offsetHeight;
|
const ew = el.offsetWidth, eh = el.offsetHeight;
|
||||||
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
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
|
// Parse structured filters: type:device, tag:foo, running:true
|
||||||
let textPart = q;
|
let textPart = q;
|
||||||
const parsedKinds = new Set();
|
const parsedKinds = new Set<string>();
|
||||||
const parsedTags = [];
|
const parsedTags: string[] = [];
|
||||||
const tokens = q.split(/\s+/);
|
const tokens = q.split(/\s+/);
|
||||||
const plainTokens = [];
|
const plainTokens: string[] = [];
|
||||||
for (const tok of tokens) {
|
for (const tok of tokens) {
|
||||||
if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); }
|
if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); }
|
||||||
else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); }
|
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 nodeGroup = svgEl.querySelector('.graph-nodes') as SVGGElement;
|
||||||
const edgeGroup = svgEl.querySelector('.graph-edges') as SVGGElement;
|
const edgeGroup = svgEl.querySelector('.graph-edges') as SVGGElement;
|
||||||
|
|
||||||
renderEdges(edgeGroup, _edges);
|
renderEdges(edgeGroup, _edges!);
|
||||||
renderNodes(nodeGroup, _nodeMap, {
|
renderNodes(nodeGroup, _nodeMap!, {
|
||||||
onNodeClick: _onNodeClick,
|
onNodeClick: _onNodeClick,
|
||||||
onNodeDblClick: _onNodeDblClick,
|
onNodeDblClick: _onNodeDblClick,
|
||||||
onEditNode: _onEditNode,
|
onEditNode: _onEditNode,
|
||||||
@@ -732,14 +732,14 @@ function _renderGraph(container: HTMLElement): void {
|
|||||||
onCloneNode: _onCloneNode,
|
onCloneNode: _onCloneNode,
|
||||||
onActivatePreset: _onActivatePreset,
|
onActivatePreset: _onActivatePreset,
|
||||||
});
|
});
|
||||||
markOrphans(nodeGroup, _nodeMap, _edges);
|
markOrphans(nodeGroup, _nodeMap!, _edges!);
|
||||||
|
|
||||||
// Animated flow dots for running nodes
|
// Animated flow dots for running nodes
|
||||||
const runningIds = new Set<string>();
|
const runningIds = new Set<string>();
|
||||||
for (const node of _nodeMap.values()) {
|
for (const node of _nodeMap!.values()) {
|
||||||
if (node.running) runningIds.add(node.id);
|
if (node.running) runningIds.add(node.id);
|
||||||
}
|
}
|
||||||
renderFlowDots(edgeGroup, _edges, runningIds);
|
renderFlowDots(edgeGroup, _edges!, runningIds);
|
||||||
|
|
||||||
// Set bounds for view clamping, then fit
|
// Set bounds for view clamping, then fit
|
||||||
if (_bounds) _canvas.setBounds(_bounds);
|
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.addEventListener('keydown', _onKeydown);
|
||||||
container.setAttribute('tabindex', '0');
|
container.setAttribute('tabindex', '0');
|
||||||
container.style.outline = 'none';
|
container.style.outline = 'none';
|
||||||
@@ -1039,8 +1041,9 @@ function _initLegendDrag(legendEl: Element | null): void {
|
|||||||
|
|
||||||
/* ── Minimap (draggable header & resize handle) ── */
|
/* ── Minimap (draggable header & resize handle) ── */
|
||||||
|
|
||||||
function _initMinimap(mmEl: HTMLElement | null): void {
|
function _initMinimap(mmElArg: HTMLElement | null): void {
|
||||||
if (!mmEl || !_nodeMap || !_bounds) return;
|
if (!mmElArg || !_nodeMap || !_bounds) return;
|
||||||
|
const mmEl: HTMLElement = mmElArg;
|
||||||
const svg = mmEl.querySelector('svg') as SVGSVGElement | null;
|
const svg = mmEl.querySelector('svg') as SVGSVGElement | null;
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
const container = mmEl.closest('.graph-container') as HTMLElement;
|
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 {
|
function _initResizeHandle(rh: HTMLElement | null, corner: string): void {
|
||||||
if (!rh) return;
|
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) => {
|
rh.addEventListener('pointerdown', (e) => {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
rs = { x: e.clientX, y: e.clientY };
|
rs = { x: e.clientX, y: e.clientY };
|
||||||
@@ -1116,7 +1119,7 @@ function _initMinimap(mmEl: HTMLElement | null): void {
|
|||||||
rh.setPointerCapture(e.pointerId);
|
rh.setPointerCapture(e.pointerId);
|
||||||
});
|
});
|
||||||
rh.addEventListener('pointermove', (e) => {
|
rh.addEventListener('pointermove', (e) => {
|
||||||
if (!rs) return;
|
if (!rs || !rss) return;
|
||||||
const cr = container.getBoundingClientRect();
|
const cr = container.getBoundingClientRect();
|
||||||
const dy = e.clientY - rs.y;
|
const dy = e.clientY - rs.y;
|
||||||
const newH = Math.max(80, Math.min(cr.height - 20, rss.h + dy));
|
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';
|
const dock = saved?.dock || 'tl';
|
||||||
_applyToolbarDock(tbEl, container, dock, false);
|
_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) => {
|
handle.addEventListener('pointerdown', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1289,7 +1292,7 @@ function _initToolbarDrag(tbEl: HTMLElement | null): void {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
handle.addEventListener('pointermove', (e) => {
|
handle.addEventListener('pointermove', (e) => {
|
||||||
if (!dragStart) return;
|
if (!dragStart || !dragStartPos) return;
|
||||||
const cr = container.getBoundingClientRect();
|
const cr = container.getBoundingClientRect();
|
||||||
const ew = tbEl.offsetWidth, eh = tbEl.offsetHeight;
|
const ew = tbEl.offsetWidth, eh = tbEl.offsetHeight;
|
||||||
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
||||||
@@ -1410,7 +1413,7 @@ async function _bulkDeleteSelected(): Promise<void> {
|
|||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
for (const id of _selectedIds) {
|
for (const id of _selectedIds) {
|
||||||
const node = _nodeMap.get(id);
|
const node = _nodeMap?.get(id);
|
||||||
if (node) _onDeleteNode(node);
|
if (node) _onDeleteNode(node);
|
||||||
}
|
}
|
||||||
_selectedIds.clear();
|
_selectedIds.clear();
|
||||||
@@ -1506,10 +1509,12 @@ function _updateNodeRunning(nodeId: string, running: boolean): void {
|
|||||||
// Update flow dots since running set changed
|
// Update flow dots since running set changed
|
||||||
if (edgeGroup) {
|
if (edgeGroup) {
|
||||||
const runningIds = new Set<string>();
|
const runningIds = new Set<string>();
|
||||||
for (const n of _nodeMap.values()) {
|
if (_nodeMap) {
|
||||||
if (n.running) runningIds.add(n.id);
|
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();
|
_detachSelectedEdge();
|
||||||
} else if (_selectedIds.size === 1) {
|
} else if (_selectedIds.size === 1) {
|
||||||
const nodeId = [..._selectedIds][0];
|
const nodeId = [..._selectedIds][0];
|
||||||
const node = _nodeMap.get(nodeId);
|
const node = _nodeMap?.get(nodeId);
|
||||||
if (node) _onDeleteNode(node);
|
if (node) _onDeleteNode(node);
|
||||||
} else if (_selectedIds.size > 1) {
|
} else if (_selectedIds.size > 1) {
|
||||||
_bulkDeleteSelected();
|
_bulkDeleteSelected();
|
||||||
@@ -1614,13 +1619,13 @@ function _navigateDirection(dir: string): void {
|
|||||||
if (!_nodeMap || _nodeMap.size === 0) return;
|
if (!_nodeMap || _nodeMap.size === 0) return;
|
||||||
|
|
||||||
// Get current anchor node
|
// Get current anchor node
|
||||||
let anchor = null;
|
let anchor: any = null;
|
||||||
if (_selectedIds.size === 1) {
|
if (_selectedIds.size === 1) {
|
||||||
anchor = _nodeMap.get([..._selectedIds][0]);
|
anchor = _nodeMap.get([..._selectedIds][0]);
|
||||||
}
|
}
|
||||||
if (!anchor) {
|
if (!anchor) {
|
||||||
// Select first visible node (topmost-leftmost)
|
// Select first visible node (topmost-leftmost)
|
||||||
let best = null;
|
let best: any = null;
|
||||||
for (const n of _nodeMap.values()) {
|
for (const n of _nodeMap.values()) {
|
||||||
if (!best || n.y < best.y || (n.y === best.y && n.x < best.x)) best = n;
|
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 cx = anchor.x + anchor.width / 2;
|
||||||
const cy = anchor.y + anchor.height / 2;
|
const cy = anchor.y + anchor.height / 2;
|
||||||
let bestNode = null;
|
let bestNode: any = null;
|
||||||
let bestDist = Infinity;
|
let bestDist = Infinity;
|
||||||
|
|
||||||
for (const n of _nodeMap.values()) {
|
for (const n of _nodeMap.values()) {
|
||||||
@@ -1702,8 +1707,8 @@ function _selectAll(): void {
|
|||||||
/* ── Edge click ── */
|
/* ── Edge click ── */
|
||||||
|
|
||||||
function _onEdgeClick(edgePath: Element, nodeGroup: SVGGElement | null, edgeGroup: SVGGElement | null): void {
|
function _onEdgeClick(edgePath: Element, nodeGroup: SVGGElement | null, edgeGroup: SVGGElement | null): void {
|
||||||
const fromId = edgePath.getAttribute('data-from');
|
const fromId = edgePath.getAttribute('data-from') ?? '';
|
||||||
const toId = edgePath.getAttribute('data-to');
|
const toId = edgePath.getAttribute('data-to') ?? '';
|
||||||
const field = edgePath.getAttribute('data-field') || '';
|
const field = edgePath.getAttribute('data-field') || '';
|
||||||
|
|
||||||
// Track selected edge for Delete key detach
|
// Track selected edge for Delete key detach
|
||||||
@@ -1819,10 +1824,10 @@ function _onDragPointerMove(e: PointerEvent): void {
|
|||||||
node.x = item.startX + gdx;
|
node.x = item.startX + gdx;
|
||||||
node.y = item.startY + gdy;
|
node.y = item.startY + gdy;
|
||||||
if (item.el) item.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
|
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);
|
_updateMinimapNode(item.id, node);
|
||||||
}
|
}
|
||||||
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null, _nodeMap, _edges);
|
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null as any, _nodeMap!, _edges!);
|
||||||
} else {
|
} else {
|
||||||
const ds = _dragState as DragStateSingle;
|
const ds = _dragState as DragStateSingle;
|
||||||
const node = _nodeMap!.get(ds.nodeId);
|
const node = _nodeMap!.get(ds.nodeId);
|
||||||
@@ -1831,8 +1836,8 @@ function _onDragPointerMove(e: PointerEvent): void {
|
|||||||
node.y = ds.startNode.y + gdy;
|
node.y = ds.startNode.y + gdy;
|
||||||
ds.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
|
ds.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
|
||||||
if (edgeGroup) {
|
if (edgeGroup) {
|
||||||
updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap, _edges);
|
updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!);
|
||||||
updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap, _edges);
|
updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!);
|
||||||
}
|
}
|
||||||
_updateMinimapNode(ds.nodeId, node);
|
_updateMinimapNode(ds.nodeId, node);
|
||||||
}
|
}
|
||||||
@@ -1867,7 +1872,7 @@ function _onDragPointerUp(): void {
|
|||||||
if (edgeGroup && _edges && _nodeMap) {
|
if (edgeGroup && _edges && _nodeMap) {
|
||||||
const runningIds = new Set<string>();
|
const runningIds = new Set<string>();
|
||||||
for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); }
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
_rubberBand = {
|
_rubberBand = {
|
||||||
startGraph: _canvas.screenToGraph(e.clientX, e.clientY),
|
startGraph: _canvas!.screenToGraph(e.clientX, e.clientY),
|
||||||
startClient: { x: e.clientX, y: e.clientY },
|
startClient: { x: e.clientX, y: e.clientY },
|
||||||
active: false,
|
active: false,
|
||||||
};
|
};
|
||||||
@@ -1930,10 +1935,10 @@ function _onRubberBandUp(): void {
|
|||||||
const rect = document.querySelector('.graph-selection-rect') as SVGElement | null;
|
const rect = document.querySelector('.graph-selection-rect') as SVGElement | null;
|
||||||
|
|
||||||
if (_rubberBand.active && rect && _nodeMap) {
|
if (_rubberBand.active && rect && _nodeMap) {
|
||||||
const rx = parseFloat(rect.getAttribute('x'));
|
const rx = parseFloat(rect.getAttribute('x') ?? '0');
|
||||||
const ry = parseFloat(rect.getAttribute('y'));
|
const ry = parseFloat(rect.getAttribute('y') ?? '0');
|
||||||
const rw = parseFloat(rect.getAttribute('width'));
|
const rw = parseFloat(rect.getAttribute('width') ?? '0');
|
||||||
const rh = parseFloat(rect.getAttribute('height'));
|
const rh = parseFloat(rect.getAttribute('height') ?? '0');
|
||||||
|
|
||||||
_selectedIds.clear();
|
_selectedIds.clear();
|
||||||
for (const node of _nodeMap.values()) {
|
for (const node of _nodeMap.values()) {
|
||||||
@@ -2014,9 +2019,9 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const sourceNodeId = port.getAttribute('data-node-id');
|
const sourceNodeId = port.getAttribute('data-node-id') ?? '';
|
||||||
const sourceKind = port.getAttribute('data-node-kind');
|
const sourceKind = port.getAttribute('data-node-kind') ?? '';
|
||||||
const portType = port.getAttribute('data-port-type');
|
const portType = port.getAttribute('data-port-type') ?? '';
|
||||||
const sourceNode = _nodeMap?.get(sourceNodeId);
|
const sourceNode = _nodeMap?.get(sourceNodeId);
|
||||||
if (!sourceNode) return;
|
if (!sourceNode) return;
|
||||||
|
|
||||||
@@ -2029,7 +2034,7 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
|
|||||||
const dragPath = document.createElementNS(SVG_NS, 'path');
|
const dragPath = document.createElementNS(SVG_NS, 'path');
|
||||||
dragPath.setAttribute('class', 'graph-drag-edge');
|
dragPath.setAttribute('class', 'graph-drag-edge');
|
||||||
dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
|
dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
|
||||||
const root = svgEl.querySelector('.graph-root');
|
const root = svgEl.querySelector('.graph-root')!;
|
||||||
root.appendChild(dragPath);
|
root.appendChild(dragPath);
|
||||||
|
|
||||||
_connectState = { sourceNodeId, sourceKind, portType, startX, startY, 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 elem = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
const targetPort = elem?.closest?.('.graph-port-in');
|
const targetPort = elem?.closest?.('.graph-port-in');
|
||||||
if (targetPort) {
|
if (targetPort) {
|
||||||
const targetNodeId = targetPort.getAttribute('data-node-id');
|
const targetNodeId = targetPort.getAttribute('data-node-id') ?? '';
|
||||||
const targetKind = targetPort.getAttribute('data-node-kind');
|
const targetKind = targetPort.getAttribute('data-node-kind') ?? '';
|
||||||
const targetPortType = targetPort.getAttribute('data-port-type');
|
const targetPortType = targetPort.getAttribute('data-port-type') ?? '';
|
||||||
|
|
||||||
if (targetNodeId !== sourceNodeId) {
|
if (targetNodeId !== sourceNodeId) {
|
||||||
// Find the matching connection
|
// Find the matching connection
|
||||||
@@ -2143,8 +2148,8 @@ async function _doConnect(targetId: string, targetKind: string, field: string, s
|
|||||||
|
|
||||||
/* ── Undo / Redo ── */
|
/* ── Undo / Redo ── */
|
||||||
|
|
||||||
const _undoStack = [];
|
const _undoStack: UndoAction[] = [];
|
||||||
const _redoStack = [];
|
const _redoStack: UndoAction[] = [];
|
||||||
const _MAX_UNDO = 30;
|
const _MAX_UNDO = 30;
|
||||||
|
|
||||||
/** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */
|
/** 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> {
|
async function _undo(): Promise<void> {
|
||||||
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; }
|
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; }
|
||||||
const action = _undoStack.pop();
|
const action = _undoStack.pop()!;
|
||||||
try {
|
try {
|
||||||
await action.undo();
|
await action.undo();
|
||||||
_redoStack.push(action);
|
_redoStack.push(action);
|
||||||
@@ -2182,7 +2187,7 @@ async function _undo(): Promise<void> {
|
|||||||
|
|
||||||
async function _redo(): Promise<void> {
|
async function _redo(): Promise<void> {
|
||||||
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; }
|
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; }
|
||||||
const action = _redoStack.pop();
|
const action = _redoStack.pop()!;
|
||||||
try {
|
try {
|
||||||
await action.redo();
|
await action.redo();
|
||||||
_undoStack.push(action);
|
_undoStack.push(action);
|
||||||
@@ -2201,7 +2206,7 @@ let _helpVisible = false;
|
|||||||
|
|
||||||
function _loadHelpPos(): AnchoredRect | null {
|
function _loadHelpPos(): AnchoredRect | null {
|
||||||
try {
|
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 };
|
return saved || { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 };
|
||||||
} catch { return { 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') || '';
|
const field = edgePath.getAttribute('data-field') || '';
|
||||||
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
|
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);
|
const toNode = _nodeMap?.get(toId);
|
||||||
if (!toNode) return;
|
if (!toNode) return;
|
||||||
|
|
||||||
@@ -2289,7 +2294,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
|
|||||||
});
|
});
|
||||||
menu.appendChild(btn);
|
menu.appendChild(btn);
|
||||||
|
|
||||||
container.querySelector('.graph-container').appendChild(menu);
|
container.querySelector('.graph-container')!.appendChild(menu);
|
||||||
_edgeContextMenu = 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 _hoverTooltip: HTMLDivElement | null = null; // the <div> element, created once per graph render
|
||||||
let _hoverTooltipChart: any = null; // Chart.js instance
|
let _hoverTooltipChart: any = null; // Chart.js instance
|
||||||
let _hoverTimer: ReturnType<typeof setTimeout> | null = null; // 300ms delay timer
|
let _hoverTimer: ReturnType<typeof setTimeout> | undefined = undefined; // 300ms delay timer
|
||||||
let _hoverPollInterval: ReturnType<typeof setInterval> | null = null; // 1s polling interval
|
let _hoverPollInterval: ReturnType<typeof setInterval> | undefined = undefined; // 1s polling interval
|
||||||
let _hoverNodeId: string | null = null; // currently shown node id
|
let _hoverNodeId: string | null = null; // currently shown node id
|
||||||
let _hoverFpsHistory = []; // rolling fps_actual samples
|
let _hoverFpsHistory: number[] = []; // rolling fps_actual samples
|
||||||
let _hoverFpsCurrentHistory = []; // rolling fps_current samples
|
let _hoverFpsCurrentHistory: number[] = []; // rolling fps_current samples
|
||||||
|
|
||||||
const HOVER_DELAY_MS = 300;
|
const HOVER_DELAY_MS = 300;
|
||||||
const HOVER_HISTORY_LEN = 20;
|
const HOVER_HISTORY_LEN = 20;
|
||||||
@@ -2374,7 +2379,7 @@ function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement):
|
|||||||
if (related && nodeEl.contains(related)) return;
|
if (related && nodeEl.contains(related)) return;
|
||||||
|
|
||||||
clearTimeout(_hoverTimer);
|
clearTimeout(_hoverTimer);
|
||||||
_hoverTimer = null;
|
_hoverTimer = undefined;
|
||||||
|
|
||||||
const nodeId = nodeEl.getAttribute('data-id');
|
const nodeId = nodeEl.getAttribute('data-id');
|
||||||
if (nodeId === _hoverNodeId) {
|
if (nodeId === _hoverNodeId) {
|
||||||
@@ -2384,7 +2389,7 @@ function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _positionTooltip(_nodeEl: Element | null, container: HTMLElement): void {
|
function _positionTooltip(_nodeEl: Element | null, container: HTMLElement): void {
|
||||||
if (!_canvas || !_hoverTooltip) return;
|
if (!_canvas || !_hoverTooltip || !_hoverNodeId) return;
|
||||||
|
|
||||||
const node = _nodeMap?.get(_hoverNodeId);
|
const node = _nodeMap?.get(_hoverNodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -2467,7 +2472,7 @@ async function _showNodeTooltip(nodeId: string, nodeEl: Element, container: HTML
|
|||||||
|
|
||||||
function _hideNodeTooltip(): void {
|
function _hideNodeTooltip(): void {
|
||||||
clearInterval(_hoverPollInterval);
|
clearInterval(_hoverPollInterval);
|
||||||
_hoverPollInterval = null;
|
_hoverPollInterval = undefined;
|
||||||
_hoverNodeId = null;
|
_hoverNodeId = null;
|
||||||
|
|
||||||
if (_hoverTooltipChart) {
|
if (_hoverTooltipChart) {
|
||||||
@@ -2478,7 +2483,7 @@ function _hideNodeTooltip(): void {
|
|||||||
_hoverTooltip.classList.remove('gnt-fade-in');
|
_hoverTooltip.classList.remove('gnt-fade-in');
|
||||||
_hoverTooltip.classList.add('gnt-fade-out');
|
_hoverTooltip.classList.add('gnt-fade-out');
|
||||||
_hoverTooltip.addEventListener('animationend', () => {
|
_hoverTooltip.addEventListener('animationend', () => {
|
||||||
if (_hoverTooltip.classList.contains('gnt-fade-out')) {
|
if (_hoverTooltip?.classList.contains('gnt-fade-out')) {
|
||||||
_hoverTooltip.style.display = 'none';
|
_hoverTooltip.style.display = 'none';
|
||||||
}
|
}
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
@@ -2506,6 +2511,7 @@ async function _fetchTooltipMetrics(nodeId: string, container: HTMLElement, node
|
|||||||
const uptimeSec = metrics.uptime_seconds ?? 0;
|
const uptimeSec = metrics.uptime_seconds ?? 0;
|
||||||
|
|
||||||
// Update text rows
|
// Update text rows
|
||||||
|
if (!_hoverTooltip) return;
|
||||||
const fpsEl = _hoverTooltip.querySelector('[data-gnt="fps"]');
|
const fpsEl = _hoverTooltip.querySelector('[data-gnt="fps"]');
|
||||||
const errorsEl = _hoverTooltip.querySelector('[data-gnt="errors"]');
|
const errorsEl = _hoverTooltip.querySelector('[data-gnt="errors"]');
|
||||||
const uptimeEl = _hoverTooltip.querySelector('[data-gnt="uptime"]');
|
const uptimeEl = _hoverTooltip.querySelector('[data-gnt="uptime"]');
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function _ensureBrightnessEntitySelect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function patchKCTargetMetrics(target: any) {
|
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;
|
if (!card) return;
|
||||||
const state = target.state || {};
|
const state = target.state || {};
|
||||||
const metrics = target.metrics || {};
|
const metrics = target.metrics || {};
|
||||||
@@ -523,8 +523,8 @@ export async function showKCEditor(targetId: any = null, cloneData: any = null)
|
|||||||
try {
|
try {
|
||||||
// Load sources, pattern templates, and value sources in parallel
|
// Load sources, pattern templates, and value sources in parallel
|
||||||
const [sources, patTemplates, valueSources] = await Promise.all([
|
const [sources, patTemplates, valueSources] = await Promise.all([
|
||||||
streamsCache.fetch().catch(() => []),
|
streamsCache.fetch().catch((): any[] => []),
|
||||||
patternTemplatesCache.fetch().catch(() => []),
|
patternTemplatesCache.fetch().catch((): any[] => []),
|
||||||
valueSourcesCache.fetch(),
|
valueSourcesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -751,7 +751,7 @@ export async function deleteKCTarget(targetId: any) {
|
|||||||
// ===== KC BRIGHTNESS =====
|
// ===== KC BRIGHTNESS =====
|
||||||
|
|
||||||
export function updateKCBrightnessLabel(targetId: any, value: any) {
|
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) + '%';
|
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function createPatternTemplateCard(pt: PatternTemplate) {
|
|||||||
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null): Promise<void> {
|
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Load sources for background capture
|
// 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;
|
const bgSelect = document.getElementById('pattern-bg-source') as HTMLSelectElement;
|
||||||
bgSelect.innerHTML = '';
|
bgSelect.innerHTML = '';
|
||||||
@@ -116,7 +116,7 @@ export async function showPatternTemplateEditor(templateId: string | null = null
|
|||||||
setPatternEditorSelectedIdx(-1);
|
setPatternEditorSelectedIdx(-1);
|
||||||
setPatternCanvasDragMode(null);
|
setPatternCanvasDragMode(null);
|
||||||
|
|
||||||
let _editorTags = [];
|
let _editorTags: string[] = [];
|
||||||
|
|
||||||
if (templateId) {
|
if (templateId) {
|
||||||
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
|
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 {
|
export function renderPatternCanvas(): void {
|
||||||
const canvas = document.getElementById('pattern-canvas') as HTMLCanvasElement;
|
const canvas = document.getElementById('pattern-canvas') as HTMLCanvasElement;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d')!;
|
||||||
const w = canvas.width;
|
const w = canvas.width;
|
||||||
const h = canvas.height;
|
const h = canvas.height;
|
||||||
|
|
||||||
@@ -396,8 +396,8 @@ export function renderPatternCanvas(): void {
|
|||||||
ctx.strokeRect(rx, ry, rw, rh);
|
ctx.strokeRect(rx, ry, rw, rh);
|
||||||
|
|
||||||
// Edge highlight
|
// Edge highlight
|
||||||
let edgeDir = null;
|
let edgeDir: string | null = null;
|
||||||
if (isDragging && patternCanvasDragMode.startsWith('resize-')) {
|
if (isDragging && patternCanvasDragMode?.startsWith('resize-')) {
|
||||||
edgeDir = patternCanvasDragMode.replace('resize-', '');
|
edgeDir = patternCanvasDragMode.replace('resize-', '');
|
||||||
} else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') {
|
} else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') {
|
||||||
edgeDir = patternEditorHoverHit;
|
edgeDir = patternEditorHoverHit;
|
||||||
@@ -586,16 +586,16 @@ function _patternCanvasDragMove(e: MouseEvent | { clientX: number; clientY: numb
|
|||||||
const mx = (e.clientX - canvasRect.left) * scaleX;
|
const mx = (e.clientX - canvasRect.left) * scaleX;
|
||||||
const my = (e.clientY - canvasRect.top) * scaleY;
|
const my = (e.clientY - canvasRect.top) * scaleY;
|
||||||
|
|
||||||
const dx = (mx - patternCanvasDragStart.mx) / w;
|
const dx = (mx - patternCanvasDragStart!.mx!) / w;
|
||||||
const dy = (my - patternCanvasDragStart.my) / h;
|
const dy = (my - patternCanvasDragStart!.my!) / h;
|
||||||
const orig = patternCanvasDragOrigRect;
|
const orig = patternCanvasDragOrigRect!;
|
||||||
const r = patternEditorRects[patternEditorSelectedIdx];
|
const r = patternEditorRects[patternEditorSelectedIdx];
|
||||||
|
|
||||||
if (patternCanvasDragMode === 'move') {
|
if (patternCanvasDragMode === 'move') {
|
||||||
r.x = Math.max(0, Math.min(1 - r.width, orig.x + dx));
|
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));
|
r.y = Math.max(0, Math.min(1 - r.height, orig.y + dy));
|
||||||
} else if (patternCanvasDragMode.startsWith('resize-')) {
|
} else if (patternCanvasDragMode?.startsWith('resize-')) {
|
||||||
const dir = patternCanvasDragMode.replace('resize-', '');
|
const dir = patternCanvasDragMode!.replace('resize-', '');
|
||||||
let nx = orig.x, ny = orig.y, nw = orig.width, nh = orig.height;
|
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('w')) { nx = orig.x + dx; nw = orig.width - dx; }
|
||||||
if (dir.includes('e')) { 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;
|
const my = (e.clientY - canvasRect.top) * scaleY;
|
||||||
let cursor = 'default';
|
let cursor = 'default';
|
||||||
let newHoverIdx = -1;
|
let newHoverIdx = -1;
|
||||||
let newHoverHit = null;
|
let newHoverHit: string | null = null;
|
||||||
if (e.clientX >= canvasRect.left && e.clientX <= canvasRect.right &&
|
if (e.clientX >= canvasRect.left && e.clientX <= canvasRect.right &&
|
||||||
e.clientY >= canvasRect.top && e.clientY <= canvasRect.bottom) {
|
e.clientY >= canvasRect.top && e.clientY <= canvasRect.bottom) {
|
||||||
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
|
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 rect = canvas.getBoundingClientRect();
|
||||||
const scaleX = w / rect.width;
|
const scaleX = w / rect.width;
|
||||||
const scaleY = h / rect.height;
|
const scaleY = h / rect.height;
|
||||||
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
|
const mx = (e.offsetX !== undefined ? e.offsetX : (e.clientX ?? 0) - rect.left) * scaleX;
|
||||||
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
|
const my = (e.offsetY !== undefined ? e.offsetY : (e.clientY ?? 0) - rect.top) * scaleY;
|
||||||
|
|
||||||
// Check delete button on hovered or selected rects first
|
// Check delete button on hovered or selected rects first
|
||||||
for (const idx of [patternEditorHoveredIdx, patternEditorSelectedIdx]) {
|
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
|
// Test all rects; selected rect takes priority so it stays interactive
|
||||||
// even when overlapping with others.
|
// even when overlapping with others.
|
||||||
const selIdx = patternEditorSelectedIdx;
|
const selIdx = patternEditorSelectedIdx;
|
||||||
const testOrder = [];
|
const testOrder: number[] = [];
|
||||||
if (selIdx >= 0 && selIdx < patternEditorRects.length) testOrder.push(selIdx);
|
if (selIdx >= 0 && selIdx < patternEditorRects.length) testOrder.push(selIdx);
|
||||||
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
|
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
|
||||||
if (i !== selIdx) testOrder.push(i);
|
if (i !== selIdx) testOrder.push(i);
|
||||||
@@ -795,15 +795,15 @@ function _patternCanvasMouseMove(e: MouseEvent | { offsetX?: number; offsetY?: n
|
|||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const scaleX = w / rect.width;
|
const scaleX = w / rect.width;
|
||||||
const scaleY = h / rect.height;
|
const scaleY = h / rect.height;
|
||||||
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
|
const mx = (e.offsetX !== undefined ? e.offsetX : (e.clientX ?? 0) - rect.left) * scaleX;
|
||||||
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
|
const my = (e.offsetY !== undefined ? e.offsetY : (e.clientY ?? 0) - rect.top) * scaleY;
|
||||||
|
|
||||||
let cursor = 'default';
|
let cursor = 'default';
|
||||||
let newHoverIdx = -1;
|
let newHoverIdx = -1;
|
||||||
let newHoverHit = null;
|
let newHoverHit: string | null = null;
|
||||||
// Selected rect takes priority for hover so edges stay reachable under overlaps
|
// Selected rect takes priority for hover so edges stay reachable under overlaps
|
||||||
const selIdx = patternEditorSelectedIdx;
|
const selIdx = patternEditorSelectedIdx;
|
||||||
const hoverOrder = [];
|
const hoverOrder: number[] = [];
|
||||||
if (selIdx >= 0 && selIdx < patternEditorRects.length) hoverOrder.push(selIdx);
|
if (selIdx >= 0 && selIdx < patternEditorRects.length) hoverOrder.push(selIdx);
|
||||||
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
|
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
|
||||||
if (i !== selIdx) hoverOrder.push(i);
|
if (i !== selIdx) hoverOrder.push(i);
|
||||||
|
|||||||
@@ -55,21 +55,21 @@ export function renderPerfSection(): string {
|
|||||||
return `<div class="perf-charts-grid">
|
return `<div class="perf-charts-grid">
|
||||||
<div class="perf-chart-card">
|
<div class="perf-chart-card">
|
||||||
<div class="perf-chart-header">
|
<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>
|
<span class="perf-chart-value" id="perf-cpu-value">-</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><canvas id="perf-chart-cpu"></canvas></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>
|
||||||
<div class="perf-chart-card">
|
<div class="perf-chart-card">
|
||||||
<div class="perf-chart-header">
|
<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>
|
<span class="perf-chart-value" id="perf-ram-value">-</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div>
|
<div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="perf-chart-card" id="perf-gpu-card">
|
<div class="perf-chart-card" id="perf-gpu-card">
|
||||||
<div class="perf-chart-header">
|
<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>
|
<span class="perf-chart-value" id="perf-gpu-value">-</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-gpu-name"></span><canvas id="perf-chart-gpu"></canvas></div>
|
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-gpu-name"></span><canvas id="perf-chart-gpu"></canvas></div>
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../c
|
|||||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||||
import { cardColorStyle, cardColorButton } from '../core/card-colors.ts';
|
import { cardColorStyle, cardColorButton } from '../core/card-colors.ts';
|
||||||
import { EntityPalette } from '../core/entity-palette.ts';
|
import { EntityPalette } from '../core/entity-palette.ts';
|
||||||
|
import { navigateToCard } from '../core/navigation.ts';
|
||||||
import type { ScenePreset } from '../types.ts';
|
import type { ScenePreset } from '../types.ts';
|
||||||
|
|
||||||
let _editingId: string | null = null;
|
let _editingId: string | null = null;
|
||||||
let _allTargets = []; // fetched on capture open
|
let _allTargets: any[] = []; // fetched on capture open
|
||||||
let _sceneTagsInput: TagInput | null = null;
|
let _sceneTagsInput: TagInput | null = null;
|
||||||
|
|
||||||
class ScenePresetEditorModal extends Modal {
|
class ScenePresetEditorModal extends Modal {
|
||||||
@@ -76,7 +77,7 @@ export function createSceneCard(preset: ScenePreset) {
|
|||||||
const colorStyle = cardColorStyle(preset.id);
|
const colorStyle = cardColorStyle(preset.id);
|
||||||
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}>
|
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}>
|
||||||
<div class="card-top-actions">
|
<div class="card-top-actions">
|
||||||
<button class="card-remove-btn" onclick="deleteScenePreset('${preset.id}', '${escapeHtml(preset.name)}')" title="${t('scenes.delete')}">✕</button>
|
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title" title="${escapeHtml(preset.name)}">${escapeHtml(preset.name)}</div>
|
<div class="card-title" title="${escapeHtml(preset.name)}">${escapeHtml(preset.name)}</div>
|
||||||
@@ -88,10 +89,10 @@ export function createSceneCard(preset: ScenePreset) {
|
|||||||
</div>
|
</div>
|
||||||
${renderTagChips(preset.tags)}
|
${renderTagChips(preset.tags)}
|
||||||
<div class="card-actions">
|
<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" data-action="clone-scene" data-id="${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" data-action="edit-scene" data-id="${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-secondary" data-action="recapture-scene" data-id="${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-success" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
|
||||||
${cardColorButton(preset.id, 'data-scene-id')}
|
${cardColorButton(preset.id, 'data-scene-id')}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -106,7 +107,7 @@ export async function loadScenePresets(): Promise<ScenePreset[]> {
|
|||||||
export function renderScenePresetsSection(presets: ScenePreset[]): string | { headerExtra: string; content: string } {
|
export function renderScenePresetsSection(presets: ScenePreset[]): string | { headerExtra: string; content: string } {
|
||||||
if (!presets || presets.length === 0) return '';
|
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('');
|
const cards = presets.map(p => _renderDashboardPresetCard(p)).join('');
|
||||||
|
|
||||||
return { headerExtra: captureBtn, content: `<div class="dashboard-autostart-grid">${cards}</div>` };
|
return { headerExtra: captureBtn, content: `<div class="dashboard-autostart-grid">${cards}</div>` };
|
||||||
@@ -120,7 +121,7 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
|
|||||||
].filter(Boolean).join(' \u00b7 ');
|
].filter(Boolean).join(' \u00b7 ');
|
||||||
|
|
||||||
const pStyle = cardColorStyle(preset.id);
|
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">
|
<div class="dashboard-target-info">
|
||||||
<span class="dashboard-target-icon">${ICON_SCENE}</span>
|
<span class="dashboard-target-icon">${ICON_SCENE}</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -130,7 +131,7 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-target-actions">
|
<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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -155,7 +156,7 @@ export async function openScenePresetCapture(): Promise<void> {
|
|||||||
selectorGroup.style.display = '';
|
selectorGroup.style.display = '';
|
||||||
targetList.innerHTML = '';
|
targetList.innerHTML = '';
|
||||||
try {
|
try {
|
||||||
_allTargets = await outputTargetsCache.fetch().catch(() => []);
|
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
|
||||||
_refreshTargetSelect();
|
_refreshTargetSelect();
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -190,7 +191,7 @@ export async function editScenePreset(presetId: string): Promise<void> {
|
|||||||
selectorGroup.style.display = '';
|
selectorGroup.style.display = '';
|
||||||
targetList.innerHTML = '';
|
targetList.innerHTML = '';
|
||||||
try {
|
try {
|
||||||
_allTargets = await outputTargetsCache.fetch().catch(() => []);
|
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
|
||||||
|
|
||||||
// Pre-add targets already in the preset
|
// Pre-add targets already in the preset
|
||||||
const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
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');
|
const item = document.createElement('div');
|
||||||
item.className = 'scene-target-item';
|
item.className = 'scene-target-item';
|
||||||
item.dataset.targetId = tid;
|
item.dataset.targetId = tid;
|
||||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">✕</button>`;
|
||||||
targetList.appendChild(item);
|
targetList.appendChild(item);
|
||||||
}
|
}
|
||||||
_refreshTargetSelect();
|
_refreshTargetSelect();
|
||||||
@@ -294,7 +295,7 @@ function _addTargetToList(targetId: string, targetName: string): void {
|
|||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'scene-target-item';
|
item.className = 'scene-target-item';
|
||||||
item.dataset.targetId = targetId;
|
item.dataset.targetId = targetId;
|
||||||
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">✕</button>`;
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
_refreshTargetSelect();
|
_refreshTargetSelect();
|
||||||
}
|
}
|
||||||
@@ -320,10 +321,7 @@ export async function addSceneTarget(): Promise<void> {
|
|||||||
if (tgt) _addTargetToList(tgt.id, tgt.name);
|
if (tgt) _addTargetToList(tgt.id, tgt.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeSceneTarget(btn: HTMLElement): void {
|
// removeSceneTarget is now handled via event delegation on the modal
|
||||||
btn.closest('.scene-target-item').remove();
|
|
||||||
_refreshTargetSelect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Activate =====
|
// ===== Activate =====
|
||||||
|
|
||||||
@@ -403,7 +401,7 @@ export async function cloneScenePreset(presetId: string): Promise<void> {
|
|||||||
selectorGroup.style.display = '';
|
selectorGroup.style.display = '';
|
||||||
targetList.innerHTML = '';
|
targetList.innerHTML = '';
|
||||||
try {
|
try {
|
||||||
_allTargets = await outputTargetsCache.fetch().catch(() => []);
|
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
|
||||||
|
|
||||||
// Pre-add targets from the cloned preset
|
// Pre-add targets from the cloned preset
|
||||||
const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
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');
|
const item = document.createElement('div');
|
||||||
item.className = 'scene-target-item';
|
item.className = 'scene-target-item';
|
||||||
item.dataset.targetId = tid;
|
item.dataset.targetId = tid;
|
||||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">✕</button>`;
|
||||||
targetList.appendChild(item);
|
targetList.appendChild(item);
|
||||||
}
|
}
|
||||||
_refreshTargetSelect();
|
_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 =====
|
// ===== Helpers =====
|
||||||
|
|
||||||
function _reloadScenesTab(): void {
|
function _reloadScenesTab(): void {
|
||||||
@@ -466,3 +515,18 @@ function _reloadScenesTab(): void {
|
|||||||
// Also refresh dashboard (scene presets section)
|
// Also refresh dashboard (scene presets section)
|
||||||
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -208,9 +208,7 @@ function _formatElapsed(seconds: number): string {
|
|||||||
export function createSyncClockCard(clock: SyncClock) {
|
export function createSyncClockCard(clock: SyncClock) {
|
||||||
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
|
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 statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
|
||||||
const toggleAction = clock.is_running
|
const toggleAction = clock.is_running ? 'pause' : 'resume';
|
||||||
? `pauseSyncClock('${clock.id}')`
|
|
||||||
: `resumeSyncClock('${clock.id}')`;
|
|
||||||
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.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;
|
const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null;
|
||||||
|
|
||||||
@@ -232,14 +230,46 @@ export function createSyncClockCard(clock: SyncClock) {
|
|||||||
${renderTagChips(clock.tags)}
|
${renderTagChips(clock.tags)}
|
||||||
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
|
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
|
||||||
actions: `
|
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" data-action="${toggleAction}" data-id="${clock.id}" 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" data-action="reset" data-id="${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" data-action="clone" data-id="${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="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.showSyncClockModal = showSyncClockModal;
|
||||||
window.closeSyncClockModal = closeSyncClockModal;
|
window.closeSyncClockModal = closeSyncClockModal;
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ document.addEventListener('languageChanged', () => {
|
|||||||
|
|
||||||
// --- FPS sparkline history and chart instances for target cards ---
|
// --- FPS sparkline history and chart instances for target cards ---
|
||||||
const _TARGET_MAX_FPS_SAMPLES = 30;
|
const _TARGET_MAX_FPS_SAMPLES = 30;
|
||||||
const _targetFpsHistory = {}; // fps_actual (rolling avg)
|
const _targetFpsHistory: Record<string, number[]> = {}; // fps_actual (rolling avg)
|
||||||
const _targetFpsCurrentHistory = {}; // fps_current (sends/sec)
|
const _targetFpsCurrentHistory: Record<string, number[]> = {}; // fps_current (sends/sec)
|
||||||
const _targetFpsCharts = {};
|
const _targetFpsCharts: Record<string, any> = {};
|
||||||
|
|
||||||
function _pushTargetFps(targetId: any, actual: any, current: any) {
|
function _pushTargetFps(targetId: any, actual: any, current: any) {
|
||||||
if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
|
if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
|
||||||
@@ -154,7 +154,7 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Editor state ---
|
// --- Editor state ---
|
||||||
let _editorCssSources = []; // populated when editor opens
|
let _editorCssSources: any[] = []; // populated when editor opens
|
||||||
let _targetTagsInput: TagInput | null = null;
|
let _targetTagsInput: TagInput | null = null;
|
||||||
|
|
||||||
class TargetEditorModal extends Modal {
|
class TargetEditorModal extends Modal {
|
||||||
@@ -343,12 +343,12 @@ function _ensureProtocolIconSelect() {
|
|||||||
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
|
_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 {
|
try {
|
||||||
// Load devices, CSS sources, and value sources for dropdowns
|
// Load devices, CSS sources, and value sources for dropdowns
|
||||||
const [devices, cssSources] = await Promise.all([
|
const [devices, cssSources] = await Promise.all([
|
||||||
devicesCache.fetch().catch(() => []),
|
devicesCache.fetch().catch((): any[] => []),
|
||||||
colorStripSourcesCache.fetch().catch(() => []),
|
colorStripSourcesCache.fetch().catch((): any[] => []),
|
||||||
valueSourcesCache.fetch(),
|
valueSourcesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
|||||||
deviceSelect.appendChild(opt);
|
deviceSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
let _editorTags = [];
|
let _editorTags: string[] = [];
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
// Editing existing target
|
// Editing existing target
|
||||||
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
||||||
@@ -598,14 +598,14 @@ export async function loadTargetsTab() {
|
|||||||
try {
|
try {
|
||||||
// Fetch all entities via DataCache
|
// Fetch all entities via DataCache
|
||||||
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
|
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
|
||||||
devicesCache.fetch().catch(() => []),
|
devicesCache.fetch().catch((): any[] => []),
|
||||||
outputTargetsCache.fetch().catch(() => []),
|
outputTargetsCache.fetch().catch((): any[] => []),
|
||||||
colorStripSourcesCache.fetch().catch(() => []),
|
colorStripSourcesCache.fetch().catch((): any[] => []),
|
||||||
patternTemplatesCache.fetch().catch(() => []),
|
patternTemplatesCache.fetch().catch((): any[] => []),
|
||||||
streamsCache.fetch().catch(() => []),
|
streamsCache.fetch().catch((): any[] => []),
|
||||||
valueSourcesCache.fetch().catch(() => []),
|
valueSourcesCache.fetch().catch((): any[] => []),
|
||||||
audioSourcesCache.fetch().catch(() => []),
|
audioSourcesCache.fetch().catch((): any[] => []),
|
||||||
syncClocksCache.fetch().catch(() => []),
|
syncClocksCache.fetch().catch((): any[] => []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const colorStripSourceMap = {};
|
const colorStripSourceMap = {};
|
||||||
@@ -698,7 +698,7 @@ export async function loadTargetsTab() {
|
|||||||
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
|
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
|
||||||
|
|
||||||
// Track which target cards were replaced/added (need chart re-init)
|
// Track which target cards were replaced/added (need chart re-init)
|
||||||
let changedTargetIds = null;
|
let changedTargetIds: Set<string> | null = null;
|
||||||
|
|
||||||
if (csDevices.isMounted()) {
|
if (csDevices.isMounted()) {
|
||||||
// ── Incremental update: reconcile cards in-place ──
|
// ── Incremental update: reconcile cards in-place ──
|
||||||
@@ -760,13 +760,13 @@ export async function loadTargetsTab() {
|
|||||||
if ((device.capabilities || []).includes('brightness_control')) {
|
if ((device.capabilities || []).includes('brightness_control')) {
|
||||||
if (device.id in _deviceBrightnessCache) {
|
if (device.id in _deviceBrightnessCache) {
|
||||||
const bri = _deviceBrightnessCache[device.id];
|
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) {
|
if (slider) {
|
||||||
slider.value = String(bri);
|
slider.value = String(bri);
|
||||||
slider.title = Math.round(bri / 255 * 100) + '%';
|
slider.title = Math.round(bri / 255 * 100) + '%';
|
||||||
slider.disabled = false;
|
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');
|
if (wrap) wrap.classList.remove('brightness-loading');
|
||||||
} else {
|
} else {
|
||||||
fetchDeviceBrightness(device.id);
|
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)
|
// Patch "Last seen" labels in-place (avoids full card re-render on relative time changes)
|
||||||
for (const device of devicesWithState) {
|
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) {
|
if (el) {
|
||||||
const ts = device.state?.device_last_checked;
|
const ts = device.state?.device_last_checked;
|
||||||
const label = ts ? formatRelativeTime(ts) : null;
|
const label = ts ? formatRelativeTime(ts) : null;
|
||||||
@@ -918,7 +918,7 @@ function _buildLedTimingHTML(state: any) {
|
|||||||
function _patchTargetMetrics(target: any) {
|
function _patchTargetMetrics(target: any) {
|
||||||
const container = document.getElementById('targets-panel-content');
|
const container = document.getElementById('targets-panel-content');
|
||||||
if (!container) return;
|
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;
|
if (!card) return;
|
||||||
const state = target.state || {};
|
const state = target.state || {};
|
||||||
const metrics = target.metrics || {};
|
const metrics = target.metrics || {};
|
||||||
@@ -1141,7 +1141,7 @@ export async function stopAllKCTargets() {
|
|||||||
async function _stopAllByType(targetType: any) {
|
async function _stopAllByType(targetType: any) {
|
||||||
try {
|
try {
|
||||||
const [allTargets, statesResp] = await Promise.all([
|
const [allTargets, statesResp] = await Promise.all([
|
||||||
outputTargetsCache.fetch().catch(() => []),
|
outputTargetsCache.fetch().catch((): any[] => []),
|
||||||
fetchWithAuth('/output-targets/batch/states'),
|
fetchWithAuth('/output-targets/batch/states'),
|
||||||
]);
|
]);
|
||||||
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
|
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
|
||||||
@@ -1339,7 +1339,7 @@ function _renderLedStrip(canvas: any, rgbBytes: any) {
|
|||||||
canvas.width = ledCount;
|
canvas.width = ledCount;
|
||||||
canvas.height = 1;
|
canvas.height = 1;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d')!;
|
||||||
const imageData = ctx.createImageData(ledCount, 1);
|
const imageData = ctx.createImageData(ledCount, 1);
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
|
|
||||||
@@ -1447,7 +1447,7 @@ function connectLedPreviewWS(targetId: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _setPreviewButtonState(targetId: any, active: boolean) {
|
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) {
|
if (btn) {
|
||||||
btn.classList.toggle('btn-warning', active);
|
btn.classList.toggle('btn-warning', active);
|
||||||
btn.classList.toggle('btn-secondary', !active);
|
btn.classList.toggle('btn-secondary', !active);
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ function showTutorialStep(index: number, direction: number = 1): void {
|
|||||||
|
|
||||||
if (needsScroll) {
|
if (needsScroll) {
|
||||||
// Hide tooltip while scrolling to prevent stale position flash
|
// 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';
|
if (tt) tt.style.visibility = 'hidden';
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
_waitForScrollEnd().then(() => {
|
_waitForScrollEnd().then(() => {
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ function _drawWaveformPreview(waveformType: any) {
|
|||||||
canvas.height = cssH * dpr;
|
canvas.height = cssH * dpr;
|
||||||
canvas.style.height = cssH + 'px';
|
canvas.style.height = cssH + 'px';
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d')!;
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
ctx.clearRect(0, 0, cssW, cssH);
|
ctx.clearRect(0, 0, cssW, cssH);
|
||||||
|
|
||||||
@@ -552,7 +552,7 @@ const VS_HISTORY_SIZE = 200;
|
|||||||
let _testVsWs: WebSocket | null = null;
|
let _testVsWs: WebSocket | null = null;
|
||||||
let _testVsAnimFrame: number | null = null;
|
let _testVsAnimFrame: number | null = null;
|
||||||
let _testVsLatest: any = null;
|
let _testVsLatest: any = null;
|
||||||
let _testVsHistory = [];
|
let _testVsHistory: number[] = [];
|
||||||
let _testVsMinObserved = Infinity;
|
let _testVsMinObserved = Infinity;
|
||||||
let _testVsMaxObserved = -Infinity;
|
let _testVsMaxObserved = -Infinity;
|
||||||
|
|
||||||
@@ -602,7 +602,10 @@ export function testValueSource(sourceId: any) {
|
|||||||
}
|
}
|
||||||
if (data.value < _testVsMinObserved) _testVsMinObserved = data.value;
|
if (data.value < _testVsMinObserved) _testVsMinObserved = data.value;
|
||||||
if (data.value > _testVsMaxObserved) _testVsMaxObserved = data.value;
|
if (data.value > _testVsMaxObserved) _testVsMaxObserved = data.value;
|
||||||
} catch {}
|
} catch (e) {
|
||||||
|
console.error('Value source test WS parse error:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_testVsWs.onclose = () => {
|
_testVsWs.onclose = () => {
|
||||||
@@ -647,7 +650,7 @@ function _sizeVsCanvas(canvas: HTMLCanvasElement) {
|
|||||||
canvas.width = rect.width * dpr;
|
canvas.width = rect.width * dpr;
|
||||||
canvas.height = 200 * dpr;
|
canvas.height = 200 * dpr;
|
||||||
canvas.style.height = '200px';
|
canvas.style.height = '200px';
|
||||||
canvas.getContext('2d').scale(dpr, dpr);
|
canvas.getContext('2d')!.scale(dpr, dpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderVsTestLoop() {
|
function _renderVsTestLoop() {
|
||||||
@@ -661,7 +664,7 @@ function _renderVsChart() {
|
|||||||
const canvas = document.getElementById('vs-test-canvas') as HTMLCanvasElement;
|
const canvas = document.getElementById('vs-test-canvas') as HTMLCanvasElement;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d')!;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const w = canvas.width / dpr;
|
const w = canvas.width / dpr;
|
||||||
const h = canvas.height / dpr;
|
const h = canvas.height / dpr;
|
||||||
@@ -885,7 +888,7 @@ export function addSchedulePoint(time: string = '', value: number = 1.0) {
|
|||||||
|
|
||||||
function _getScheduleFromUI() {
|
function _getScheduleFromUI() {
|
||||||
const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row');
|
const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row');
|
||||||
const schedule = [];
|
const schedule: { time: string; value: number }[] = [];
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
const time = (row.querySelector('.schedule-time') as HTMLInputElement).value;
|
const time = (row.querySelector('.schedule-time') as HTMLInputElement).value;
|
||||||
const value = parseFloat((row.querySelector('.schedule-value') as HTMLInputElement).value);
|
const value = parseFloat((row.querySelector('.schedule-value') as HTMLInputElement).value);
|
||||||
|
|||||||
@@ -202,11 +202,9 @@ interface Window {
|
|||||||
saveScenePreset: (...args: any[]) => any;
|
saveScenePreset: (...args: any[]) => any;
|
||||||
closeScenePresetEditor: (...args: any[]) => any;
|
closeScenePresetEditor: (...args: any[]) => any;
|
||||||
activateScenePreset: (...args: any[]) => any;
|
activateScenePreset: (...args: any[]) => any;
|
||||||
recaptureScenePreset: (...args: any[]) => any;
|
|
||||||
cloneScenePreset: (...args: any[]) => any;
|
cloneScenePreset: (...args: any[]) => any;
|
||||||
deleteScenePreset: (...args: any[]) => any;
|
deleteScenePreset: (...args: any[]) => any;
|
||||||
addSceneTarget: (...args: any[]) => any;
|
addSceneTarget: (...args: any[]) => any;
|
||||||
removeSceneTarget: (...args: any[]) => any;
|
|
||||||
|
|
||||||
// ─── Device Discovery ───
|
// ─── Device Discovery ───
|
||||||
onDeviceTypeChanged: (...args: any[]) => any;
|
onDeviceTypeChanged: (...args: any[]) => any;
|
||||||
|
|||||||
@@ -12,19 +12,8 @@ const CACHE_NAME = 'ledgrab-v33';
|
|||||||
// Only pre-cache static assets (no auth required).
|
// Only pre-cache static assets (no auth required).
|
||||||
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
||||||
const PRECACHE_URLS = [
|
const PRECACHE_URLS = [
|
||||||
'/static/css/base.css',
|
'/static/dist/app.bundle.css',
|
||||||
'/static/css/layout.css',
|
'/static/dist/app.bundle.js',
|
||||||
'/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/icons/icon-192.png',
|
'/static/icons/icon-192.png',
|
||||||
'/static/icons/icon-512.png',
|
'/static/icons/icon-512.png',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Base class for JSON entity stores — eliminates boilerplate across 12+ stores."""
|
"""Base class for JSON entity stores — eliminates boilerplate across 12+ stores."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
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}")
|
logger.error(f"Failed to save {self._json_key} to {self.file_path}: {e}")
|
||||||
raise
|
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 ────────────────────────────────────────────────
|
# ── Common CRUD ────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_all(self) -> List[T]:
|
def get_all(self) -> List[T]:
|
||||||
|
|||||||
@@ -177,10 +177,17 @@ class Device:
|
|||||||
|
|
||||||
|
|
||||||
# Fields that can be updated (all Device.__init__ params except identity/timestamps)
|
# Fields that can be updated (all Device.__init__ params except identity/timestamps)
|
||||||
_UPDATABLE_FIELDS = {
|
_UPDATABLE_FIELDS: frozenset[str] = frozenset({
|
||||||
k for k in Device.__init__.__code__.co_varnames
|
"name", "url", "led_count", "enabled", "device_type",
|
||||||
if k not in ('self', 'device_id', 'created_at', 'updated_at')
|
"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]):
|
class DeviceStore(BaseJsonStore[Device]):
|
||||||
@@ -235,43 +242,46 @@ class DeviceStore(BaseJsonStore[Device]):
|
|||||||
gamesense_device_type: str = "keyboard",
|
gamesense_device_type: str = "keyboard",
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Create a new 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
|
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
||||||
if device_type == "mock":
|
|
||||||
url = f"mock://{device_id}"
|
|
||||||
|
|
||||||
device = Device(
|
# Mock devices use their device ID as the URL authority
|
||||||
device_id=device_id,
|
if device_type == "mock":
|
||||||
name=name,
|
url = f"mock://{device_id}"
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._items[device_id] = device
|
device = Device(
|
||||||
self._save()
|
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}")
|
self._items[device_id] = device
|
||||||
return device
|
self._save()
|
||||||
|
|
||||||
|
logger.info(f"Created device {device_id}: {name}")
|
||||||
|
return device
|
||||||
|
|
||||||
def update_device(self, device_id: str, **kwargs) -> Device:
|
def update_device(self, device_id: str, **kwargs) -> Device:
|
||||||
"""Update device fields.
|
"""Update device fields.
|
||||||
@@ -279,17 +289,37 @@ class DeviceStore(BaseJsonStore[Device]):
|
|||||||
Pass any updatable Device field as a keyword argument.
|
Pass any updatable Device field as a keyword argument.
|
||||||
``None`` values are ignored (no change).
|
``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():
|
# Collect updates (ignore None values and unknown fields)
|
||||||
if value is not None and key in _UPDATABLE_FIELDS:
|
updates = {
|
||||||
setattr(device, key, value)
|
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)
|
# Check name uniqueness if name is being changed
|
||||||
self._save()
|
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}")
|
# Build new Device from existing fields + updates (immutable pattern)
|
||||||
return device
|
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 ───────────────────────────────────────────
|
# ── Unique helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
0
server/tests/api/__init__.py
Normal file
0
server/tests/api/__init__.py
Normal file
0
server/tests/api/routes/__init__.py
Normal file
0
server/tests/api/routes/__init__.py
Normal file
196
server/tests/api/routes/test_devices_routes.py
Normal file
196
server/tests/api/routes/test_devices_routes.py
Normal 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()
|
||||||
74
server/tests/api/routes/test_system_routes.py
Normal file
74
server/tests/api/routes/test_system_routes.py
Normal 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"]
|
||||||
67
server/tests/api/routes/test_webhooks_routes.py
Normal file
67
server/tests/api/routes/test_webhooks_routes.py
Normal 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"
|
||||||
@@ -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
|
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
|
@pytest.fixture
|
||||||
def test_data_dir(tmp_path):
|
def test_data_dir(tmp_path):
|
||||||
"""Provide a temporary directory for test data."""
|
"""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
|
@pytest.fixture
|
||||||
@@ -16,6 +42,198 @@ def test_config_dir(tmp_path):
|
|||||||
return tmp_path / "config"
|
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
|
@pytest.fixture
|
||||||
def sample_calibration():
|
def sample_calibration():
|
||||||
"""Provide a sample calibration configuration."""
|
"""Provide a sample calibration configuration."""
|
||||||
@@ -29,21 +247,3 @@ def sample_calibration():
|
|||||||
{"edge": "left", "led_start": 110, "led_count": 40, "reverse": True},
|
{"edge": "left", "led_start": 110, "led_count": 40, "reverse": True},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_device():
|
|
||||||
"""Provide a sample device configuration."""
|
|
||||||
return {
|
|
||||||
"id": "test_device_001",
|
|
||||||
"name": "Test WLED Device",
|
|
||||||
"url": "http://192.168.1.100",
|
|
||||||
"led_count": 150,
|
|
||||||
"enabled": True,
|
|
||||||
"settings": {
|
|
||||||
"display_index": 0,
|
|
||||||
"fps": 30,
|
|
||||||
"border_width": 10,
|
|
||||||
"brightness": 0.8,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
0
server/tests/core/__init__.py
Normal file
0
server/tests/core/__init__.py
Normal file
290
server/tests/core/test_automation_engine.py
Normal file
290
server/tests/core/test_automation_engine.py
Normal 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
|
||||||
185
server/tests/core/test_sync_clock_runtime.py
Normal file
185
server/tests/core/test_sync_clock_runtime.py
Normal 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
|
||||||
1
server/tests/e2e/__init__.py
Normal file
1
server/tests/e2e/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""End-to-end API tests for critical user flows."""
|
||||||
72
server/tests/e2e/conftest.py
Normal file
72
server/tests/e2e/conftest.py
Normal 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
Reference in New Issue
Block a user