Compare commits
301 Commits
ef925ad0a9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fa851618b | |||
| 2240471b67 | |||
| 81b275979b | |||
| 47c696bae3 | |||
| 43fbc1eff5 | |||
| 997ff2fd70 | |||
| 55772b58dd | |||
| 968046d96b | |||
| 122e95545c | |||
| f4647027d2 | |||
| cdba98813b | |||
| 1f047d6561 | |||
| 6a31814900 | |||
| ea9b05733b | |||
| 05152a0f51 | |||
| 191c988cf9 | |||
| afd4a3bc05 | |||
| be356f30eb | |||
| 8a6ffca446 | |||
| 823cb90d2d | |||
| 00c9ad3a86 | |||
| bcba5f33fc | |||
| 29b43b028d | |||
| 304fa24389 | |||
| a4a0e39b9b | |||
| bbe42ee0a2 | |||
| 0bb4d7c3aa | |||
| 0bbaf81e26 | |||
| 50c40ed13f | |||
| 014b4175b9 | |||
| 6c7b7ea7d7 | |||
| 3292e0daaf | |||
| 294d704eb0 | |||
| 7e78323c9c | |||
| d1c8324c0f | |||
| 49c2a63d68 | |||
| 46d77052ad | |||
| dd92af9913 | |||
| a922c6e052 | |||
| 6395709bb8 | |||
| 272cb69247 | |||
| 51ec0970c3 | |||
| 153972fcd5 | |||
| 8960e7dca3 | |||
| 39981fbc45 | |||
| e163575bac | |||
| 844866b489 | |||
| 5c7c2ad1b2 | |||
| b370bb7d75 | |||
| ff24ec95e6 | |||
| 18c886cbc5 | |||
| 7902d2e1f9 | |||
| a54e2ab8b0 | |||
| 6d85385dbb | |||
| bd7a315c2c | |||
| 42b5ecf1cd | |||
| fe7fd8d539 | |||
| 561229a7fe | |||
| e912019873 | |||
| 568a992a4e | |||
| 9b5686ac0a | |||
| 812d15419c | |||
| b9c71d5bb9 | |||
| 97db63824e | |||
| f2162133a8 | |||
| bebdfcf319 | |||
| 0e270685e8 | |||
| c431cb67b6 | |||
| 31a0f4b2ff | |||
| 9b0dcdb794 | |||
| b62f8b8eaa | |||
| e97ef3afa6 | |||
| 4245e81a35 | |||
| b4ab5ffe3c | |||
| 4db7cd2d27 | |||
| 012e9f5ddb | |||
| ff7b595032 | |||
| 40ea2e3b99 | |||
| 2f221f6219 | |||
| 2a73b92d4a | |||
| f6f428515a | |||
| bf910204a9 | |||
| 6a22757755 | |||
| 27884282a7 | |||
| 99d8c4b8fb | |||
| bf212c86ec | |||
| 42280f094a | |||
| d498bb72a9 | |||
| c78797ba09 | |||
| 2d847beefa | |||
| 155bbdbc24 | |||
| 040a5bbdaa | |||
| 3e7b64664a | |||
| 6349e91e0f | |||
| 304c4703b9 | |||
| ac10b53064 | |||
| 09b4a1b182 | |||
| d8e73cb2b5 | |||
| b0a769b781 | |||
| 1559440a21 | |||
| 3915573514 | |||
| ee40d99067 | |||
| 73562cd525 | |||
| 1ce25caa35 | |||
| 7b4b455c7d | |||
| 37c80f01af | |||
| 954e37c2ca | |||
| 30fa107ef7 | |||
| 2712c6682e | |||
| 186940124c | |||
| 32e0f0eb5c | |||
| 353a1c2d85 | |||
| 5b4813368b | |||
| 8061c26bef | |||
| 96bd3bd0f0 | |||
| 0984a3b639 | |||
| be91e74c6e | |||
| a728c75113 | |||
| dc4495a117 | |||
| 6fc0e20e1d | |||
| b4d89e271d | |||
| d95eb683e1 | |||
| d6bda9afed | |||
| a330a8c0f0 | |||
| bc5d8fdc9b | |||
| de04872fdc | |||
| 80b48e3618 | |||
| fddbd771f2 | |||
| f08117eb7b | |||
| 39e41dfce7 | |||
| aa1e4a6afc | |||
| 52ee4bdeb6 | |||
| aafcf83896 | |||
| 01104acad1 | |||
| 6366b0b317 | |||
| ddfa7637d6 | |||
| 9ee6dcf94a | |||
| 8fe9c6489b | |||
| b9ec509f56 | |||
| bf2fd5ca69 | |||
| 62b3d44e63 | |||
| 90acae5207 | |||
| ec58282c19 | |||
| cb779e10d3 | |||
| 32a54b7d3c | |||
| 6a7826e550 | |||
| a89b3a25d0 | |||
| 493d96d604 | |||
| 9b2ccde8a7 | |||
| fa81d6a608 | |||
| 175a2c6c10 | |||
| 252db09145 | |||
| a34edf9650 | |||
| ff4e7f8adb | |||
| 0eb0f44ddb | |||
| 39b31aec34 | |||
| 21248e2dc9 | |||
| da3e53e1f1 | |||
| 2e747b5ece | |||
| bd8d7a019f | |||
| c95c6e9a44 | |||
| 5f90336edd | |||
| bd6c072adf | |||
| 49c985e5c5 | |||
| efb05eba77 | |||
| 6a7ba3d0b7 | |||
| 2bca119ad4 | |||
| 46a2ebf61e | |||
| c262ec0775 | |||
| efb6cf7ce6 | |||
| 111bfe743a | |||
| f6977105b8 | |||
| 9194b978e0 | |||
| 88abd31c1c | |||
| d33d70cfe8 | |||
| fccf50c62a | |||
| 6f5bda6d8f | |||
| fda040ae18 | |||
| cadef971e7 | |||
| f8656b72a6 | |||
| 9cfe628cc5 | |||
| bafd8b4130 | |||
| dac0c2d418 | |||
| 8fa89903e9 | |||
| b8bfdac36b | |||
| 3c35bf0c49 | |||
| 88b3ecd5e1 | |||
| a164abe774 | |||
| c2deef214e | |||
| a0c9cb0039 | |||
| 147ef3b4eb | |||
| 4806f5020c | |||
| bae2166bc2 | |||
| cbbaa852ed | |||
| 3bfa9062f9 | |||
| f0e8f0ef33 | |||
| 1dc43f1259 | |||
| 847ac38d8a | |||
| 7a4d7149a6 | |||
| f507a6cf11 | |||
| 7f80faf8be | |||
| 82e12ffaac | |||
| b51839ef3c | |||
| d05b4b78f4 | |||
| 701eac19e5 | |||
| 466527bd4a | |||
| 2b6bc22fc8 | |||
| 83800e71fa | |||
| 7b07f38ce5 | |||
| 8bf40573f1 | |||
| f67936c977 | |||
| a82eec7a06 | |||
| d4a7c81296 | |||
| e0e744095e | |||
| 16f29bee30 | |||
| 359f33fdbb | |||
| 68ce394ccc | |||
| f2f67493b1 | |||
| 04ee2e5830 | |||
| 0cd8304004 | |||
| 468cfa2022 | |||
| d45e59b0e6 | |||
| f96cd5f367 | |||
| a5d855f469 | |||
| 34d9495eb3 | |||
| a39dc1b06a | |||
| dc12452bcd | |||
| 0b89731d0c | |||
| 858a8e3ac2 | |||
| e4c4301a7b | |||
| 053a56eed3 | |||
| a6253e8d96 | |||
| 67a15776b2 | |||
| 1e4a7a067f | |||
| d339dd3f90 | |||
| 48651f0a4e | |||
| 425deb9570 | |||
| 8f79b77fe4 | |||
| ef474fe275 | |||
| 27720e51aa | |||
| 166ec351b1 | |||
| 808037775f | |||
| 9efb08acb6 | |||
| 199039326b | |||
| cc08bb1c19 | |||
| f15ff8fea0 | |||
| 9d593379b8 | |||
| bbd2ac9910 | |||
| 2657f46e5d | |||
| e5a6eafd09 | |||
| e32bfab888 | |||
| f9a5fb68ed | |||
| 9e555cef2e | |||
| a4083764fb | |||
| 9392741f08 | |||
| d4a0f3a7f5 | |||
| 1d5f542603 | |||
| 27575930b8 | |||
| 2a01c2947a | |||
| ee52e2d98f | |||
| 8a0730d91b | |||
| 1f6c913343 | |||
| 1204676c30 | |||
| 6d33686b79 | |||
| 67d141b75b | |||
| 7c0c064453 | |||
| b14da85f3b | |||
| 55a9662234 | |||
| 84f063eee9 | |||
| 5004992f26 | |||
| 0a000cc44c | |||
| 8cf7678e2b | |||
| 1604855935 | |||
| c31818a20d | |||
| 872949a7e1 | |||
| 55e25b8860 | |||
| be37df4459 | |||
| c5ced0d904 | |||
| 7479b1fb8d | |||
| 2a8e2daefc | |||
| 0a23cb7043 | |||
| 018bedf9f6 | |||
| a3aeafef13 | |||
| 7de3546b14 | |||
| c4e0257389 | |||
| 99f47fdbf9 | |||
| 3101894ab5 | |||
| c3b1d3edd9 | |||
| 3ae20761a1 | |||
| 2b90fafb9c | |||
| 755077607a | |||
| fbf597dc29 | |||
| bfe6a7a2ab | |||
| ab8041269e | |||
| ff4e054ef8 | |||
| bef28ece5c | |||
| 6388e0defa | |||
| 747cdfabd6 | |||
| df52a197d9 | |||
| f83cd81937 | |||
| 45634836b6 |
74
.gitea/workflows/release.yml
Normal file
74
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
name: Build Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Build portable distribution
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
.\build-dist.ps1 -Version "${{ gitea.ref_name }}"
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: LedGrab-${{ gitea.ref_name }}-win-x64
|
||||||
|
path: build/LedGrab-*.zip
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
$tag = "${{ gitea.ref_name }}"
|
||||||
|
$zipFile = Get-ChildItem "build\LedGrab-*.zip" | Select-Object -First 1
|
||||||
|
if (-not $zipFile) { throw "ZIP not found" }
|
||||||
|
|
||||||
|
$baseUrl = "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||||
|
$headers = @{
|
||||||
|
"Authorization" = "token $env:GITEA_TOKEN"
|
||||||
|
"Content-Type" = "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create release
|
||||||
|
$body = @{
|
||||||
|
tag_name = $tag
|
||||||
|
name = "LedGrab $tag"
|
||||||
|
body = "Portable Windows build — unzip, run ``LedGrab.bat``, open http://localhost:8080"
|
||||||
|
draft = $false
|
||||||
|
prerelease = ($tag -match '(alpha|beta|rc)')
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$release = Invoke-RestMethod -Method Post `
|
||||||
|
-Uri "$baseUrl/releases" `
|
||||||
|
-Headers $headers -Body $body
|
||||||
|
|
||||||
|
Write-Host "Created release: $($release.html_url)"
|
||||||
|
|
||||||
|
# Upload ZIP asset
|
||||||
|
$uploadHeaders = @{
|
||||||
|
"Authorization" = "token $env:GITEA_TOKEN"
|
||||||
|
}
|
||||||
|
$uploadUrl = "$baseUrl/releases/$($release.id)/assets?name=$($zipFile.Name)"
|
||||||
|
Invoke-RestMethod -Method Post -Uri $uploadUrl `
|
||||||
|
-Headers $uploadHeaders `
|
||||||
|
-ContentType "application/octet-stream" `
|
||||||
|
-InFile $zipFile.FullName
|
||||||
|
|
||||||
|
Write-Host "Uploaded: $($zipFile.Name)"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,6 +26,9 @@ ENV/
|
|||||||
env/
|
env/
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
118
BRAINSTORM.md
Normal file
118
BRAINSTORM.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Feature Brainstorm — LED Grab
|
||||||
|
|
||||||
|
## New Automation Conditions (Profiles)
|
||||||
|
|
||||||
|
Right now profiles only trigger on **app detection**. High-value additions:
|
||||||
|
|
||||||
|
- **Time-of-day / Schedule** — "warm tones after sunset, off at midnight." Schedule-based value sources pattern already exists
|
||||||
|
- **Display state** — detect monitor on/off/sleep, auto-stop targets when display is off
|
||||||
|
- **System idle** — dim or switch to ambient effect after N minutes of no input
|
||||||
|
- **Sunrise/sunset** — fetch local solar times, drive circadian color temperature shifts
|
||||||
|
- **Webhook/MQTT trigger** — let external systems activate profiles without HA integration
|
||||||
|
|
||||||
|
## New Output Targets
|
||||||
|
|
||||||
|
Currently: WLED, Adalight, AmbileD, DDP. Potential:
|
||||||
|
|
||||||
|
- **MQTT publish** — generic IoT output, any MQTT subscriber becomes a target
|
||||||
|
- **Art-Net / sACN (E1.31)** — stage/theatrical lighting protocols, DMX controllers
|
||||||
|
- **OpenRGB** — control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
|
||||||
|
- **HTTP webhook** — POST color data to arbitrary endpoints
|
||||||
|
- **Recording target** — save color streams to file for playback later
|
||||||
|
|
||||||
|
## New Color Strip Sources
|
||||||
|
|
||||||
|
- **Spotify / media player** — album art color extraction or tempo-synced effects
|
||||||
|
- **Weather** — pull conditions from API, map to palettes (blue=rain, orange=sun, white=snow)
|
||||||
|
- **Camera / webcam** — border-sampling from camera feed for video calls or room-reactive lighting
|
||||||
|
- **Script source** — user-written JS/Python snippets producing color arrays per frame
|
||||||
|
- **Notification reactive** — flash/pulse on OS notifications (optional app filter)
|
||||||
|
|
||||||
|
## Processing Pipeline Extensions
|
||||||
|
|
||||||
|
- **Palette quantization** — force output to match a user-defined palette
|
||||||
|
- **Zone grouping** — merge adjacent LEDs into logical groups sharing one averaged color
|
||||||
|
- **Color temperature filter** — warm/cool shift separate from hue shift (circadian/mood)
|
||||||
|
- **Noise gate** — suppress small color changes below threshold, preventing shimmer on static content
|
||||||
|
|
||||||
|
## Multi-Instance & Sync
|
||||||
|
|
||||||
|
- **Multi-room sync** — multiple instances with shared clock for synchronized effects
|
||||||
|
- **Multi-display unification** — treat 2-3 monitors as single virtual display for seamless ambilight
|
||||||
|
- **Leader/follower mode** — one target's output drives others with optional delay (cascade)
|
||||||
|
|
||||||
|
## UX & Dashboard
|
||||||
|
|
||||||
|
- **PWA / mobile layout** — mobile-first layout + "Add to Home Screen" manifest
|
||||||
|
- **Scene presets** — bundled source + filters + brightness as one-click presets ("Movie night", "Gaming")
|
||||||
|
- **Live preview on dashboard** — miniature screen with LED colors rendered around its border
|
||||||
|
- **Undo/redo for calibration** — reduce frustration in the fiddly calibration editor
|
||||||
|
- **Drag-and-drop filter ordering** — reorder postprocessing filter chains visually
|
||||||
|
|
||||||
|
## API & Integration
|
||||||
|
|
||||||
|
- **WebSocket event bus** — broadcast all state changes over a single WS channel
|
||||||
|
- **OBS integration** — detect active scene, switch profiles; or use OBS virtual camera as source
|
||||||
|
- **Plugin system** — formalize extension points into documented plugin API with hot-reload
|
||||||
|
|
||||||
|
## Creative / Fun
|
||||||
|
|
||||||
|
- **Effect sequencer** — timeline-based choreography of effects, colors, and transitions
|
||||||
|
- **Music BPM sync** — lock effect speed to detected BPM (beat detection already exists)
|
||||||
|
- **Color extraction from image** — upload photo, extract palette, use as gradient/cycle source
|
||||||
|
- **Transition effects** — crossfade, wipe, or dissolve between sources/profiles instead of instant cut
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deep Dive: Notification Reactive Source
|
||||||
|
|
||||||
|
**Type:** New `ColorStripSource` (`source_type: "notification"`) — normally outputs transparent RGBA, flashes on notification events. Designed to be used as a layer in a **composite source** so it overlays on top of a persistent base (gradient, effect, screen capture, etc.).
|
||||||
|
|
||||||
|
### Trigger modes (both active simultaneously)
|
||||||
|
|
||||||
|
1. **OS listener (Windows)** — `pywinrt` + `Windows.UI.Notifications.Management.UserNotificationListener`. Runs in background thread, pushes events to source via queue. Windows-only for now; macOS (`pyobjc` + `NSUserNotificationCenter`) and Linux (`dbus` + `org.freedesktop.Notifications`) deferred to future.
|
||||||
|
2. **Webhook** — `POST /api/v1/notifications/{source_id}/fire` with optional body `{ "app": "MyApp", "color": "#FF0000" }`. Always available, cross-platform by nature.
|
||||||
|
|
||||||
|
### Source config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
os_listener: true # enable Windows notification listener
|
||||||
|
app_filter:
|
||||||
|
mode: whitelist|blacklist # which apps to react to
|
||||||
|
apps: [Discord, Slack, Telegram]
|
||||||
|
app_colors: # user-configured app → color mapping
|
||||||
|
Discord: "#5865F2"
|
||||||
|
Slack: "#4A154B"
|
||||||
|
Telegram: "#26A5E4"
|
||||||
|
default_color: "#FFFFFF" # fallback when app has no mapping
|
||||||
|
effect: flash|pulse|sweep # visual effect type
|
||||||
|
duration_ms: 1500 # effect duration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Effect rendering
|
||||||
|
|
||||||
|
Source outputs RGBA color array per frame:
|
||||||
|
- **Idle**: all pixels `(0,0,0,0)` — composite passes through base layer
|
||||||
|
- **Flash**: instant full-color, linear fade to transparent over `duration_ms`
|
||||||
|
- **Pulse**: sine fade in/out over `duration_ms`
|
||||||
|
- **Sweep**: color travels across the strip like a wave
|
||||||
|
|
||||||
|
Each notification starts its own mini-timeline from trigger timestamp (not sync clock).
|
||||||
|
|
||||||
|
### Overlap handling
|
||||||
|
|
||||||
|
New notification while previous effect is active → restart timer with new color. No queuing.
|
||||||
|
|
||||||
|
### App color resolution
|
||||||
|
|
||||||
|
1. Webhook body `color` field (explicit override) → highest priority
|
||||||
|
2. `app_colors` mapping by app name
|
||||||
|
3. `default_color` fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top Picks (impact vs effort)
|
||||||
|
|
||||||
|
1. **Time-of-day + idle profile conditions** — builds on existing profile/condition architecture
|
||||||
|
2. **MQTT output target** — opens the door to an enormous IoT ecosystem
|
||||||
|
3. **Scene presets** — purely frontend, bundles existing features into one-click UX
|
||||||
121
CLAUDE.md
121
CLAUDE.md
@@ -1,5 +1,31 @@
|
|||||||
# Claude Instructions for WLED Screen Controller
|
# Claude Instructions for WLED Screen Controller
|
||||||
|
|
||||||
|
## Code Search
|
||||||
|
|
||||||
|
**If `ast-index` is available, use it as the PRIMARY code search tool.** It is significantly faster than grep and returns structured, accurate results. Fall back to grep/Glob only when ast-index is not installed, returns empty results, or when searching regex patterns/string literals/comments.
|
||||||
|
|
||||||
|
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if available:
|
||||||
|
ast-index version
|
||||||
|
|
||||||
|
# Rebuild index (first time or after major changes):
|
||||||
|
ast-index rebuild
|
||||||
|
|
||||||
|
# Common commands:
|
||||||
|
ast-index search "Query" # Universal search across files, symbols, modules
|
||||||
|
ast-index class "ClassName" # Find class/struct/interface definitions
|
||||||
|
ast-index usages "SymbolName" # Find all places a symbol is used
|
||||||
|
ast-index implementations "BaseClass" # Find all subclasses/implementations
|
||||||
|
ast-index symbol "FunctionName" # Find any symbol (class, function, property)
|
||||||
|
ast-index outline "path/to/File.cpp" # Show all symbols in a file
|
||||||
|
ast-index hierarchy "ClassName" # Show inheritance tree
|
||||||
|
ast-index callers "FunctionName" # Find all call sites
|
||||||
|
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
|
## CRITICAL: Git Commit and Push Policy
|
||||||
|
|
||||||
**🚨 NEVER CREATE COMMITS WITHOUT EXPLICIT USER APPROVAL 🚨**
|
**🚨 NEVER CREATE COMMITS WITHOUT EXPLICIT USER APPROVAL 🚨**
|
||||||
@@ -70,13 +96,55 @@
|
|||||||
|
|
||||||
**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.
|
**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.** Files under `/server/src/wled_controller/static/` (HTML, JS, CSS, JSON locale files) are served directly by FastAPI's static file handler — changes take effect on the next browser page refresh without restarting the server.
|
**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
|
### Restart procedure
|
||||||
|
|
||||||
1. Stop the running Python process: `powershell -Command "Get-Process -Name python -ErrorAction SilentlyContinue | Stop-Process -Force"`
|
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
|
||||||
2. Start the server: `powershell -Command "Set-Location 'c:\Users\Alexei\Documents\wled-screen-controller\server'; python -m wled_controller.main"` (run in background)
|
|
||||||
3. Wait 3 seconds and check startup logs to confirm it's running
|
```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
|
||||||
|
|
||||||
@@ -89,28 +157,41 @@ This is a monorepo containing:
|
|||||||
For detailed server-specific instructions (restart policy, testing, etc.), see:
|
For detailed server-specific instructions (restart policy, testing, etc.), see:
|
||||||
- `server/CLAUDE.md`
|
- `server/CLAUDE.md`
|
||||||
|
|
||||||
## UI Conventions for Dialogs
|
## Frontend (HTML, CSS, JS, i18n)
|
||||||
|
|
||||||
### Hints
|
For all frontend conventions (CSS variables, UI patterns, modals, localization, tutorials), see [`contexts/frontend.md`](contexts/frontend.md).
|
||||||
|
|
||||||
Every form field in a modal should have a hint. Use the `.label-row` wrapper with a `?` toggle button:
|
## Task Tracking via TODO.md
|
||||||
|
|
||||||
```html
|
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`.
|
||||||
<div class="form-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label for="my-field" data-i18n="my.label">Label:</label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="my.label.hint">Hint text</small>
|
|
||||||
<input type="text" id="my-field">
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffix on the label key.
|
- **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
|
||||||
|
|
||||||
### Select dropdowns
|
## Documentation Lookup
|
||||||
|
|
||||||
Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.
|
**Use context7 MCP tools for library/framework documentation lookups.** When you need to check API signatures, usage patterns, or current behavior of external libraries (e.g., FastAPI, OpenCV, Pydantic, yt-dlp), use `mcp__plugin_context7_context7__resolve-library-id` to find the library, then `mcp__plugin_context7_context7__query-docs` to fetch up-to-date docs. This avoids relying on potentially outdated training data.
|
||||||
|
|
||||||
|
## IMPORTANT: Demo Mode Awareness
|
||||||
|
|
||||||
|
**When adding new entity types, engines, device providers, or stores — keep demo mode in sync:**
|
||||||
|
|
||||||
|
1. **New entity stores**: Add the store's file path to `StorageConfig` in `config.py` — the `model_post_init()` auto-rewrites `data/` → `data/demo/` paths when demo is active.
|
||||||
|
2. **New capture engines**: If a new engine is added, verify demo mode filtering still works (demo engines use `is_demo_mode()` gate in `is_available()`).
|
||||||
|
3. **New audio engines**: Same as capture engines — `is_available()` must respect `is_demo_mode()`.
|
||||||
|
4. **New device providers**: If discovery is added, gate it with `is_demo_mode()` like `DemoDeviceProvider.discover()`.
|
||||||
|
5. **New seed data**: When adding new entity types that should appear in demo mode, update `server/src/wled_controller/core/demo_seed.py` to include sample entities.
|
||||||
|
6. **Frontend indicators**: Demo mode state is exposed via `GET /api/v1/version` → `demo_mode: bool`. Frontend stores it as `demoMode` in app state and sets `document.body.dataset.demo = 'true'`.
|
||||||
|
7. **Backup/Restore**: If new stores are added to `STORE_MAP` in `system.py`, they automatically work in demo mode since the data directory is already isolated.
|
||||||
|
|
||||||
|
**Key files:**
|
||||||
|
|
||||||
|
- Config flag: `server/src/wled_controller/config.py` → `Config.demo`, `is_demo_mode()`
|
||||||
|
- Demo engines: `core/capture_engines/demo_engine.py`, `core/audio/demo_engine.py`
|
||||||
|
- Demo devices: `core/devices/demo_provider.py`
|
||||||
|
- Seed data: `core/demo_seed.py`
|
||||||
|
|
||||||
## General Guidelines
|
## General Guidelines
|
||||||
|
|
||||||
|
|||||||
320
README.md
320
README.md
@@ -1,194 +1,236 @@
|
|||||||
# WLED Screen Controller
|
# LED Grab
|
||||||
|
|
||||||
Ambient lighting controller that synchronizes WLED devices with your screen content for an immersive viewing experience.
|
Ambient lighting system that captures screen content and drives LED strips in real time. Supports WLED, Adalight, AmbileD, and DDP devices with audio-reactive effects, pattern generation, and automated profile switching.
|
||||||
|
|
||||||
## Overview
|
## What It Does
|
||||||
|
|
||||||
This project consists of two components:
|
The server captures pixels from a screen (or Android device via ADB), extracts border colors, applies post-processing filters, and streams the result to LED strips at up to 60 fps. A built-in web dashboard provides device management, calibration, live LED preview, and real-time metrics — no external UI required.
|
||||||
|
|
||||||
1. **Python Server** - Captures screen border pixels and sends color data to WLED devices via REST API
|
A Home Assistant integration exposes devices as entities for smart home automation.
|
||||||
2. **Home Assistant Integration** - Controls and monitors the server from Home Assistant OS
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🖥️ **Multi-Monitor Support** - Select which display to capture
|
### Screen Capture
|
||||||
- ⚡ **Configurable FPS** - Adjust update rate (1-60 FPS)
|
|
||||||
- 🎨 **Smart Calibration** - Map screen edges to LED positions
|
- Multi-monitor support with per-target display selection
|
||||||
- 🔌 **REST API** - Full control via HTTP endpoints
|
- 6 capture engine backends — MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows), Scrcpy (Android via ADB), Camera/Webcam (OpenCV)
|
||||||
- 🏠 **Home Assistant Integration** - Native HAOS support with entities
|
- Configurable capture regions, FPS, and border width
|
||||||
- 🐳 **Docker Support** - Easy deployment with Docker Compose
|
- Capture templates for reusable configurations
|
||||||
- 📊 **Real-time Metrics** - Monitor FPS, status, and performance
|
|
||||||
|
### LED Device Support
|
||||||
|
|
||||||
|
- WLED (HTTP/UDP) with mDNS auto-discovery
|
||||||
|
- Adalight (serial) — Arduino-compatible LED controllers
|
||||||
|
- AmbileD (serial)
|
||||||
|
- DDP (Distributed Display Protocol, UDP)
|
||||||
|
- OpenRGB — PC peripherals (keyboard, mouse, RAM, fans, LED strips)
|
||||||
|
- Serial port auto-detection and baud rate configuration
|
||||||
|
|
||||||
|
### Color Processing
|
||||||
|
|
||||||
|
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip
|
||||||
|
- Reusable post-processing templates
|
||||||
|
- Color strip sources: audio-reactive, pattern generator, composite layering, audio-to-color mapping
|
||||||
|
- Pattern templates with customizable effects
|
||||||
|
|
||||||
|
### Audio Integration
|
||||||
|
|
||||||
|
- Multichannel audio capture from any system device (input or loopback)
|
||||||
|
- WASAPI engine on Windows, Sounddevice (PortAudio) engine on Linux/macOS
|
||||||
|
- Per-channel mono extraction
|
||||||
|
- Audio-reactive color strip sources driven by frequency analysis
|
||||||
|
|
||||||
|
### Automation
|
||||||
|
|
||||||
|
- Profile engine with condition-based switching (time of day, active window, etc.)
|
||||||
|
- Dynamic brightness value sources (schedule-based, scene-aware)
|
||||||
|
- Key Colors (KC) targets with live WebSocket color streaming
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- Web UI at `http://localhost:8080` — no installation needed on the client side
|
||||||
|
- Progressive Web App (PWA) — installable on phones and tablets with offline caching
|
||||||
|
- Responsive mobile layout with bottom tab navigation
|
||||||
|
- Device management with auto-discovery wizard
|
||||||
|
- Visual calibration editor with overlay preview
|
||||||
|
- Live LED strip preview via WebSocket
|
||||||
|
- Real-time FPS, latency, and uptime charts
|
||||||
|
- Localized in English, Russian, and Chinese
|
||||||
|
|
||||||
|
### Home Assistant Integration
|
||||||
|
|
||||||
|
- HACS-compatible custom component
|
||||||
|
- Light, switch, sensor, and number entities per device
|
||||||
|
- Real-time metrics via data coordinator
|
||||||
|
- WebSocket-based live LED preview in HA
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Server
|
- Python 3.11+ (or Docker)
|
||||||
- Python 3.11 or higher
|
- A supported LED device on the local network or connected via USB
|
||||||
- Windows, Linux, or macOS
|
- Windows, Linux, or macOS — all core features work cross-platform
|
||||||
- WLED device on the same network
|
|
||||||
|
|
||||||
### Home Assistant Integration
|
### Platform Notes
|
||||||
- Home Assistant OS 2023.1 or higher
|
|
||||||
- Running WLED Screen Controller server
|
| Feature | Windows | Linux / macOS |
|
||||||
|
| ------- | ------- | ------------- |
|
||||||
|
| Screen capture | DXCam, BetterCam, WGC, MSS | MSS |
|
||||||
|
| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) |
|
||||||
|
| Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) |
|
||||||
|
| GPU monitoring | NVIDIA (pynvml) | NVIDIA (pynvml) |
|
||||||
|
| Android capture | Scrcpy (ADB) | Scrcpy (ADB) |
|
||||||
|
| Monitor names | Friendly names (WMI) | Generic ("Display 0") |
|
||||||
|
| Profile conditions | Process/window detection | Not yet implemented |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Server Installation
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/yourusername/wled-screen-controller.git
|
|
||||||
cd wled-screen-controller/server
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install dependencies**
|
|
||||||
```bash
|
|
||||||
pip install .
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Run the server**
|
|
||||||
```bash
|
|
||||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Access the API**
|
|
||||||
- API: http://localhost:8080
|
|
||||||
- Interactive docs: http://localhost:8080/docs
|
|
||||||
|
|
||||||
### Docker Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
git clone https://github.com/yourusername/wled-screen-controller.git
|
||||||
|
cd wled-screen-controller/server
|
||||||
|
|
||||||
|
# Option A: Docker (recommended)
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Option B: Python
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Linux/Mac
|
||||||
|
# venv\Scripts\activate # Windows
|
||||||
|
pip install .
|
||||||
|
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||||
|
# set PYTHONPATH=%CD%\src # Windows
|
||||||
|
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8080` to access the dashboard. The default API key for development is `development-key-change-in-production`.
|
||||||
|
|
||||||
|
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including Docker manual builds and Home Assistant setup.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
wled-screen-controller/
|
||||||
|
├── server/ # Python FastAPI backend
|
||||||
|
│ ├── src/wled_controller/
|
||||||
|
│ │ ├── main.py # Application entry point
|
||||||
|
│ │ ├── config.py # YAML + env var configuration
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ ├── routes/ # REST + WebSocket endpoints
|
||||||
|
│ │ │ └── schemas/ # Pydantic request/response models
|
||||||
|
│ │ ├── core/
|
||||||
|
│ │ │ ├── capture/ # Screen capture, calibration, pixel processing
|
||||||
|
│ │ │ ├── capture_engines/ # MSS, DXCam, BetterCam, WGC, Scrcpy, Camera backends
|
||||||
|
│ │ │ ├── devices/ # WLED, Adalight, AmbileD, DDP, OpenRGB clients
|
||||||
|
│ │ │ ├── audio/ # Audio capture engines
|
||||||
|
│ │ │ ├── filters/ # Post-processing filter pipeline
|
||||||
|
│ │ │ ├── processing/ # Stream orchestration and target processors
|
||||||
|
│ │ │ └── profiles/ # Condition-based profile automation
|
||||||
|
│ │ ├── storage/ # JSON-based persistence layer
|
||||||
|
│ │ ├── static/ # Web dashboard (vanilla JS, CSS, HTML)
|
||||||
|
│ │ │ ├── js/core/ # API client, state, i18n, modals, events
|
||||||
|
│ │ │ ├── js/features/ # Feature modules (devices, streams, targets, etc.)
|
||||||
|
│ │ │ ├── css/ # Stylesheets
|
||||||
|
│ │ │ └── locales/ # en.json, ru.json, zh.json
|
||||||
|
│ │ └── utils/ # Logging, monitor detection
|
||||||
|
│ ├── config/ # default_config.yaml
|
||||||
|
│ ├── tests/ # pytest suite
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── docker-compose.yml
|
||||||
|
├── custom_components/ # Home Assistant integration (HACS)
|
||||||
|
│ └── wled_screen_controller/
|
||||||
|
├── docs/
|
||||||
|
│ ├── API.md # REST API reference
|
||||||
|
│ └── CALIBRATION.md # LED calibration guide
|
||||||
|
├── INSTALLATION.md
|
||||||
|
└── LICENSE # MIT
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Edit `server/config/default_config.yaml`:
|
Edit `server/config/default_config.yaml` or use environment variables with the `LED_GRAB_` prefix:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 8080
|
port: 8080
|
||||||
|
log_level: "INFO"
|
||||||
|
|
||||||
processing:
|
auth:
|
||||||
default_fps: 30
|
api_keys:
|
||||||
border_width: 10
|
dev: "development-key-change-in-production"
|
||||||
|
|
||||||
wled:
|
storage:
|
||||||
timeout: 5
|
devices_file: "data/devices.json"
|
||||||
retry_attempts: 3
|
templates_file: "data/capture_templates.json"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
format: "json"
|
||||||
|
file: "logs/wled_controller.log"
|
||||||
|
max_size_mb: 100
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Usage
|
Environment variable override example: `LED_GRAB_SERVER__PORT=9090`.
|
||||||
|
|
||||||
### Attach a WLED Device
|
## API
|
||||||
|
|
||||||
```bash
|
The server exposes a REST API (with Swagger docs at `/docs`) covering:
|
||||||
curl -X POST http://localhost:8080/api/v1/devices \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"name": "Living Room TV",
|
|
||||||
"url": "http://192.168.1.100",
|
|
||||||
"led_count": 150
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Start Processing
|
- **Devices** — CRUD, discovery, validation, state, metrics
|
||||||
|
- **Capture Templates** — Screen capture configurations
|
||||||
|
- **Picture Sources** — Screen capture stream definitions
|
||||||
|
- **Picture Targets** — LED target management, start/stop processing
|
||||||
|
- **Post-Processing Templates** — Filter pipeline configurations
|
||||||
|
- **Color Strip Sources** — Audio, pattern, composite, mapped sources
|
||||||
|
- **Audio Sources** — Multichannel and mono audio device configuration
|
||||||
|
- **Pattern Templates** — Effect pattern definitions
|
||||||
|
- **Value Sources** — Dynamic brightness/value providers
|
||||||
|
- **Key Colors Targets** — KC targets with WebSocket live color stream
|
||||||
|
- **Profiles** — Condition-based automation profiles
|
||||||
|
|
||||||
```bash
|
All endpoints require API key authentication via `X-API-Key` header or `?token=` query parameter.
|
||||||
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Status
|
See [docs/API.md](docs/API.md) for the full reference.
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/api/v1/devices/{device_id}/state
|
|
||||||
```
|
|
||||||
|
|
||||||
See [API Documentation](docs/API.md) for complete API reference.
|
|
||||||
|
|
||||||
## Calibration
|
## Calibration
|
||||||
|
|
||||||
The calibration system maps screen border pixels to LED positions. See [Calibration Guide](docs/CALIBRATION.md) for details.
|
The calibration system maps screen border pixels to physical LED positions. Configure layout direction, start position, and per-edge segments through the web dashboard or API.
|
||||||
|
|
||||||
Example calibration:
|
See [docs/CALIBRATION.md](docs/CALIBRATION.md) for a step-by-step guide.
|
||||||
```json
|
|
||||||
{
|
|
||||||
"layout": "clockwise",
|
|
||||||
"start_position": "bottom_left",
|
|
||||||
"segments": [
|
|
||||||
{"edge": "bottom", "led_start": 0, "led_count": 40},
|
|
||||||
{"edge": "right", "led_start": 40, "led_count": 30},
|
|
||||||
{"edge": "top", "led_start": 70, "led_count": 40},
|
|
||||||
{"edge": "left", "led_start": 110, "led_count": 40}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Home Assistant Integration
|
## Home Assistant
|
||||||
|
|
||||||
1. Copy `homeassistant/custom_components/wled_screen_controller` to your Home Assistant `custom_components` folder
|
Install via HACS (add as a custom repository) or manually copy `custom_components/wled_screen_controller/` into your HA config directory. The integration creates light, switch, sensor, and number entities for each configured device.
|
||||||
2. Restart Home Assistant
|
|
||||||
3. Go to Settings → Integrations → Add Integration
|
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
|
||||||
4. Search for "WLED Screen Controller"
|
|
||||||
5. Enter your server URL
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
cd server
|
||||||
pytest tests/ -v
|
|
||||||
|
# Install with dev dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Format and lint
|
||||||
|
black src/ tests/
|
||||||
|
ruff check src/ tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Project Structure
|
Optional extras:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[perf]" # High-performance capture engines (Windows)
|
||||||
|
pip install -e ".[camera]" # Webcam capture via OpenCV
|
||||||
```
|
```
|
||||||
wled-screen-controller/
|
|
||||||
├── server/ # Python FastAPI server
|
|
||||||
│ ├── src/wled_controller/ # Main application code
|
|
||||||
│ ├── tests/ # Unit and integration tests
|
|
||||||
│ ├── config/ # Configuration files
|
|
||||||
│ └── pyproject.toml # Python dependencies & project config
|
|
||||||
├── homeassistant/ # Home Assistant integration
|
|
||||||
│ └── custom_components/
|
|
||||||
└── docs/ # Documentation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Screen capture fails
|
|
||||||
- **Windows**: Ensure Python has screen capture permissions
|
|
||||||
- **Linux**: Install X11 dependencies: `apt-get install libxcb1 libxcb-randr0`
|
|
||||||
- **macOS**: Grant screen recording permission in System Preferences
|
|
||||||
|
|
||||||
### WLED not responding
|
|
||||||
- Verify WLED device is on the same network
|
|
||||||
- Check firewall settings
|
|
||||||
- Test connection: `curl http://YOUR_WLED_IP/json/info`
|
|
||||||
|
|
||||||
### Low FPS
|
|
||||||
- Reduce `border_width` in configuration
|
|
||||||
- Lower target FPS
|
|
||||||
- Check network latency to WLED device
|
|
||||||
- Reduce LED count
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE) file
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions welcome! Please open an issue or pull request.
|
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- [WLED](https://github.com/Aircoookie/WLED) - Amazing LED control software
|
- [WLED](https://github.com/Aircoookie/WLED) — LED control firmware
|
||||||
- [FastAPI](https://fastapi.tiangolo.com/) - Modern Python web framework
|
- [FastAPI](https://fastapi.tiangolo.com/) — Python web framework
|
||||||
- [mss](https://python-mss.readthedocs.io/) - Fast screen capture library
|
- [MSS](https://python-mss.readthedocs.io/) — Cross-platform screen capture
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
- GitHub Issues: [Report a bug](https://github.com/yourusername/wled-screen-controller/issues)
|
|
||||||
- Discussions: [Ask a question](https://github.com/yourusername/wled-screen-controller/discussions)
|
|
||||||
|
|||||||
250
build-dist.ps1
Normal file
250
build-dist.ps1
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Build a portable Windows distribution of LedGrab.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Downloads embedded Python, installs all dependencies, copies app code,
|
||||||
|
builds the frontend bundle, and produces a self-contained ZIP.
|
||||||
|
|
||||||
|
.PARAMETER Version
|
||||||
|
Version string (e.g. "0.1.0" or "v0.1.0"). Auto-detected from git tag
|
||||||
|
or __init__.py if omitted.
|
||||||
|
|
||||||
|
.PARAMETER PythonVersion
|
||||||
|
Embedded Python version to download. Default: 3.11.9
|
||||||
|
|
||||||
|
.PARAMETER SkipFrontend
|
||||||
|
Skip npm ci + npm run build (use if frontend is already built).
|
||||||
|
|
||||||
|
.PARAMETER SkipPerf
|
||||||
|
Skip installing optional [perf] extras (dxcam, bettercam, windows-capture).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\build-dist.ps1
|
||||||
|
.\build-dist.ps1 -Version "0.2.0"
|
||||||
|
.\build-dist.ps1 -SkipFrontend -SkipPerf
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$Version = "",
|
||||||
|
[string]$PythonVersion = "3.11.9",
|
||||||
|
[switch]$SkipFrontend,
|
||||||
|
[switch]$SkipPerf
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue' # faster downloads
|
||||||
|
|
||||||
|
$ScriptRoot = $PSScriptRoot
|
||||||
|
$BuildDir = Join-Path $ScriptRoot "build"
|
||||||
|
$DistName = "LedGrab"
|
||||||
|
$DistDir = Join-Path $BuildDir $DistName
|
||||||
|
$ServerDir = Join-Path $ScriptRoot "server"
|
||||||
|
$PythonDir = Join-Path $DistDir "python"
|
||||||
|
$AppDir = Join-Path $DistDir "app"
|
||||||
|
|
||||||
|
# ── Version detection ──────────────────────────────────────────
|
||||||
|
|
||||||
|
if (-not $Version) {
|
||||||
|
# Try git tag
|
||||||
|
try {
|
||||||
|
$gitTag = git describe --tags --exact-match 2>$null
|
||||||
|
if ($gitTag) { $Version = $gitTag }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (-not $Version) {
|
||||||
|
# Try env var (CI)
|
||||||
|
if ($env:GITEA_REF_NAME) { $Version = $env:GITEA_REF_NAME }
|
||||||
|
elseif ($env:GITHUB_REF_NAME) { $Version = $env:GITHUB_REF_NAME }
|
||||||
|
}
|
||||||
|
if (-not $Version) {
|
||||||
|
# Parse from __init__.py
|
||||||
|
$initFile = Join-Path $ServerDir "src\wled_controller\__init__.py"
|
||||||
|
$match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"'
|
||||||
|
if ($match) { $Version = $match.Matches[0].Groups[1].Value }
|
||||||
|
}
|
||||||
|
if (-not $Version) { $Version = "0.0.0" }
|
||||||
|
|
||||||
|
# Strip leading 'v' for filenames
|
||||||
|
$VersionClean = $Version -replace '^v', ''
|
||||||
|
$ZipName = "LedGrab-v${VersionClean}-win-x64.zip"
|
||||||
|
|
||||||
|
Write-Host "=== Building LedGrab v${VersionClean} ===" -ForegroundColor Cyan
|
||||||
|
Write-Host " Python: $PythonVersion"
|
||||||
|
Write-Host " Output: build\$ZipName"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# ── Clean ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (Test-Path $DistDir) {
|
||||||
|
Write-Host "[1/8] Cleaning previous build..."
|
||||||
|
Remove-Item -Recurse -Force $DistDir
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
|
||||||
|
|
||||||
|
# ── Download embedded Python ───────────────────────────────────
|
||||||
|
|
||||||
|
$PythonZipUrl = "https://www.python.org/ftp/python/${PythonVersion}/python-${PythonVersion}-embed-amd64.zip"
|
||||||
|
$PythonZipPath = Join-Path $BuildDir "python-embed.zip"
|
||||||
|
|
||||||
|
Write-Host "[2/8] Downloading embedded Python ${PythonVersion}..."
|
||||||
|
if (-not (Test-Path $PythonZipPath)) {
|
||||||
|
Invoke-WebRequest -Uri $PythonZipUrl -OutFile $PythonZipPath
|
||||||
|
}
|
||||||
|
Write-Host " Extracting to python/..."
|
||||||
|
Expand-Archive -Path $PythonZipPath -DestinationPath $PythonDir -Force
|
||||||
|
|
||||||
|
# ── Patch ._pth to enable site-packages ────────────────────────
|
||||||
|
|
||||||
|
Write-Host "[3/8] Patching Python path configuration..."
|
||||||
|
$pthFile = Get-ChildItem -Path $PythonDir -Filter "python*._pth" | Select-Object -First 1
|
||||||
|
if (-not $pthFile) { throw "Could not find python*._pth in $PythonDir" }
|
||||||
|
|
||||||
|
$pthContent = Get-Content $pthFile.FullName -Raw
|
||||||
|
# Uncomment 'import site'
|
||||||
|
$pthContent = $pthContent -replace '#\s*import site', 'import site'
|
||||||
|
# Add Lib\site-packages if not present
|
||||||
|
if ($pthContent -notmatch 'Lib\\site-packages') {
|
||||||
|
$pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n"
|
||||||
|
}
|
||||||
|
Set-Content -Path $pthFile.FullName -Value $pthContent -NoNewline
|
||||||
|
Write-Host " Patched $($pthFile.Name)"
|
||||||
|
|
||||||
|
# ── Install pip ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Write-Host "[4/8] Installing pip..."
|
||||||
|
$GetPipPath = Join-Path $BuildDir "get-pip.py"
|
||||||
|
if (-not (Test-Path $GetPipPath)) {
|
||||||
|
Invoke-WebRequest -Uri "https://bootstrap.pypa.io/get-pip.py" -OutFile $GetPipPath
|
||||||
|
}
|
||||||
|
$python = Join-Path $PythonDir "python.exe"
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
& $python $GetPipPath --no-warn-script-location 2>&1 | Out-Null
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" }
|
||||||
|
|
||||||
|
# ── Install dependencies ──────────────────────────────────────
|
||||||
|
|
||||||
|
Write-Host "[5/8] Installing dependencies..."
|
||||||
|
$extras = "camera,notifications"
|
||||||
|
if (-not $SkipPerf) { $extras += ",perf" }
|
||||||
|
|
||||||
|
# Install the project (pulls all deps via pyproject.toml), then remove
|
||||||
|
# the installed package itself — PYTHONPATH handles app code loading.
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
& $python -m pip install --no-warn-script-location "${ServerDir}[${extras}]" 2>&1 | ForEach-Object {
|
||||||
|
if ($_ -match 'ERROR|Failed') { Write-Host " $_" -ForegroundColor Red }
|
||||||
|
}
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " Some optional deps may have failed (continuing)..." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove the installed wled_controller package to avoid duplication
|
||||||
|
$sitePackages = Join-Path $PythonDir "Lib\site-packages"
|
||||||
|
Get-ChildItem -Path $sitePackages -Filter "wled*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Get-ChildItem -Path $sitePackages -Filter "wled*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Clean up caches and test files to reduce size
|
||||||
|
Write-Host " Cleaning up caches..."
|
||||||
|
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "tests" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "test" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# ── Build frontend ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (-not $SkipFrontend) {
|
||||||
|
Write-Host "[6/8] Building frontend bundle..."
|
||||||
|
Push-Location $ServerDir
|
||||||
|
try {
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
& npm ci --loglevel error 2>&1 | Out-Null
|
||||||
|
& npm run build 2>&1 | ForEach-Object {
|
||||||
|
$line = "$_"
|
||||||
|
if ($line -and $line -notmatch 'RemoteException') { Write-Host " $line" }
|
||||||
|
}
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "[6/8] Skipping frontend build (--SkipFrontend)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Copy application files ─────────────────────────────────────
|
||||||
|
|
||||||
|
Write-Host "[7/8] Copying application files..."
|
||||||
|
New-Item -ItemType Directory -Path $AppDir -Force | Out-Null
|
||||||
|
|
||||||
|
# Copy source code (includes static/dist bundle, templates, locales)
|
||||||
|
$srcDest = Join-Path $AppDir "src"
|
||||||
|
Copy-Item -Path (Join-Path $ServerDir "src") -Destination $srcDest -Recurse
|
||||||
|
|
||||||
|
# Copy config
|
||||||
|
$configDest = Join-Path $AppDir "config"
|
||||||
|
Copy-Item -Path (Join-Path $ServerDir "config") -Destination $configDest -Recurse
|
||||||
|
|
||||||
|
# Create empty data/ and logs/ directories
|
||||||
|
New-Item -ItemType Directory -Path (Join-Path $DistDir "data") -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
|
||||||
|
|
||||||
|
# Clean up source maps and __pycache__ from app code
|
||||||
|
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
|
||||||
|
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# ── Create launcher ────────────────────────────────────────────
|
||||||
|
|
||||||
|
Write-Host "[8/8] Creating launcher..."
|
||||||
|
|
||||||
|
$launcherContent = @'
|
||||||
|
@echo off
|
||||||
|
title LedGrab v%VERSION%
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
:: Set paths
|
||||||
|
set PYTHONPATH=%~dp0app\src
|
||||||
|
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
|
||||||
|
|
||||||
|
:: Create data directory if missing
|
||||||
|
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||||
|
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo =============================================
|
||||||
|
echo LedGrab v%VERSION%
|
||||||
|
echo Open http://localhost:8080 in your browser
|
||||||
|
echo =============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: Start the server (open browser after short delay)
|
||||||
|
start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:8080"
|
||||||
|
"%~dp0python\python.exe" -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||||
|
|
||||||
|
pause
|
||||||
|
'@
|
||||||
|
|
||||||
|
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
|
||||||
|
$launcherPath = Join-Path $DistDir "LedGrab.bat"
|
||||||
|
Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII
|
||||||
|
|
||||||
|
# ── Create ZIP ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$ZipPath = Join-Path $BuildDir $ZipName
|
||||||
|
if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath }
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Creating $ZipName..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Use 7-Zip if available (faster, handles locked files), else fall back to Compress-Archive
|
||||||
|
$7z = Get-Command 7z -ErrorAction SilentlyContinue
|
||||||
|
if ($7z) {
|
||||||
|
& 7z a -tzip -mx=7 $ZipPath "$DistDir\*" | Select-Object -Last 3
|
||||||
|
} else {
|
||||||
|
Compress-Archive -Path "$DistDir\*" -DestinationPath $ZipPath -CompressionLevel Optimal
|
||||||
|
}
|
||||||
|
|
||||||
|
$zipSize = (Get-Item $ZipPath).Length / 1MB
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Build complete ===" -ForegroundColor Green
|
||||||
|
Write-Host " Archive: $ZipPath"
|
||||||
|
Write-Host " Size: $([math]::Round($zipSize, 1)) MB"
|
||||||
|
Write-Host ""
|
||||||
66
contexts/chrome-tools.md
Normal file
66
contexts/chrome-tools.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Chrome Browser Tools (MCP)
|
||||||
|
|
||||||
|
**Read this file when using Chrome browser tools** (`mcp__claude-in-chrome__*`) for testing or debugging the frontend.
|
||||||
|
|
||||||
|
## Tool Loading
|
||||||
|
|
||||||
|
All Chrome MCP tools are deferred — they must be loaded with `ToolSearch` before first use:
|
||||||
|
|
||||||
|
```
|
||||||
|
ToolSearch query="select:mcp__claude-in-chrome__<tool_name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Commonly used tools:
|
||||||
|
- `tabs_context_mcp` — get available tabs (call first in every session)
|
||||||
|
- `navigate` — go to a URL
|
||||||
|
- `computer` — screenshots, clicks, keyboard, scrolling, zoom
|
||||||
|
- `read_page` — accessibility tree of page elements
|
||||||
|
- `find` — find elements by text/selector
|
||||||
|
- `javascript_tool` — run JS in the page console
|
||||||
|
- `form_input` — fill form fields
|
||||||
|
|
||||||
|
## Browser Tricks
|
||||||
|
|
||||||
|
### Hard Reload (bypass cache)
|
||||||
|
|
||||||
|
After rebuilding the frontend bundle (`npm run build`), do a hard reload to bypass browser cache:
|
||||||
|
|
||||||
|
```
|
||||||
|
computer action="key" text="ctrl+shift+r"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is equivalent to Ctrl+Shift+R and forces the browser to re-fetch all resources, ignoring cached versions.
|
||||||
|
|
||||||
|
### Zoom into UI regions
|
||||||
|
|
||||||
|
Use the `zoom` action to inspect small UI elements (icons, badges, text):
|
||||||
|
|
||||||
|
```
|
||||||
|
computer action="zoom" region=[x0, y0, x1, y1]
|
||||||
|
```
|
||||||
|
|
||||||
|
Coordinates define a rectangle from top-left to bottom-right in viewport pixels.
|
||||||
|
|
||||||
|
### Scroll to element
|
||||||
|
|
||||||
|
Use `scroll_to` with a `ref` from `read_page` to bring an element into view:
|
||||||
|
|
||||||
|
```
|
||||||
|
computer action="scroll_to" ref="ref_123"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Console messages
|
||||||
|
|
||||||
|
Use `read_console_messages` to check for JS errors after page load or interactions.
|
||||||
|
|
||||||
|
### Network requests
|
||||||
|
|
||||||
|
Use `read_network_requests` to inspect API calls, check response codes, and debug loading issues.
|
||||||
|
|
||||||
|
## Typical Verification Workflow
|
||||||
|
|
||||||
|
1. Rebuild bundle: `npm run build` (from `server/` directory)
|
||||||
|
2. Hard reload: `ctrl+shift+r`
|
||||||
|
3. Take screenshot to verify visual changes
|
||||||
|
4. Zoom into specific regions if needed
|
||||||
|
5. Check console for errors
|
||||||
269
contexts/frontend.md
Normal file
269
contexts/frontend.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# Frontend Rules & Conventions
|
||||||
|
|
||||||
|
**Read this file when working on frontend tasks** (HTML, CSS, JS, locales, templates).
|
||||||
|
|
||||||
|
## CSS Custom Properties (Variables)
|
||||||
|
|
||||||
|
Defined in `server/src/wled_controller/static/css/base.css`.
|
||||||
|
|
||||||
|
**IMPORTANT:** There is NO `--accent` variable. Always use `--primary-color` for accent/brand color.
|
||||||
|
|
||||||
|
### Global (`:root`)
|
||||||
|
| Variable | Value | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `--primary-color` | `#4CAF50` | **Accent/brand color** — borders, highlights, active states |
|
||||||
|
| `--primary-hover` | `#5cb860` | Hover state for primary elements |
|
||||||
|
| `--primary-contrast` | `#ffffff` | Text on primary background |
|
||||||
|
| `--danger-color` | `#f44336` | Destructive actions, errors |
|
||||||
|
| `--warning-color` | `#ff9800` | Warnings |
|
||||||
|
| `--info-color` | `#2196F3` | Informational highlights |
|
||||||
|
|
||||||
|
### Theme-specific (`[data-theme="dark"]` / `[data-theme="light"]`)
|
||||||
|
| Variable | Dark | Light | Usage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--bg-color` | `#1a1a1a` | `#f5f5f5` | Page background |
|
||||||
|
| `--bg-secondary` | `#242424` | `#eee` | Secondary background |
|
||||||
|
| `--card-bg` | `#2d2d2d` | `#ffffff` | Card/panel background |
|
||||||
|
| `--text-color` | `#e0e0e0` | `#333333` | Primary text |
|
||||||
|
| `--text-secondary` | `#999` | `#666` | Secondary text |
|
||||||
|
| `--text-muted` | `#777` | `#999` | Muted/disabled text |
|
||||||
|
| `--border-color` | `#404040` | `#e0e0e0` | Borders, dividers |
|
||||||
|
| `--primary-text-color` | `#66bb6a` | `#3d8b40` | Primary-colored text |
|
||||||
|
| `--success-color` | `#28a745` | `#2e7d32` | Success indicators |
|
||||||
|
| `--shadow-color` | `rgba(0,0,0,0.3)` | `rgba(0,0,0,0.12)` | Box shadows |
|
||||||
|
|
||||||
|
## UI Conventions for Dialogs
|
||||||
|
|
||||||
|
### Hints
|
||||||
|
|
||||||
|
Every form field in a modal should have a hint. Use the `.label-row` wrapper with a `?` toggle button:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="my-field" data-i18n="my.label">Label:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="my.label.hint">Hint text</small>
|
||||||
|
<input type="text" id="my-field">
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffix on the label key.
|
||||||
|
|
||||||
|
### Select dropdowns
|
||||||
|
|
||||||
|
Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.
|
||||||
|
|
||||||
|
### Empty/None option format
|
||||||
|
|
||||||
|
When a selector has an optional entity (e.g., sync clock, processing template, brightness source), the empty option must use the format `None (<description>)` where the description explains what happens when nothing is selected. Use i18n keys, never hardcoded `—` or bare `None`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `None (no processing template)` — `t('common.none_no_cspt')`
|
||||||
|
- `None (no input source)` — `t('common.none_no_input')`
|
||||||
|
- `None (use own speed)` — `t('common.none_own_speed')`
|
||||||
|
- `None (full brightness)` — `t('color_strip.composite.brightness.none')`
|
||||||
|
- `None (device brightness)` — `t('targets.brightness_vs.none')`
|
||||||
|
|
||||||
|
For `EntitySelect` with `allowNone: true`, pass the same i18n string as `noneLabel`.
|
||||||
|
|
||||||
|
### Enhanced selectors (IconSelect & EntitySelect)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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`:
|
||||||
|
|
||||||
|
1. **Subclass Modal** with `snapshotValues()` returning an object of all tracked field values:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class MyEditorModal extends Modal {
|
||||||
|
constructor() { super('my-modal-id'); }
|
||||||
|
snapshotValues() {
|
||||||
|
return {
|
||||||
|
name: document.getElementById('my-name').value,
|
||||||
|
// ... all form fields
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onForceClose() {
|
||||||
|
// Optional: cleanup (reset flags, clear state, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const myModal = new MyEditorModal();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Call `modal.snapshot()`** after the form is fully populated (after `modal.open()`).
|
||||||
|
3. **Close/cancel button** calls `await modal.close()` — triggers dirty check + confirmation.
|
||||||
|
4. **Save function** calls `modal.forceClose()` after successful save — skips dirty check.
|
||||||
|
5. For complex/dynamic state (filter lists, schedule rows, conditions), serialize to JSON string in `snapshotValues()`.
|
||||||
|
|
||||||
|
The base class handles: `isDirty()` comparison, confirmation dialog, backdrop click, ESC key, focus trapping, and body scroll lock.
|
||||||
|
|
||||||
|
### Card appearance
|
||||||
|
|
||||||
|
When creating or modifying entity cards (devices, targets, CSS sources, streams, audio/value sources, templates), **always reference existing cards** of the same or similar type for visual consistency. Cards should have:
|
||||||
|
|
||||||
|
- Clone (📋) and Edit (✏️) icon buttons in `.template-card-actions`
|
||||||
|
- Delete (✕) button as `.card-remove-btn`
|
||||||
|
- Property badges in `.stream-card-props` with emoji icons
|
||||||
|
- **Crosslinks**: When a card references another entity (audio source, picture source, capture template, PP template, etc.), make the property badge a clickable link using the `stream-card-link` CSS class and an `onclick` handler calling `navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue)`. Only add the link when the referenced entity is found (to avoid broken navigation). Example: `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-multi','data-id','${id}')">🎵 Name</span>`
|
||||||
|
|
||||||
|
### Modal footer buttons
|
||||||
|
|
||||||
|
Use **icon-only** buttons (✓ / ✕) matching the device settings modal pattern, **not** text buttons:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="closeMyModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slider value display
|
||||||
|
|
||||||
|
For range sliders, display the current value **inside the label** (not in a separate wrapper). This keeps the value visible next to the property name:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<label for="my-slider"><span data-i18n="my.label">Speed:</span> <span id="my-slider-display">1.0</span></label>
|
||||||
|
...
|
||||||
|
<input type="range" id="my-slider" min="0" max="10" step="0.1" value="1.0"
|
||||||
|
oninput="document.getElementById('my-slider-display').textContent = this.value">
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** use a `range-with-value` wrapper div.
|
||||||
|
|
||||||
|
### Tutorials
|
||||||
|
|
||||||
|
The app has an interactive tutorial system (`static/js/features/tutorials.js`) with a generic engine, spotlight overlay, tooltip positioning, and keyboard navigation. Tutorials exist for:
|
||||||
|
- **Getting started** (header-level walkthrough of all tabs and controls)
|
||||||
|
- **Per-tab tutorials** (Dashboard, Targets, Sources, Profiles) triggered by `?` buttons
|
||||||
|
- **Device card tutorial** and **Calibration tutorial** (context-specific)
|
||||||
|
|
||||||
|
When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.js` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`).
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
**Always use SVG icons from the icon system, never text/emoji/Unicode symbols for buttons and UI controls.**
|
||||||
|
|
||||||
|
- Icon SVG paths are defined in `static/js/core/icon-paths.js` (Lucide icons, 24×24 viewBox)
|
||||||
|
- Icon constants are exported from `static/js/core/icons.js` (e.g. `ICON_START`, `ICON_TRASH`, `ICON_EDIT`)
|
||||||
|
- Use `_svg(path)` wrapper from `icons.js` to create new icon constants from paths
|
||||||
|
|
||||||
|
When you need a new icon:
|
||||||
|
1. Find the Lucide icon at https://lucide.dev
|
||||||
|
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.js` as a new export
|
||||||
|
3. Add a corresponding `ICON_*` constant in `icons.js` using `_svg(P.myIcon)`
|
||||||
|
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).
|
||||||
|
|
||||||
|
For icon-only buttons, use `btn btn-icon` CSS classes. The `.icon` class inside buttons auto-sizes to 16×16.
|
||||||
|
|
||||||
|
## 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`).
|
||||||
|
|
||||||
|
- In JS modules: `import { t } from '../core/i18n.js';` then `showToast(t('my.key'), 'error')`
|
||||||
|
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.t ? t('key') : 'fallback'`
|
||||||
|
- In HTML templates: use `data-i18n="key"` for text content, `data-i18n-title="key"` for title attributes, `data-i18n-aria-label="key"` for aria-labels
|
||||||
|
- Keys follow dotted namespace convention: `feature.context.description` (e.g. `device.error.brightness`, `calibration.saved`)
|
||||||
|
|
||||||
|
### Dynamic content and language changes
|
||||||
|
|
||||||
|
When a feature module generates HTML with baked-in `t()` calls (e.g., toolbar button titles, legend text), that content won't update when the user switches language. To handle this, listen for the `languageChanged` event and re-render:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
document.addEventListener('languageChanged', () => {
|
||||||
|
if (_initialized) _reRender();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Static HTML using `data-i18n` attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern.
|
||||||
|
|
||||||
|
## Bundling & Development Workflow
|
||||||
|
|
||||||
|
The frontend uses **esbuild** to bundle all JS modules and CSS files into single files for production.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- **Entry points:** `static/js/app.js` (JS), `static/css/all.css` (CSS imports all individual sheets)
|
||||||
|
- **Output:** `static/dist/app.bundle.js` and `static/dist/app.bundle.css` (minified + source maps)
|
||||||
|
- **Config:** `server/esbuild.mjs`
|
||||||
|
- **HTML:** `templates/index.html` references the bundles, not individual source files
|
||||||
|
|
||||||
|
### Commands (from `server/` directory)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `npm run build` | One-shot bundle + minify (~30ms) |
|
||||||
|
| `npm run watch` | Watch mode — auto-rebuilds on any JS/CSS file save |
|
||||||
|
|
||||||
|
### Development workflow
|
||||||
|
|
||||||
|
1. Run `npm run watch` in a terminal (stays running)
|
||||||
|
2. Edit source files in `static/js/` or `static/css/` as usual
|
||||||
|
3. esbuild rebuilds the bundle automatically (~30ms)
|
||||||
|
4. Refresh the browser to see changes
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
All JS/CSS dependencies are bundled — **no CDN or external requests** at runtime:
|
||||||
|
|
||||||
|
- **Chart.js** — imported in `perf-charts.js`, exposed as `window.Chart` for `targets.js` and `dashboard.js`
|
||||||
|
- **ELK.js** — imported in `graph-layout.js` for graph auto-layout
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- The `dist/` directory is gitignored — bundles are build artifacts, run `npm run build` after clone
|
||||||
|
- Source maps are generated so browser DevTools show original source files
|
||||||
|
- The server sets `Cache-Control: no-cache` on static JS/CSS/JSON to prevent stale browser caches during development
|
||||||
|
- GZip compression middleware reduces transfer sizes by ~75%
|
||||||
|
- **Do not edit files in `static/dist/`** — they are overwritten by the build
|
||||||
|
|
||||||
|
## Chrome Browser Tools
|
||||||
|
|
||||||
|
See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, browser tricks (hard reload, zoom, console), and verification workflow.
|
||||||
|
|
||||||
|
## Duration & Numeric Formatting
|
||||||
|
|
||||||
|
### Uptime / duration values
|
||||||
|
|
||||||
|
Use `formatUptime(seconds)` from `core/ui.js`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
|
||||||
|
|
||||||
|
### Large numbers
|
||||||
|
|
||||||
|
Use `formatCompact(n)` from `core/ui.js`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
|
||||||
|
|
||||||
|
### Preventing layout shift
|
||||||
|
|
||||||
|
Numeric/duration values that update frequently (FPS, uptime, frame counts) **must** use fixed-width styling to prevent layout reflow:
|
||||||
|
|
||||||
|
- `font-family: var(--font-mono, monospace)` — equal-width characters
|
||||||
|
- `font-variant-numeric: tabular-nums` — equal-width digits in proportional fonts
|
||||||
|
- Fixed `width` or `min-width` on the value container
|
||||||
|
- `text-align: right` to anchor the growing edge
|
||||||
|
|
||||||
|
Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(--font-mono)`, `font-weight: 600`, `min-width: 48px`.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
## Visual Graph Editor
|
||||||
|
|
||||||
|
See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.
|
||||||
|
|
||||||
|
**IMPORTANT:** When adding or modifying entity types, subtypes, or connection fields, the graph editor files **must** be updated in sync. The graph maintains its own maps of entity colors, labels, icons, connection rules, and cache references. See the "Keeping the graph in sync with entity types" section in `graph-editor.md` for the complete checklist.
|
||||||
101
contexts/graph-editor.md
Normal file
101
contexts/graph-editor.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Visual Graph Editor
|
||||||
|
|
||||||
|
**Read this file when working on the graph editor** (`static/js/features/graph-editor.js` and related modules).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The graph editor renders all entities (devices, templates, sources, clocks, targets, scenes, automations) as SVG nodes connected by edges in a left-to-right layered layout.
|
||||||
|
|
||||||
|
### Core modules
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `js/features/graph-editor.js` | Main orchestrator — toolbar, keyboard, search, filter, add-entity menu, port/node drag, minimap |
|
||||||
|
| `js/core/graph-layout.js` | ELK.js layout, `buildGraph()`, `computePorts()`, entity color/label maps |
|
||||||
|
| `js/core/graph-nodes.js` | SVG node rendering, overlay buttons, per-node color overrides |
|
||||||
|
| `js/core/graph-edges.js` | SVG edge rendering (bezier curves, arrowheads, flow dots) |
|
||||||
|
| `js/core/graph-canvas.js` | Pan/zoom controller with `zoomToPoint()` rAF animation |
|
||||||
|
| `js/core/graph-connections.js` | CONNECTION_MAP — which fields link entity types, drag-connect/detach logic |
|
||||||
|
| `css/graph-editor.css` | All graph-specific styles |
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
1. `loadGraphEditor()` → `_fetchAllEntities()` fetches all caches in parallel
|
||||||
|
2. `computeLayout(entities)` builds ELK graph, runs layout → returns `{nodes: Map, edges: Array, bounds}`
|
||||||
|
3. `computePorts(nodeMap, edges)` assigns port positions and annotates edges with `fromPortY`/`toPortY`
|
||||||
|
4. Manual position overrides (`_manualPositions`) applied after layout
|
||||||
|
5. `renderEdges()` + `renderNodes()` paint SVG elements
|
||||||
|
6. `GraphCanvas` handles pan/zoom via CSS `transform: scale() translate()`
|
||||||
|
|
||||||
|
### Edge rendering
|
||||||
|
|
||||||
|
Edges always use `_defaultBezier()` (port-aware cubic bezier) — ELK edge routing is ignored because it lacks port awareness, causing misaligned bend points. ELK is only used for node positioning.
|
||||||
|
|
||||||
|
### Port system
|
||||||
|
|
||||||
|
Nodes have input ports (left) and output ports (right), colored by edge type. Port types are ordered vertically: `template > picture > colorstrip > value > audio > clock > scene > device > default`.
|
||||||
|
|
||||||
|
## Keeping the graph in sync with entity types
|
||||||
|
|
||||||
|
**CRITICAL:** When adding or modifying entity types in the system, these graph files MUST be updated:
|
||||||
|
|
||||||
|
### Adding a new entity type
|
||||||
|
|
||||||
|
1. **`graph-layout.js`** — `ENTITY_COLORS`, `ENTITY_LABELS`, `buildGraph()` (add node loop + edge loops)
|
||||||
|
2. **`graph-layout.js`** — `edgeType()` function if the new type needs a distinct edge color
|
||||||
|
3. **`graph-nodes.js`** — `KIND_ICONS` (default icon), `SUBTYPE_ICONS` (subtype-specific icons)
|
||||||
|
4. **`graph-nodes.js`** — `START_STOP_KINDS` or `TEST_KINDS` sets if the entity supports start/stop or test
|
||||||
|
5. **`graph-connections.js`** — `CONNECTION_MAP` for drag-connect edge creation
|
||||||
|
6. **`graph-editor.js`** — `ADD_ENTITY_MAP` (add-entity menu entry with window function)
|
||||||
|
7. **`graph-editor.js`** — `ALL_CACHES` array (for new-entity-focus watcher)
|
||||||
|
8. **`graph-editor.js`** — `_fetchAllEntities()` (add cache fetch + pass to `computeLayout`)
|
||||||
|
9. **`core/state.js`** — Add/export the new DataCache
|
||||||
|
10. **`app.js`** — Import and window-export the add/edit/clone functions
|
||||||
|
|
||||||
|
### Adding a new field/connection to an existing entity
|
||||||
|
|
||||||
|
1. **`graph-layout.js`** — `buildGraph()` edges section: add `addEdge()` call
|
||||||
|
2. **`graph-connections.js`** — `CONNECTION_MAP`: add the field entry
|
||||||
|
3. **`graph-edges.js`** — `EDGE_COLORS` if a new edge type is needed
|
||||||
|
|
||||||
|
### Adding a new entity subtype
|
||||||
|
|
||||||
|
1. **`graph-nodes.js`** — `SUBTYPE_ICONS[kind]` — add icon for the new subtype
|
||||||
|
2. **`graph-layout.js`** — `buildGraph()` — ensure `subtype` is extracted from the entity data
|
||||||
|
|
||||||
|
## Features & keyboard shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Open search |
|
||||||
|
| `F` | Toggle filter |
|
||||||
|
| `F11` | Toggle fullscreen |
|
||||||
|
| `+` | Add entity menu |
|
||||||
|
| `Escape` | Close filter → close search → deselect all |
|
||||||
|
| `Delete` | Delete selected edge or node |
|
||||||
|
| `Arrows / WASD` | Spatial navigation between nodes |
|
||||||
|
| `Ctrl+A` | Select all nodes |
|
||||||
|
|
||||||
|
## Node color overrides
|
||||||
|
|
||||||
|
Per-node colors stored in `localStorage` key `graph_node_colors`. The `getNodeColor(nodeId, kind)` function returns the override or falls back to `ENTITY_COLORS[kind]`. The color bar on the left side of each node is clickable to open a native color picker.
|
||||||
|
|
||||||
|
## Filter system
|
||||||
|
|
||||||
|
The filter bar (toggled with F or toolbar button) filters nodes by name/kind/subtype. Non-matching nodes get the `.graph-filtered-out` CSS class (low opacity, no pointer events). Edges where either endpoint is filtered also dim. Minimap nodes for filtered-out entities become nearly invisible (opacity 0.07).
|
||||||
|
|
||||||
|
## Minimap
|
||||||
|
|
||||||
|
Rendered as a small SVG with colored rects for each node and a viewport rect. Supports drag-to-pan, resize handles, and position persistence in localStorage.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
**Hover events** use `pointerover`/`pointerout` with `relatedTarget` check to prevent flicker when the cursor moves between child SVG elements within the same `<g>` node.
|
||||||
|
|
||||||
|
**Node titles** display the full entity name (no truncation). Native SVG `<title>` tooltips are omitted on nodes to avoid conflict with the custom tooltip.
|
||||||
|
|
||||||
|
## New entity focus
|
||||||
|
|
||||||
|
When a user adds an entity via the graph's + menu, a watcher subscribes to all caches, detects the new ID, reloads the graph, and uses `zoomToPoint()` to smoothly fly to the new node with zoom + highlight animation.
|
||||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -12,6 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
CONF_SERVER_NAME,
|
||||||
CONF_SERVER_URL,
|
CONF_SERVER_URL,
|
||||||
CONF_API_KEY,
|
CONF_API_KEY,
|
||||||
DEFAULT_SCAN_INTERVAL,
|
DEFAULT_SCAN_INTERVAL,
|
||||||
@@ -27,14 +30,18 @@ from .ws_manager import KeyColorsWebSocketManager
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [
|
PLATFORMS: list[Platform] = [
|
||||||
|
Platform.BUTTON,
|
||||||
|
Platform.LIGHT,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.NUMBER,
|
Platform.NUMBER,
|
||||||
|
Platform.SELECT,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up LED Screen Controller from a config entry."""
|
"""Set up LED Screen Controller from a config entry."""
|
||||||
|
server_name = entry.data.get(CONF_SERVER_NAME, "LED Screen Controller")
|
||||||
server_url = entry.data[CONF_SERVER_URL]
|
server_url = entry.data[CONF_SERVER_URL]
|
||||||
api_key = entry.data[CONF_API_KEY]
|
api_key = entry.data[CONF_API_KEY]
|
||||||
|
|
||||||
@@ -54,8 +61,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
event_listener = EventStreamListener(hass, server_url, api_key, coordinator)
|
event_listener = EventStreamListener(hass, server_url, api_key, coordinator)
|
||||||
await event_listener.start()
|
await event_listener.start()
|
||||||
|
|
||||||
# Create device entries for each target
|
# Create device entries for each target and remove stale ones
|
||||||
device_registry = dr.async_get(hass)
|
device_registry = dr.async_get(hass)
|
||||||
|
current_identifiers: set[tuple[str, str]] = set()
|
||||||
if coordinator.data and "targets" in coordinator.data:
|
if coordinator.data and "targets" in coordinator.data:
|
||||||
for target_id, target_data in coordinator.data["targets"].items():
|
for target_id, target_data in coordinator.data["targets"].items():
|
||||||
info = target_data["info"]
|
info = target_data["info"]
|
||||||
@@ -69,10 +77,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
config_entry_id=entry.entry_id,
|
config_entry_id=entry.entry_id,
|
||||||
identifiers={(DOMAIN, target_id)},
|
identifiers={(DOMAIN, target_id)},
|
||||||
name=info.get("name", target_id),
|
name=info.get("name", target_id),
|
||||||
manufacturer="LED Screen Controller",
|
manufacturer=server_name,
|
||||||
model=model,
|
model=model,
|
||||||
configuration_url=server_url,
|
configuration_url=server_url,
|
||||||
)
|
)
|
||||||
|
current_identifiers.add((DOMAIN, target_id))
|
||||||
|
|
||||||
|
# Create a single "Scenes" device for scene preset buttons
|
||||||
|
scenes_identifier = (DOMAIN, f"{entry.entry_id}_scenes")
|
||||||
|
scene_presets = coordinator.data.get("scene_presets", []) if coordinator.data else []
|
||||||
|
if scene_presets:
|
||||||
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=entry.entry_id,
|
||||||
|
identifiers={scenes_identifier},
|
||||||
|
name="Scenes",
|
||||||
|
manufacturer=server_name,
|
||||||
|
model="Scene Presets",
|
||||||
|
configuration_url=server_url,
|
||||||
|
)
|
||||||
|
current_identifiers.add(scenes_identifier)
|
||||||
|
|
||||||
|
# Remove devices for targets that no longer exist
|
||||||
|
for device_entry in dr.async_entries_for_config_entry(
|
||||||
|
device_registry, entry.entry_id
|
||||||
|
):
|
||||||
|
if not device_entry.identifiers & current_identifiers:
|
||||||
|
_LOGGER.info("Removing stale device: %s", device_entry.name)
|
||||||
|
device_registry.async_remove_device(device_entry.id)
|
||||||
|
|
||||||
# Store data
|
# Store data
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
@@ -82,13 +113,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
DATA_EVENT_LISTENER: event_listener,
|
DATA_EVENT_LISTENER: event_listener,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Track target IDs to detect changes
|
# Track target and scene IDs to detect changes
|
||||||
initial_target_ids = set(
|
known_target_ids = set(
|
||||||
coordinator.data.get("targets", {}).keys() if coordinator.data else []
|
coordinator.data.get("targets", {}).keys() if coordinator.data else []
|
||||||
)
|
)
|
||||||
|
known_scene_ids = set(
|
||||||
|
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
|
||||||
|
)
|
||||||
|
|
||||||
def _on_coordinator_update() -> None:
|
def _on_coordinator_update() -> None:
|
||||||
"""Manage WS connections and detect target list changes."""
|
"""Manage WS connections and detect target list changes."""
|
||||||
|
nonlocal known_target_ids, known_scene_ids
|
||||||
|
|
||||||
if not coordinator.data:
|
if not coordinator.data:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -100,20 +136,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
state = target_data.get("state") or {}
|
state = target_data.get("state") or {}
|
||||||
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
|
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
|
||||||
if state.get("processing"):
|
if state.get("processing"):
|
||||||
hass.async_create_task(ws_manager.start_listening(target_id))
|
if target_id not in ws_manager._connections:
|
||||||
|
hass.async_create_task(ws_manager.start_listening(target_id))
|
||||||
else:
|
else:
|
||||||
hass.async_create_task(ws_manager.stop_listening(target_id))
|
if target_id in ws_manager._connections:
|
||||||
|
hass.async_create_task(ws_manager.stop_listening(target_id))
|
||||||
|
|
||||||
# Reload if target list changed
|
# Reload if target or scene list changed
|
||||||
current_ids = set(targets.keys())
|
current_ids = set(targets.keys())
|
||||||
if current_ids != initial_target_ids:
|
current_scene_ids = set(
|
||||||
_LOGGER.info("Target list changed, reloading integration")
|
p["id"] for p in coordinator.data.get("scene_presets", [])
|
||||||
|
)
|
||||||
|
if current_ids != known_target_ids or current_scene_ids != known_scene_ids:
|
||||||
|
known_target_ids = current_ids
|
||||||
|
known_scene_ids = current_scene_ids
|
||||||
|
_LOGGER.info("Target or scene list changed, reloading integration")
|
||||||
hass.async_create_task(
|
hass.async_create_task(
|
||||||
hass.config_entries.async_reload(entry.entry_id)
|
hass.config_entries.async_reload(entry.entry_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.async_add_listener(_on_coordinator_update)
|
coordinator.async_add_listener(_on_coordinator_update)
|
||||||
|
|
||||||
|
# Register set_leds service (once across all entries)
|
||||||
|
async def handle_set_leds(call) -> None:
|
||||||
|
"""Handle the set_leds service call."""
|
||||||
|
source_id = call.data["source_id"]
|
||||||
|
segments = call.data["segments"]
|
||||||
|
# Route to the coordinator that owns this source
|
||||||
|
for entry_data in hass.data[DOMAIN].values():
|
||||||
|
coord = entry_data.get(DATA_COORDINATOR)
|
||||||
|
if not coord or not coord.data:
|
||||||
|
continue
|
||||||
|
source_ids = {
|
||||||
|
s["id"] for s in coord.data.get("css_sources", [])
|
||||||
|
}
|
||||||
|
if source_id in source_ids:
|
||||||
|
await coord.push_segments(source_id, segments)
|
||||||
|
return
|
||||||
|
_LOGGER.error("No server found with source_id %s", source_id)
|
||||||
|
|
||||||
|
if not hass.services.has_service(DOMAIN, "set_leds"):
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"set_leds",
|
||||||
|
handle_set_leds,
|
||||||
|
schema=vol.Schema({
|
||||||
|
vol.Required("source_id"): str,
|
||||||
|
vol.Required("segments"): list,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -129,5 +201,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
# Unregister service if no entries remain
|
||||||
|
if not hass.data[DOMAIN]:
|
||||||
|
hass.services.async_remove(DOMAIN, "set_leds")
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|||||||
74
custom_components/wled_screen_controller/button.py
Normal file
74
custom_components/wled_screen_controller/button.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Button platform for LED Screen Controller — scene preset activation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.button import ButtonEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN, DATA_COORDINATOR
|
||||||
|
from .coordinator import WLEDScreenControllerCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up scene preset buttons."""
|
||||||
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
if coordinator.data:
|
||||||
|
for preset in coordinator.data.get("scene_presets", []):
|
||||||
|
entities.append(
|
||||||
|
SceneActivateButton(coordinator, preset, entry.entry_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class SceneActivateButton(CoordinatorEntity, ButtonEntity):
|
||||||
|
"""Button that activates a scene preset."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: WLEDScreenControllerCoordinator,
|
||||||
|
preset: dict[str, Any],
|
||||||
|
entry_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the button."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._preset_id = preset["id"]
|
||||||
|
self._entry_id = entry_id
|
||||||
|
self._attr_unique_id = f"{entry_id}_scene_{preset['id']}"
|
||||||
|
self._attr_translation_key = "activate_scene"
|
||||||
|
self._attr_translation_placeholders = {"scene_name": preset["name"]}
|
||||||
|
self._attr_icon = "mdi:palette"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> dict[str, Any]:
|
||||||
|
"""Return device information — all scene buttons belong to the Scenes device."""
|
||||||
|
return {"identifiers": {(DOMAIN, f"{self._entry_id}_scenes")}}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return if entity is available."""
|
||||||
|
if not self.coordinator.data:
|
||||||
|
return False
|
||||||
|
return self._preset_id in {
|
||||||
|
p["id"] for p in self.coordinator.data.get("scene_presets", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
async def async_press(self) -> None:
|
||||||
|
"""Activate the scene preset."""
|
||||||
|
await self.coordinator.activate_scene(self._preset_id)
|
||||||
@@ -13,12 +13,13 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import DOMAIN, CONF_SERVER_URL, CONF_API_KEY, DEFAULT_TIMEOUT
|
from .const import DOMAIN, CONF_SERVER_NAME, CONF_SERVER_URL, CONF_API_KEY, DEFAULT_TIMEOUT
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
|
vol.Optional(CONF_SERVER_NAME, default="LED Screen Controller"): str,
|
||||||
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str,
|
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str,
|
||||||
vol.Required(CONF_API_KEY): str,
|
vol.Required(CONF_API_KEY): str,
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ async def validate_server(
|
|||||||
headers = {"Authorization": f"Bearer {api_key}"}
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
try:
|
try:
|
||||||
async with session.get(
|
async with session.get(
|
||||||
f"{server_url}/api/v1/picture-targets",
|
f"{server_url}/api/v1/output-targets",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
@@ -87,6 +88,7 @@ class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
|
server_name = user_input.get(CONF_SERVER_NAME, "LED Screen Controller")
|
||||||
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
|
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
|
||||||
api_key = user_input[CONF_API_KEY]
|
api_key = user_input[CONF_API_KEY]
|
||||||
|
|
||||||
@@ -97,8 +99,9 @@ class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title="LED Screen Controller",
|
title=server_name,
|
||||||
data={
|
data={
|
||||||
|
CONF_SERVER_NAME: server_name,
|
||||||
CONF_SERVER_URL: server_url,
|
CONF_SERVER_URL: server_url,
|
||||||
CONF_API_KEY: api_key,
|
CONF_API_KEY: api_key,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
DOMAIN = "wled_screen_controller"
|
DOMAIN = "wled_screen_controller"
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
|
CONF_SERVER_NAME = "server_name"
|
||||||
CONF_SERVER_URL = "server_url"
|
CONF_SERVER_URL = "server_url"
|
||||||
CONF_API_KEY = "api_key"
|
CONF_API_KEY = "api_key"
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.server_version = "unknown"
|
self.server_version = "unknown"
|
||||||
self._auth_headers = {"Authorization": f"Bearer {api_key}"}
|
self._auth_headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
self._pattern_cache: dict[str, list[dict]] = {}
|
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
@@ -85,7 +85,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
kc_settings = target.get("key_colors_settings") or {}
|
kc_settings = target.get("key_colors_settings") or {}
|
||||||
template_id = kc_settings.get("pattern_template_id", "")
|
template_id = kc_settings.get("pattern_template_id", "")
|
||||||
if template_id:
|
if template_id:
|
||||||
result["rectangles"] = await self._get_rectangles(
|
result["rectangles"] = await self._fetch_rectangles(
|
||||||
template_id
|
template_id
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -107,12 +107,22 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
target_id, data = r
|
target_id, data = r
|
||||||
targets_data[target_id] = data
|
targets_data[target_id] = data
|
||||||
|
|
||||||
# Fetch devices with capabilities and brightness
|
# Fetch devices, CSS sources, value sources, and scene presets in parallel
|
||||||
devices_data = await self._fetch_devices()
|
devices_data, css_sources, value_sources, scene_presets = (
|
||||||
|
await asyncio.gather(
|
||||||
|
self._fetch_devices(),
|
||||||
|
self._fetch_css_sources(),
|
||||||
|
self._fetch_value_sources(),
|
||||||
|
self._fetch_scene_presets(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"targets": targets_data,
|
"targets": targets_data,
|
||||||
"devices": devices_data,
|
"devices": devices_data,
|
||||||
|
"css_sources": css_sources,
|
||||||
|
"value_sources": value_sources,
|
||||||
|
"scene_presets": scene_presets,
|
||||||
"server_version": self.server_version,
|
"server_version": self.server_version,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +136,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
try:
|
try:
|
||||||
async with self.session.get(
|
async with self.session.get(
|
||||||
f"{self.server_url}/health",
|
f"{self.server_url}/health",
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
@@ -136,11 +146,11 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
self.server_version = "unknown"
|
self.server_version = "unknown"
|
||||||
|
|
||||||
async def _fetch_targets(self) -> list[dict[str, Any]]:
|
async def _fetch_targets(self) -> list[dict[str, Any]]:
|
||||||
"""Fetch all picture targets."""
|
"""Fetch all output targets."""
|
||||||
async with self.session.get(
|
async with self.session.get(
|
||||||
f"{self.server_url}/api/v1/picture-targets",
|
f"{self.server_url}/api/v1/output-targets",
|
||||||
headers=self._auth_headers,
|
headers=self._auth_headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
@@ -149,9 +159,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
async def _fetch_target_state(self, target_id: str) -> dict[str, Any]:
|
async def _fetch_target_state(self, target_id: str) -> dict[str, Any]:
|
||||||
"""Fetch target processing state."""
|
"""Fetch target processing state."""
|
||||||
async with self.session.get(
|
async with self.session.get(
|
||||||
f"{self.server_url}/api/v1/picture-targets/{target_id}/state",
|
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
|
||||||
headers=self._auth_headers,
|
headers=self._auth_headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return await resp.json()
|
return await resp.json()
|
||||||
@@ -159,29 +169,24 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
async def _fetch_target_metrics(self, target_id: str) -> dict[str, Any]:
|
async def _fetch_target_metrics(self, target_id: str) -> dict[str, Any]:
|
||||||
"""Fetch target metrics."""
|
"""Fetch target metrics."""
|
||||||
async with self.session.get(
|
async with self.session.get(
|
||||||
f"{self.server_url}/api/v1/picture-targets/{target_id}/metrics",
|
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
|
||||||
headers=self._auth_headers,
|
headers=self._auth_headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return await resp.json()
|
return await resp.json()
|
||||||
|
|
||||||
async def _get_rectangles(self, template_id: str) -> list[dict]:
|
async def _fetch_rectangles(self, template_id: str) -> list[dict]:
|
||||||
"""Get rectangles for a pattern template, using cache."""
|
"""Fetch rectangles for a pattern template (no cache — always fresh)."""
|
||||||
if template_id in self._pattern_cache:
|
|
||||||
return self._pattern_cache[template_id]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with self.session.get(
|
async with self.session.get(
|
||||||
f"{self.server_url}/api/v1/pattern-templates/{template_id}",
|
f"{self.server_url}/api/v1/pattern-templates/{template_id}",
|
||||||
headers=self._auth_headers,
|
headers=self._auth_headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
rectangles = data.get("rectangles", [])
|
return data.get("rectangles", [])
|
||||||
self._pattern_cache[template_id] = rectangles
|
|
||||||
return rectangles
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Failed to fetch pattern template %s: %s", template_id, err
|
"Failed to fetch pattern template %s: %s", template_id, err
|
||||||
@@ -194,7 +199,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
async with self.session.get(
|
async with self.session.get(
|
||||||
f"{self.server_url}/api/v1/devices",
|
f"{self.server_url}/api/v1/devices",
|
||||||
headers=self._auth_headers,
|
headers=self._auth_headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
@@ -203,18 +208,16 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
_LOGGER.warning("Failed to fetch devices: %s", err)
|
_LOGGER.warning("Failed to fetch devices: %s", err)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
devices_data: dict[str, dict[str, Any]] = {}
|
# Fetch brightness for all capable devices in parallel
|
||||||
|
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
|
||||||
for device in devices:
|
|
||||||
device_id = device["id"]
|
device_id = device["id"]
|
||||||
entry: dict[str, Any] = {"info": device, "brightness": None}
|
entry: dict[str, Any] = {"info": device, "brightness": None}
|
||||||
|
|
||||||
if "brightness_control" in (device.get("capabilities") or []):
|
if "brightness_control" in (device.get("capabilities") or []):
|
||||||
try:
|
try:
|
||||||
async with self.session.get(
|
async with self.session.get(
|
||||||
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
|
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
|
||||||
headers=self._auth_headers,
|
headers=self._auth_headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
bri_data = await resp.json()
|
bri_data = await resp.json()
|
||||||
@@ -224,7 +227,19 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
"Failed to fetch brightness for device %s: %s",
|
"Failed to fetch brightness for device %s: %s",
|
||||||
device_id, err,
|
device_id, err,
|
||||||
)
|
)
|
||||||
|
return device_id, entry
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(fetch_device_entry(d) for d in devices),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
devices_data: dict[str, dict[str, Any]] = {}
|
||||||
|
for r in results:
|
||||||
|
if isinstance(r, Exception):
|
||||||
|
_LOGGER.warning("Device fetch failed: %s", r)
|
||||||
|
continue
|
||||||
|
device_id, entry = r
|
||||||
devices_data[device_id] = entry
|
devices_data[device_id] = entry
|
||||||
|
|
||||||
return devices_data
|
return devices_data
|
||||||
@@ -235,7 +250,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
|
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
|
||||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||||
json={"brightness": brightness},
|
json={"brightness": brightness},
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
body = await resp.text()
|
body = await resp.text()
|
||||||
@@ -246,14 +261,31 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
await self.async_request_refresh()
|
await self.async_request_refresh()
|
||||||
|
|
||||||
|
async def set_color(self, device_id: str, color: list[int] | None) -> None:
|
||||||
|
"""Set or clear the static color for a device."""
|
||||||
|
async with self.session.put(
|
||||||
|
f"{self.server_url}/api/v1/devices/{device_id}/color",
|
||||||
|
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||||
|
json={"color": color},
|
||||||
|
timeout=self._timeout,
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
body = await resp.text()
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to set color for device %s: %s %s",
|
||||||
|
device_id, resp.status, body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
await self.async_request_refresh()
|
||||||
|
|
||||||
async def set_kc_brightness(self, target_id: str, brightness: int) -> None:
|
async def set_kc_brightness(self, target_id: str, brightness: int) -> None:
|
||||||
"""Set brightness for a Key Colors target (0-255 mapped to 0.0-1.0)."""
|
"""Set brightness for a Key Colors target (0-255 mapped to 0.0-1.0)."""
|
||||||
brightness_float = round(brightness / 255, 4)
|
brightness_float = round(brightness / 255, 4)
|
||||||
async with self.session.put(
|
async with self.session.put(
|
||||||
f"{self.server_url}/api/v1/picture-targets/{target_id}",
|
f"{self.server_url}/api/v1/output-targets/{target_id}",
|
||||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||||
json={"key_colors_settings": {"brightness": brightness_float}},
|
json={"key_colors_settings": {"brightness": brightness_float}},
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
body = await resp.text()
|
body = await resp.text()
|
||||||
@@ -264,12 +296,138 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
await self.async_request_refresh()
|
await self.async_request_refresh()
|
||||||
|
|
||||||
|
async def _fetch_css_sources(self) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch all color strip sources."""
|
||||||
|
try:
|
||||||
|
async with self.session.get(
|
||||||
|
f"{self.server_url}/api/v1/color-strip-sources",
|
||||||
|
headers=self._auth_headers,
|
||||||
|
timeout=self._timeout,
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = await resp.json()
|
||||||
|
return data.get("sources", [])
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.warning("Failed to fetch CSS sources: %s", err)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _fetch_value_sources(self) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch all value sources."""
|
||||||
|
try:
|
||||||
|
async with self.session.get(
|
||||||
|
f"{self.server_url}/api/v1/value-sources",
|
||||||
|
headers=self._auth_headers,
|
||||||
|
timeout=self._timeout,
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = await resp.json()
|
||||||
|
return data.get("sources", [])
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.warning("Failed to fetch value sources: %s", err)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _fetch_scene_presets(self) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch all scene presets."""
|
||||||
|
try:
|
||||||
|
async with self.session.get(
|
||||||
|
f"{self.server_url}/api/v1/scene-presets",
|
||||||
|
headers=self._auth_headers,
|
||||||
|
timeout=self._timeout,
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = await resp.json()
|
||||||
|
return data.get("presets", [])
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.warning("Failed to fetch scene presets: %s", err)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
|
||||||
|
"""Push flat color array to an api_input CSS source."""
|
||||||
|
async with self.session.post(
|
||||||
|
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
|
||||||
|
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||||
|
json={"colors": colors},
|
||||||
|
timeout=self._timeout,
|
||||||
|
) as resp:
|
||||||
|
if resp.status not in (200, 204):
|
||||||
|
body = await resp.text()
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to push colors to source %s: %s %s",
|
||||||
|
source_id, resp.status, body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
|
||||||
|
"""Push segment data to an api_input CSS source."""
|
||||||
|
async with self.session.post(
|
||||||
|
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
|
||||||
|
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||||
|
json={"segments": segments},
|
||||||
|
timeout=self._timeout,
|
||||||
|
) as resp:
|
||||||
|
if resp.status not in (200, 204):
|
||||||
|
body = await resp.text()
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to push segments to source %s: %s %s",
|
||||||
|
source_id, resp.status, body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
async def activate_scene(self, preset_id: str) -> None:
|
||||||
|
"""Activate a scene preset."""
|
||||||
|
async with self.session.post(
|
||||||
|
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
|
||||||
|
headers=self._auth_headers,
|
||||||
|
timeout=self._timeout,
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
body = await resp.text()
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to activate scene %s: %s %s",
|
||||||
|
preset_id, resp.status, body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
await self.async_request_refresh()
|
||||||
|
|
||||||
|
async def update_source(self, source_id: str, **kwargs: Any) -> None:
|
||||||
|
"""Update a color strip source's fields."""
|
||||||
|
async with self.session.put(
|
||||||
|
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
|
||||||
|
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||||
|
json=kwargs,
|
||||||
|
timeout=self._timeout,
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
body = await resp.text()
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to update source %s: %s %s",
|
||||||
|
source_id, resp.status, body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
async def update_target(self, target_id: str, **kwargs: Any) -> None:
|
||||||
|
"""Update an output target's fields."""
|
||||||
|
async with self.session.put(
|
||||||
|
f"{self.server_url}/api/v1/output-targets/{target_id}",
|
||||||
|
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||||
|
json=kwargs,
|
||||||
|
timeout=self._timeout,
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
body = await resp.text()
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to update target %s: %s %s",
|
||||||
|
target_id, resp.status, body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
await self.async_request_refresh()
|
||||||
|
|
||||||
async def start_processing(self, target_id: str) -> None:
|
async def start_processing(self, target_id: str) -> None:
|
||||||
"""Start processing for a target."""
|
"""Start processing for a target."""
|
||||||
async with self.session.post(
|
async with self.session.post(
|
||||||
f"{self.server_url}/api/v1/picture-targets/{target_id}/start",
|
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
|
||||||
headers=self._auth_headers,
|
headers=self._auth_headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status == 409:
|
if resp.status == 409:
|
||||||
_LOGGER.debug("Target %s already processing", target_id)
|
_LOGGER.debug("Target %s already processing", target_id)
|
||||||
@@ -285,9 +443,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
async def stop_processing(self, target_id: str) -> None:
|
async def stop_processing(self, target_id: str) -> None:
|
||||||
"""Stop processing for a target."""
|
"""Stop processing for a target."""
|
||||||
async with self.session.post(
|
async with self.session.post(
|
||||||
f"{self.server_url}/api/v1/picture-targets/{target_id}/stop",
|
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
|
||||||
headers=self._auth_headers,
|
headers=self._auth_headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
timeout=self._timeout,
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status == 409:
|
if resp.status == 409:
|
||||||
_LOGGER.debug("Target %s already stopped", target_id)
|
_LOGGER.debug("Target %s already stopped", target_id)
|
||||||
|
|||||||
151
custom_components/wled_screen_controller/light.py
Normal file
151
custom_components/wled_screen_controller/light.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Light platform for LED Screen Controller (api_input CSS sources)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.light import (
|
||||||
|
ATTR_BRIGHTNESS,
|
||||||
|
ATTR_RGB_COLOR,
|
||||||
|
ColorMode,
|
||||||
|
LightEntity,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN, DATA_COORDINATOR
|
||||||
|
from .coordinator import WLEDScreenControllerCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up LED Screen Controller api_input lights."""
|
||||||
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
if coordinator.data:
|
||||||
|
for source in coordinator.data.get("css_sources", []):
|
||||||
|
if source.get("source_type") == "api_input":
|
||||||
|
entities.append(
|
||||||
|
ApiInputLight(coordinator, source, entry.entry_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiInputLight(CoordinatorEntity, LightEntity):
|
||||||
|
"""Representation of an api_input CSS source as a light entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_color_mode = ColorMode.RGB
|
||||||
|
_attr_supported_color_modes = {ColorMode.RGB}
|
||||||
|
_attr_translation_key = "api_input_light"
|
||||||
|
_attr_icon = "mdi:led-strip-variant"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: WLEDScreenControllerCoordinator,
|
||||||
|
source: dict[str, Any],
|
||||||
|
entry_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the light."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._source_id: str = source["id"]
|
||||||
|
self._source_name: str = source.get("name", self._source_id)
|
||||||
|
self._entry_id = entry_id
|
||||||
|
self._attr_unique_id = f"{self._source_id}_light"
|
||||||
|
|
||||||
|
# Restore state from fallback_color
|
||||||
|
fallback = self._get_fallback_color()
|
||||||
|
is_off = fallback == [0, 0, 0]
|
||||||
|
self._is_on: bool = not is_off
|
||||||
|
self._rgb_color: tuple[int, int, int] = (
|
||||||
|
(255, 255, 255) if is_off else tuple(fallback) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
self._brightness: int = 255
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> dict[str, Any]:
|
||||||
|
"""Return device information — one virtual device per api_input source."""
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, self._source_id)},
|
||||||
|
"name": self._source_name,
|
||||||
|
"manufacturer": "WLED Screen Controller",
|
||||||
|
"model": "API Input CSS Source",
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the entity name."""
|
||||||
|
return self._source_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return true if the light is on."""
|
||||||
|
return self._is_on
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rgb_color(self) -> tuple[int, int, int]:
|
||||||
|
"""Return the current RGB color."""
|
||||||
|
return self._rgb_color
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brightness(self) -> int:
|
||||||
|
"""Return the current brightness (0-255)."""
|
||||||
|
return self._brightness
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn on the light, optionally setting color and brightness."""
|
||||||
|
if ATTR_RGB_COLOR in kwargs:
|
||||||
|
self._rgb_color = kwargs[ATTR_RGB_COLOR]
|
||||||
|
if ATTR_BRIGHTNESS in kwargs:
|
||||||
|
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||||
|
|
||||||
|
# Scale RGB by brightness
|
||||||
|
scale = self._brightness / 255
|
||||||
|
r, g, b = self._rgb_color
|
||||||
|
scaled = [round(r * scale), round(g * scale), round(b * scale)]
|
||||||
|
|
||||||
|
await self.coordinator.push_segments(
|
||||||
|
self._source_id,
|
||||||
|
[{"start": 0, "length": 9999, "mode": "solid", "color": scaled}],
|
||||||
|
)
|
||||||
|
# Update fallback_color so the color persists beyond the timeout
|
||||||
|
await self.coordinator.update_source(
|
||||||
|
self._source_id, fallback_color=scaled,
|
||||||
|
)
|
||||||
|
self._is_on = True
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn off the light by pushing black and setting fallback to black."""
|
||||||
|
off_color = [0, 0, 0]
|
||||||
|
await self.coordinator.push_segments(
|
||||||
|
self._source_id,
|
||||||
|
[{"start": 0, "length": 9999, "mode": "solid", "color": off_color}],
|
||||||
|
)
|
||||||
|
await self.coordinator.update_source(
|
||||||
|
self._source_id, fallback_color=off_color,
|
||||||
|
)
|
||||||
|
self._is_on = False
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
def _get_fallback_color(self) -> list[int]:
|
||||||
|
"""Read fallback_color from the source config in coordinator data."""
|
||||||
|
if not self.coordinator.data:
|
||||||
|
return [0, 0, 0]
|
||||||
|
for source in self.coordinator.data.get("css_sources", []):
|
||||||
|
if source.get("id") == self._source_id:
|
||||||
|
fallback = source.get("fallback_color")
|
||||||
|
if fallback and len(fallback) >= 3:
|
||||||
|
return list(fallback[:3])
|
||||||
|
break
|
||||||
|
return [0, 0, 0]
|
||||||
@@ -51,7 +51,7 @@ async def async_setup_entry(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
capabilities = device_data.get("info", {}).get("capabilities") or []
|
capabilities = device_data.get("info", {}).get("capabilities") or []
|
||||||
if "brightness_control" not in capabilities:
|
if "brightness_control" not in capabilities or "static_color" in capabilities:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
entities.append(
|
entities.append(
|
||||||
|
|||||||
177
custom_components/wled_screen_controller/select.py
Normal file
177
custom_components/wled_screen_controller/select.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""Select platform for LED Screen Controller (CSS source & brightness source)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.select import SelectEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS
|
||||||
|
from .coordinator import WLEDScreenControllerCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NONE_OPTION = "— None —"
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up LED Screen Controller select entities."""
|
||||||
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||||
|
|
||||||
|
entities: list[SelectEntity] = []
|
||||||
|
if coordinator.data and "targets" in coordinator.data:
|
||||||
|
for target_id, target_data in coordinator.data["targets"].items():
|
||||||
|
info = target_data["info"]
|
||||||
|
|
||||||
|
# Only LED targets
|
||||||
|
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entities.append(
|
||||||
|
CSSSourceSelect(coordinator, target_id, entry.entry_id)
|
||||||
|
)
|
||||||
|
entities.append(
|
||||||
|
BrightnessSourceSelect(coordinator, target_id, entry.entry_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class CSSSourceSelect(CoordinatorEntity, SelectEntity):
|
||||||
|
"""Select entity for choosing a color strip source for an LED target."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_icon = "mdi:palette"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: WLEDScreenControllerCoordinator,
|
||||||
|
target_id: str,
|
||||||
|
entry_id: str,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._target_id = target_id
|
||||||
|
self._entry_id = entry_id
|
||||||
|
self._attr_unique_id = f"{target_id}_css_source"
|
||||||
|
self._attr_translation_key = "color_strip_source"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> dict[str, Any]:
|
||||||
|
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def options(self) -> list[str]:
|
||||||
|
if not self.coordinator.data:
|
||||||
|
return []
|
||||||
|
sources = self.coordinator.data.get("css_sources") or []
|
||||||
|
return [s["name"] for s in sources]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_option(self) -> str | None:
|
||||||
|
if not self.coordinator.data:
|
||||||
|
return None
|
||||||
|
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||||
|
if not target_data:
|
||||||
|
return None
|
||||||
|
current_id = target_data["info"].get("color_strip_source_id", "")
|
||||||
|
sources = self.coordinator.data.get("css_sources") or []
|
||||||
|
for s in sources:
|
||||||
|
if s["id"] == current_id:
|
||||||
|
return s["name"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
if not self.coordinator.data:
|
||||||
|
return False
|
||||||
|
return self._target_id in self.coordinator.data.get("targets", {})
|
||||||
|
|
||||||
|
async def async_select_option(self, option: str) -> None:
|
||||||
|
source_id = self._name_to_id_map().get(option)
|
||||||
|
if source_id is None:
|
||||||
|
_LOGGER.error("CSS source not found: %s", option)
|
||||||
|
return
|
||||||
|
await self.coordinator.update_target(
|
||||||
|
self._target_id, color_strip_source_id=source_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def _name_to_id_map(self) -> dict[str, str]:
|
||||||
|
sources = (self.coordinator.data or {}).get("css_sources") or []
|
||||||
|
return {s["name"]: s["id"] for s in sources}
|
||||||
|
|
||||||
|
|
||||||
|
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
|
||||||
|
"""Select entity for choosing a brightness value source for an LED target."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_icon = "mdi:brightness-auto"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: WLEDScreenControllerCoordinator,
|
||||||
|
target_id: str,
|
||||||
|
entry_id: str,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._target_id = target_id
|
||||||
|
self._entry_id = entry_id
|
||||||
|
self._attr_unique_id = f"{target_id}_brightness_source"
|
||||||
|
self._attr_translation_key = "brightness_source"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> dict[str, Any]:
|
||||||
|
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def options(self) -> list[str]:
|
||||||
|
if not self.coordinator.data:
|
||||||
|
return [NONE_OPTION]
|
||||||
|
sources = self.coordinator.data.get("value_sources") or []
|
||||||
|
return [NONE_OPTION] + [s["name"] for s in sources]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_option(self) -> str | None:
|
||||||
|
if not self.coordinator.data:
|
||||||
|
return None
|
||||||
|
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||||
|
if not target_data:
|
||||||
|
return None
|
||||||
|
current_id = target_data["info"].get("brightness_value_source_id", "")
|
||||||
|
if not current_id:
|
||||||
|
return NONE_OPTION
|
||||||
|
sources = self.coordinator.data.get("value_sources") or []
|
||||||
|
for s in sources:
|
||||||
|
if s["id"] == current_id:
|
||||||
|
return s["name"]
|
||||||
|
return NONE_OPTION
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
if not self.coordinator.data:
|
||||||
|
return False
|
||||||
|
return self._target_id in self.coordinator.data.get("targets", {})
|
||||||
|
|
||||||
|
async def async_select_option(self, option: str) -> None:
|
||||||
|
if option == NONE_OPTION:
|
||||||
|
source_id = ""
|
||||||
|
else:
|
||||||
|
name_map = {
|
||||||
|
s["name"]: s["id"]
|
||||||
|
for s in (self.coordinator.data or {}).get("value_sources") or []
|
||||||
|
}
|
||||||
|
source_id = name_map.get(option)
|
||||||
|
if source_id is None:
|
||||||
|
_LOGGER.error("Value source not found: %s", option)
|
||||||
|
return
|
||||||
|
await self.coordinator.update_target(
|
||||||
|
self._target_id, brightness_value_source_id=source_id
|
||||||
|
)
|
||||||
19
custom_components/wled_screen_controller/services.yaml
Normal file
19
custom_components/wled_screen_controller/services.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
set_leds:
|
||||||
|
name: Set LEDs
|
||||||
|
description: Push segment data to an api_input color strip source
|
||||||
|
fields:
|
||||||
|
source_id:
|
||||||
|
name: Source ID
|
||||||
|
description: The api_input CSS source ID (e.g., css_abc12345)
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
segments:
|
||||||
|
name: Segments
|
||||||
|
description: >
|
||||||
|
List of segment objects. Each segment has: start (int), length (int),
|
||||||
|
mode ("solid"/"per_pixel"/"gradient"), color ([R,G,B] for solid),
|
||||||
|
colors ([[R,G,B],...] for per_pixel/gradient)
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
@@ -5,10 +5,12 @@
|
|||||||
"title": "Set up LED Screen Controller",
|
"title": "Set up LED Screen Controller",
|
||||||
"description": "Enter the URL and API key for your LED Screen Controller server.",
|
"description": "Enter the URL and API key for your LED Screen Controller server.",
|
||||||
"data": {
|
"data": {
|
||||||
|
"server_name": "Server Name",
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"api_key": "API Key"
|
"api_key": "API Key"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
|
"server_name": "Display name for this server in Home Assistant",
|
||||||
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
|
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
|
||||||
"api_key": "API key from your server's configuration file"
|
"api_key": "API key from your server's configuration file"
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
"button": {
|
||||||
|
"activate_scene": {
|
||||||
|
"name": "{scene_name}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"api_input_light": {
|
||||||
|
"name": "Light"
|
||||||
|
}
|
||||||
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"processing": {
|
"processing": {
|
||||||
"name": "Processing"
|
"name": "Processing"
|
||||||
@@ -50,6 +62,30 @@
|
|||||||
"brightness": {
|
"brightness": {
|
||||||
"name": "Brightness"
|
"name": "Brightness"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"color_strip_source": {
|
||||||
|
"name": "Color Strip Source"
|
||||||
|
},
|
||||||
|
"brightness_source": {
|
||||||
|
"name": "Brightness Source"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"set_leds": {
|
||||||
|
"name": "Set LEDs",
|
||||||
|
"description": "Push segment data to an api_input color strip source.",
|
||||||
|
"fields": {
|
||||||
|
"source_id": {
|
||||||
|
"name": "Source ID",
|
||||||
|
"description": "The api_input CSS source ID (e.g., css_abc12345)."
|
||||||
|
},
|
||||||
|
"segments": {
|
||||||
|
"name": "Segments",
|
||||||
|
"description": "List of segment objects with start, length, mode, and color/colors fields."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
"title": "Set up LED Screen Controller",
|
"title": "Set up LED Screen Controller",
|
||||||
"description": "Enter the URL and API key for your LED Screen Controller server.",
|
"description": "Enter the URL and API key for your LED Screen Controller server.",
|
||||||
"data": {
|
"data": {
|
||||||
|
"server_name": "Server Name",
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"api_key": "API Key"
|
"api_key": "API Key"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
|
"server_name": "Display name for this server in Home Assistant",
|
||||||
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
|
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
|
||||||
"api_key": "API key from your server's configuration file"
|
"api_key": "API key from your server's configuration file"
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
"button": {
|
||||||
|
"activate_scene": {
|
||||||
|
"name": "{scene_name}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"api_input_light": {
|
||||||
|
"name": "Light"
|
||||||
|
}
|
||||||
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"processing": {
|
"processing": {
|
||||||
"name": "Processing"
|
"name": "Processing"
|
||||||
@@ -50,6 +62,14 @@
|
|||||||
"brightness": {
|
"brightness": {
|
||||||
"name": "Brightness"
|
"name": "Brightness"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"color_strip_source": {
|
||||||
|
"name": "Color Strip Source"
|
||||||
|
},
|
||||||
|
"brightness_source": {
|
||||||
|
"name": "Brightness Source"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
"title": "Настройка LED Screen Controller",
|
"title": "Настройка LED Screen Controller",
|
||||||
"description": "Введите URL и API-ключ вашего сервера LED Screen Controller.",
|
"description": "Введите URL и API-ключ вашего сервера LED Screen Controller.",
|
||||||
"data": {
|
"data": {
|
||||||
|
"server_name": "Имя сервера",
|
||||||
"server_url": "URL сервера",
|
"server_url": "URL сервера",
|
||||||
"api_key": "API-ключ"
|
"api_key": "API-ключ"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
|
"server_name": "Отображаемое имя сервера в Home Assistant",
|
||||||
"server_url": "URL сервера LED Screen Controller (например, http://192.168.1.100:8080)",
|
"server_url": "URL сервера LED Screen Controller (например, http://192.168.1.100:8080)",
|
||||||
"api_key": "API-ключ из конфигурационного файла сервера"
|
"api_key": "API-ключ из конфигурационного файла сервера"
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
"button": {
|
||||||
|
"activate_scene": {
|
||||||
|
"name": "{scene_name}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"api_input_light": {
|
||||||
|
"name": "Подсветка"
|
||||||
|
}
|
||||||
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"processing": {
|
"processing": {
|
||||||
"name": "Обработка"
|
"name": "Обработка"
|
||||||
@@ -50,6 +62,14 @@
|
|||||||
"brightness": {
|
"brightness": {
|
||||||
"name": "Яркость"
|
"name": "Яркость"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"color_strip_source": {
|
||||||
|
"name": "Источник цветовой полосы"
|
||||||
|
},
|
||||||
|
"brightness_source": {
|
||||||
|
"name": "Источник яркости"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class KeyColorsWebSocketManager:
|
|||||||
ws_base = self._server_url.replace("http://", "ws://").replace(
|
ws_base = self._server_url.replace("http://", "ws://").replace(
|
||||||
"https://", "wss://"
|
"https://", "wss://"
|
||||||
)
|
)
|
||||||
return f"{ws_base}/api/v1/picture-targets/{target_id}/ws?token={self._api_key}"
|
return f"{ws_base}/api/v1/output-targets/{target_id}/ws?token={self._api_key}"
|
||||||
|
|
||||||
async def start_listening(self, target_id: str) -> None:
|
async def start_listening(self, target_id: str) -> None:
|
||||||
"""Start WebSocket connection for a target."""
|
"""Start WebSocket connection for a target."""
|
||||||
|
|||||||
18
docs/API.md
18
docs/API.md
@@ -201,12 +201,11 @@ Get processing settings.
|
|||||||
{
|
{
|
||||||
"display_index": 0,
|
"display_index": 0,
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
"border_width": 10,
|
"brightness": 1.0,
|
||||||
"color_correction": {
|
"smoothing": 0.3,
|
||||||
"gamma": 2.2,
|
"interpolation_mode": "average",
|
||||||
"saturation": 1.0,
|
"standby_interval": 1.0,
|
||||||
"brightness": 1.0
|
"state_check_interval": 30
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -219,12 +218,7 @@ Update processing settings.
|
|||||||
{
|
{
|
||||||
"display_index": 1,
|
"display_index": 1,
|
||||||
"fps": 60,
|
"fps": 60,
|
||||||
"border_width": 15,
|
"brightness": 0.8
|
||||||
"color_correction": {
|
|
||||||
"gamma": 2.4,
|
|
||||||
"saturation": 1.2,
|
|
||||||
"brightness": 0.8
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
30
plans/demo-mode/CONTEXT.md
Normal file
30
plans/demo-mode/CONTEXT.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Feature Context: Demo Mode
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
Starting implementation. No changes made yet.
|
||||||
|
|
||||||
|
## Key Architecture Notes
|
||||||
|
- `EngineRegistry` (class-level dict) holds capture engines, auto-registered in `capture_engines/__init__.py`
|
||||||
|
- `AudioEngineRegistry` (class-level dict) holds audio engines, auto-registered in `audio/__init__.py`
|
||||||
|
- `LEDDeviceProvider` instances registered via `register_provider()` in `led_client.py`
|
||||||
|
- Already has `MockDeviceProvider` + `MockClient` (device type "mock") for testing
|
||||||
|
- Config is `pydantic_settings.BaseSettings` in `config.py`, loaded from YAML + env vars
|
||||||
|
- Frontend header in `templates/index.html` line 27-31: title + version badge
|
||||||
|
- Frontend bundle: `cd server && npm run build` (esbuild)
|
||||||
|
- Data stored as JSON in `data/` directory, paths configured via `StorageConfig`
|
||||||
|
|
||||||
|
## Temporary Workarounds
|
||||||
|
- None yet
|
||||||
|
|
||||||
|
## Cross-Phase Dependencies
|
||||||
|
- Phase 1 (config flag) is foundational — all other phases depend on `is_demo_mode()`
|
||||||
|
- Phase 2 & 3 (engines) can be done independently of each other
|
||||||
|
- Phase 4 (seed data) depends on knowing what entities to create, which is informed by phases 2-3
|
||||||
|
- Phase 5 (frontend) depends on the system info API field from phase 1
|
||||||
|
- Phase 6 (engine resolution) depends on engines existing from phases 2-3
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
- Demo mode activated via `WLED_DEMO=true` env var or `demo: true` in YAML config
|
||||||
|
- Isolated data directory `data/demo/` keeps demo entities separate from real config
|
||||||
|
- Demo engines use `ENGINE_TYPE = "demo"` and are always registered but return `is_available() = True` only in demo mode
|
||||||
|
- The existing `MockDeviceProvider`/`MockClient` can be reused or extended for demo device output
|
||||||
44
plans/demo-mode/PLAN.md
Normal file
44
plans/demo-mode/PLAN.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Feature: Demo Mode
|
||||||
|
|
||||||
|
**Branch:** `feature/demo-mode`
|
||||||
|
**Base branch:** `master`
|
||||||
|
**Created:** 2026-03-20
|
||||||
|
**Status:** 🟡 In Progress
|
||||||
|
**Strategy:** Big Bang
|
||||||
|
**Mode:** Automated
|
||||||
|
**Execution:** Orchestrator
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Add a demo mode that allows users to explore and test the app without real hardware. Virtual capture engines, audio engines, and device providers replace real hardware. An isolated data directory with seed data provides a fully populated sandbox. A visual indicator in the UI makes it clear the app is running in demo mode.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
- **Build (frontend):** `cd server && npm run build`
|
||||||
|
- **Typecheck (frontend):** `cd server && npm run typecheck`
|
||||||
|
- **Test (backend):** `cd server && python -m pytest ../tests/ -x`
|
||||||
|
- **Server start:** `cd server && python -m wled_controller.main`
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
- [x] Phase 1: Demo Mode Config & Flag [domain: backend] → [subplan](./phase-1-config-flag.md)
|
||||||
|
- [x] Phase 2: Virtual Capture Engine [domain: backend] → [subplan](./phase-2-virtual-capture-engine.md)
|
||||||
|
- [x] Phase 3: Virtual Audio Engine [domain: backend] → [subplan](./phase-3-virtual-audio-engine.md)
|
||||||
|
- [x] Phase 4: Demo Device Provider & Seed Data [domain: backend] → [subplan](./phase-4-demo-device-seed-data.md)
|
||||||
|
- [x] Phase 5: Frontend Demo Indicator & Sandbox UX [domain: fullstack] → [subplan](./phase-5-frontend-demo-ux.md)
|
||||||
|
- [x] Phase 6: Demo-only Engine Resolution [domain: backend] → [subplan](./phase-6-engine-resolution.md)
|
||||||
|
|
||||||
|
## Phase Progress Log
|
||||||
|
|
||||||
|
| Phase | Domain | Status | Review | Build | Committed |
|
||||||
|
|-------|--------|--------|--------|-------|-----------|
|
||||||
|
| Phase 1: Config & Flag | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||||
|
| Phase 2: Virtual Capture Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||||
|
| Phase 3: Virtual Audio Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||||
|
| Phase 4: Demo Device & Seed Data | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||||
|
| Phase 5: Frontend Demo UX | fullstack | ✅ Done | ✅ | ✅ | ⬜ |
|
||||||
|
| Phase 6: Engine Resolution | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||||
|
|
||||||
|
## Final Review
|
||||||
|
- [ ] Comprehensive code review
|
||||||
|
- [ ] Full build passes
|
||||||
|
- [ ] Full test suite passes
|
||||||
|
- [ ] Merged to `master`
|
||||||
42
plans/demo-mode/phase-1-config-flag.md
Normal file
42
plans/demo-mode/phase-1-config-flag.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Phase 1: Demo Mode Config & Flag
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Add a `demo` boolean flag to the application configuration and expose it to the frontend via the system info API. When demo mode is active, the server uses an isolated data directory so demo entities don't pollute real user data.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Task 1: Add `demo: bool = False` field to `Config` class in `config.py`
|
||||||
|
- [ ] Task 2: Add a module-level helper `is_demo_mode() -> bool` in `config.py` for easy import
|
||||||
|
- [ ] Task 3: Modify `StorageConfig` path resolution: when `demo=True`, prefix all storage paths with `data/demo/` instead of `data/`
|
||||||
|
- [ ] Task 4: Expose `demo_mode: bool` in the existing `GET /api/v1/system/info` endpoint response
|
||||||
|
- [ ] Task 5: Add `WLED_DEMO=true` env var support (already handled by pydantic-settings env prefix `WLED_`)
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `server/src/wled_controller/config.py` — Add `demo` field, `is_demo_mode()` helper, storage path override
|
||||||
|
- `server/src/wled_controller/api/routes/system.py` — Add `demo_mode` to system info response
|
||||||
|
- `server/src/wled_controller/api/schemas/system.py` — Add `demo_mode` field to response schema
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- `Config(demo=True)` is accepted; default is `False`
|
||||||
|
- `WLED_DEMO=true` activates demo mode
|
||||||
|
- `is_demo_mode()` returns the correct value
|
||||||
|
- When demo mode is on, all storage files resolve under `data/demo/`
|
||||||
|
- `GET /api/v1/system/info` includes `demo_mode: true/false`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The env var will be `WLED_DEMO` because of `env_prefix="WLED_"` in pydantic-settings
|
||||||
|
- Storage path override should happen at `Config` construction time, not lazily
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completion -->
|
||||||
48
plans/demo-mode/phase-2-virtual-capture-engine.md
Normal file
48
plans/demo-mode/phase-2-virtual-capture-engine.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Phase 2: Virtual Capture Engine
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Create a `DemoCaptureEngine` that provides virtual displays and produces animated test pattern frames, allowing screen capture workflows to function in demo mode without real monitors.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Task 1: Create `server/src/wled_controller/core/capture_engines/demo_engine.py` with `DemoCaptureEngine` and `DemoCaptureStream`
|
||||||
|
- [ ] Task 2: `DemoCaptureEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000` (highest in demo mode)
|
||||||
|
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
|
||||||
|
- [ ] Task 4: `get_available_displays()` returns 3 virtual displays:
|
||||||
|
- "Demo Display 1080p" (1920×1080)
|
||||||
|
- "Demo Ultrawide" (3440×1440)
|
||||||
|
- "Demo Portrait" (1080×1920)
|
||||||
|
- [ ] Task 5: `DemoCaptureStream.capture_frame()` produces animated test patterns:
|
||||||
|
- Horizontally scrolling rainbow gradient (simple, visually clear)
|
||||||
|
- Uses `time.time()` for animation so frames change over time
|
||||||
|
- Returns proper `ScreenCapture` with RGB numpy array
|
||||||
|
- [ ] Task 6: Register `DemoCaptureEngine` in `capture_engines/__init__.py`
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `server/src/wled_controller/core/capture_engines/demo_engine.py` — New file: DemoCaptureEngine + DemoCaptureStream
|
||||||
|
- `server/src/wled_controller/core/capture_engines/__init__.py` — Register DemoCaptureEngine
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- `DemoCaptureEngine.is_available()` is True only in demo mode
|
||||||
|
- Virtual displays appear in the display list API when in demo mode
|
||||||
|
- `capture_frame()` returns valid RGB frames that change over time
|
||||||
|
- Engine is properly registered in EngineRegistry
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Test patterns should be computationally cheap (no heavy image processing)
|
||||||
|
- Use numpy operations for pattern generation (vectorized, fast)
|
||||||
|
- Frame dimensions must match the virtual display dimensions
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completion -->
|
||||||
47
plans/demo-mode/phase-3-virtual-audio-engine.md
Normal file
47
plans/demo-mode/phase-3-virtual-audio-engine.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Phase 3: Virtual Audio Engine
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Create a `DemoAudioEngine` that provides virtual audio devices and produces synthetic audio data, enabling audio-reactive visualizations in demo mode.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Task 1: Create `server/src/wled_controller/core/audio/demo_engine.py` with `DemoAudioEngine` and `DemoAudioCaptureStream`
|
||||||
|
- [ ] Task 2: `DemoAudioEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000`
|
||||||
|
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
|
||||||
|
- [ ] Task 4: `enumerate_devices()` returns 2 virtual devices:
|
||||||
|
- "Demo Microphone" (input, not loopback)
|
||||||
|
- "Demo System Audio" (loopback)
|
||||||
|
- [ ] Task 5: `DemoAudioCaptureStream` implements:
|
||||||
|
- `channels = 2`, `sample_rate = 44100`, `chunk_size = 1024`
|
||||||
|
- `read_chunk()` produces synthetic audio: a mix of sine waves with slowly varying frequencies to simulate music-like beat patterns
|
||||||
|
- Returns proper float32 ndarray
|
||||||
|
- [ ] Task 6: Register `DemoAudioEngine` in `audio/__init__.py`
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `server/src/wled_controller/core/audio/demo_engine.py` — New file: DemoAudioEngine + DemoAudioCaptureStream
|
||||||
|
- `server/src/wled_controller/core/audio/__init__.py` — Register DemoAudioEngine
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- `DemoAudioEngine.is_available()` is True only in demo mode
|
||||||
|
- Virtual audio devices appear in audio device enumeration when in demo mode
|
||||||
|
- `read_chunk()` returns valid float32 audio data that varies over time
|
||||||
|
- Audio analyzer produces non-trivial frequency band data from the synthetic signal
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Synthetic audio should produce interesting FFT results (multiple frequencies, amplitude modulation)
|
||||||
|
- Keep it computationally lightweight
|
||||||
|
- Must conform to `AudioCaptureStreamBase` interface exactly
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completion -->
|
||||||
54
plans/demo-mode/phase-4-demo-device-seed-data.md
Normal file
54
plans/demo-mode/phase-4-demo-device-seed-data.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Phase 4: Demo Device Provider & Seed Data
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Create a demo device provider that exposes discoverable virtual LED devices, and build a seed data generator that populates the demo data directory with sample entities on first run.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Task 1: Create `server/src/wled_controller/core/devices/demo_provider.py` — `DemoDeviceProvider` extending `LEDDeviceProvider`:
|
||||||
|
- `device_type = "demo"`
|
||||||
|
- `capabilities = {"manual_led_count", "power_control", "brightness_control", "static_color"}`
|
||||||
|
- `create_client()` returns a `MockClient` (reuse existing)
|
||||||
|
- `discover()` returns 3 pre-defined virtual devices:
|
||||||
|
- "Demo LED Strip" (60 LEDs, ip="demo-strip")
|
||||||
|
- "Demo LED Matrix" (256 LEDs / 16×16, ip="demo-matrix")
|
||||||
|
- "Demo LED Ring" (24 LEDs, ip="demo-ring")
|
||||||
|
- `check_health()` always returns online with simulated ~2ms latency
|
||||||
|
- `validate_device()` returns `{"led_count": <from url>}`
|
||||||
|
- [ ] Task 2: Register `DemoDeviceProvider` in `led_client.py` `_register_builtin_providers()`
|
||||||
|
- [ ] Task 3: Create `server/src/wled_controller/core/demo_seed.py` — seed data generator:
|
||||||
|
- Function `seed_demo_data(storage_config: StorageConfig)` that checks if demo data dir is empty and populates it
|
||||||
|
- Seed entities: 3 devices (matching discover results), 2 output targets, 2 picture sources (using demo engine), 2 CSS sources (gradient + color_cycle), 1 audio source (using demo engine), 1 scene preset, 1 automation
|
||||||
|
- Use proper ID formats matching existing conventions (e.g., `dev_<hex>`, `tgt_<hex>`, etc.)
|
||||||
|
- [ ] Task 4: Call `seed_demo_data()` during server startup in `main.py` when demo mode is active (before stores are loaded)
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `server/src/wled_controller/core/devices/demo_provider.py` — New: DemoDeviceProvider
|
||||||
|
- `server/src/wled_controller/core/devices/led_client.py` — Register DemoDeviceProvider
|
||||||
|
- `server/src/wled_controller/core/demo_seed.py` — New: seed data generator
|
||||||
|
- `server/src/wled_controller/main.py` — Call seed on demo startup
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Demo devices appear in discovery results when in demo mode
|
||||||
|
- Seed data populates `data/demo/` with valid JSON files on first demo run
|
||||||
|
- Subsequent demo runs don't overwrite existing demo data
|
||||||
|
- All seeded entities load correctly in stores
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Seed data must match the exact schema expected by each store (look at existing JSON files for format)
|
||||||
|
- Use the entity dataclass `to_dict()` / store patterns to generate valid data
|
||||||
|
- Demo discovery should NOT appear when not in demo mode
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completion -->
|
||||||
50
plans/demo-mode/phase-5-frontend-demo-ux.md
Normal file
50
plans/demo-mode/phase-5-frontend-demo-ux.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Phase 5: Frontend Demo Indicator & Sandbox UX
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** fullstack
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Add visual indicators in the frontend that clearly communicate demo mode status to the user, including a badge, dismissible banner, and engine labeling.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Task 1: Add `demo_mode` field to system info API response schema (if not already done in Phase 1)
|
||||||
|
- [ ] Task 2: In frontend initialization (`app.ts` or `state.ts`), fetch system info and store `demoMode` in app state
|
||||||
|
- [ ] Task 3: Add `<span class="demo-badge" id="demo-badge" style="display:none">DEMO</span>` next to app title in `index.html` header
|
||||||
|
- [ ] Task 4: CSS for `.demo-badge`: amber/yellow pill shape, subtle pulse animation, clearly visible but not distracting
|
||||||
|
- [ ] Task 5: On app load, if `demoMode` is true: show badge, set `document.body.dataset.demo = 'true'`
|
||||||
|
- [ ] Task 6: Add a dismissible demo banner at the top of the page: "You're in demo mode — all devices and data are virtual. No real hardware is used." with a dismiss (×) button. Store dismissal in localStorage.
|
||||||
|
- [ ] Task 7: Add i18n keys for demo badge and banner text in `en.json`, `ru.json`, `zh.json`
|
||||||
|
- [ ] Task 8: In engine/display dropdowns, demo engines should display with "Demo: " prefix for clarity
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `server/src/wled_controller/templates/index.html` — Demo badge + banner HTML
|
||||||
|
- `server/src/wled_controller/static/css/app.css` — Demo badge + banner styles
|
||||||
|
- `server/src/wled_controller/static/js/app.ts` — Demo mode detection and UI toggle
|
||||||
|
- `server/src/wled_controller/static/js/core/state.ts` — Store demo mode flag
|
||||||
|
- `server/src/wled_controller/static/locales/en.json` — i18n keys
|
||||||
|
- `server/src/wled_controller/static/locales/ru.json` — i18n keys
|
||||||
|
- `server/src/wled_controller/static/locales/zh.json` — i18n keys
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Demo badge visible next to "LED Grab" title when in demo mode
|
||||||
|
- Demo badge hidden when not in demo mode
|
||||||
|
- Banner appears on first demo visit, can be dismissed, stays dismissed across refreshes
|
||||||
|
- Engine dropdowns clearly label demo engines
|
||||||
|
- All text is localized
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Badge should use `--warning-color` or a custom amber for the pill
|
||||||
|
- Banner should be a thin strip, not intrusive
|
||||||
|
- `localStorage` key: `demo-banner-dismissed`
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completion -->
|
||||||
46
plans/demo-mode/phase-6-engine-resolution.md
Normal file
46
plans/demo-mode/phase-6-engine-resolution.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Phase 6: Demo-only Engine Resolution
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Ensure demo engines are the primary/preferred engines in demo mode, and are hidden when not in demo mode. This makes demo mode act as a "virtual platform" where only demo engines resolve.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Task 1: Modify `EngineRegistry.get_available_engines()` to filter out engines with `ENGINE_TYPE == "demo"` when not in demo mode (they report `is_available()=False` anyway, but belt-and-suspenders)
|
||||||
|
- [ ] Task 2: Modify `AudioEngineRegistry.get_available_engines()` similarly
|
||||||
|
- [ ] Task 3: In demo mode, `get_best_available_engine()` should return the demo engine (already handled by priority=1000, but verify)
|
||||||
|
- [ ] Task 4: Modify the `GET /api/v1/config/displays` endpoint: in demo mode, default to demo engine displays if no engine_type specified
|
||||||
|
- [ ] Task 5: Modify the audio engine listing endpoint similarly
|
||||||
|
- [ ] Task 6: Ensure `DemoDeviceProvider.discover()` only returns devices when in demo mode
|
||||||
|
- [ ] Task 7: End-to-end verification: start server in demo mode, verify only demo engines/devices appear in API responses
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `server/src/wled_controller/core/capture_engines/factory.py` — Filter demo engines
|
||||||
|
- `server/src/wled_controller/core/audio/factory.py` — Filter demo engines
|
||||||
|
- `server/src/wled_controller/api/routes/system.py` — Display endpoint defaults
|
||||||
|
- `server/src/wled_controller/api/routes/audio_templates.py` — Audio engine listing
|
||||||
|
- `server/src/wled_controller/core/devices/demo_provider.py` — Guard discover()
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- In demo mode: demo engines are primary, real engines may also be listed but demo is default
|
||||||
|
- Not in demo mode: demo engines are completely hidden from all API responses
|
||||||
|
- Display list defaults to demo displays in demo mode
|
||||||
|
- Audio device list defaults to demo devices in demo mode
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- This is the "demo OS identifier" concept — demo mode acts as a virtual platform
|
||||||
|
- Be careful not to break existing behavior when demo=False (default)
|
||||||
|
- The demo engines already have `is_available() = is_demo_mode()`, so the main concern is UI defaults
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completion -->
|
||||||
@@ -54,7 +54,7 @@ netstat -an | grep 8080
|
|||||||
- **Permission errors**: Ensure file permissions allow Python to execute
|
- **Permission errors**: Ensure file permissions allow Python to execute
|
||||||
|
|
||||||
#### Files that DON'T require restart:
|
#### Files that DON'T require restart:
|
||||||
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - these are served directly
|
- 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
|
- Locale files (`static/locales/*.json`) - loaded by frontend
|
||||||
- Documentation files (`*.md`)
|
- Documentation files (`*.md`)
|
||||||
- Configuration files in `config/` if server supports hot-reload (check implementation)
|
- Configuration files in `config/` if server supports hot-reload (check implementation)
|
||||||
@@ -130,6 +130,31 @@ After restarting the server with new code:
|
|||||||
|
|
||||||
## Frontend UI Patterns
|
## 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
|
### Modal Dialogs
|
||||||
|
|
||||||
**IMPORTANT**: All modal dialogs must follow these standards for consistent UX:
|
**IMPORTANT**: All modal dialogs must follow these standards for consistent UX:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY config/ ./config/
|
COPY config/ ./config/
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir ".[notifications]"
|
||||||
|
|
||||||
# Create directories for data and logs
|
# Create directories for data and logs
|
||||||
RUN mkdir -p /app/data /app/logs
|
RUN mkdir -p /app/data /app/logs
|
||||||
|
|||||||
@@ -17,9 +17,18 @@ storage:
|
|||||||
templates_file: "data/capture_templates.json"
|
templates_file: "data/capture_templates.json"
|
||||||
postprocessing_templates_file: "data/postprocessing_templates.json"
|
postprocessing_templates_file: "data/postprocessing_templates.json"
|
||||||
picture_sources_file: "data/picture_sources.json"
|
picture_sources_file: "data/picture_sources.json"
|
||||||
picture_targets_file: "data/picture_targets.json"
|
output_targets_file: "data/output_targets.json"
|
||||||
pattern_templates_file: "data/pattern_templates.json"
|
pattern_templates_file: "data/pattern_templates.json"
|
||||||
|
|
||||||
|
mqtt:
|
||||||
|
enabled: false
|
||||||
|
broker_host: "localhost"
|
||||||
|
broker_port: 1883
|
||||||
|
username: ""
|
||||||
|
password: ""
|
||||||
|
client_id: "ledgrab"
|
||||||
|
base_topic: "ledgrab"
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
format: "json" # json or text
|
format: "json" # json or text
|
||||||
file: "logs/wled_controller.log"
|
file: "logs/wled_controller.log"
|
||||||
|
|||||||
34
server/config/demo_config.yaml
Normal file
34
server/config/demo_config.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Demo mode configuration
|
||||||
|
# Loaded automatically when WLED_DEMO=true is set.
|
||||||
|
# Uses isolated data directory (data/demo/) and a pre-configured API key
|
||||||
|
# so the demo works out of the box with zero setup.
|
||||||
|
|
||||||
|
demo: true
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8081
|
||||||
|
log_level: "INFO"
|
||||||
|
cors_origins:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
auth:
|
||||||
|
api_keys:
|
||||||
|
demo: "demo"
|
||||||
|
|
||||||
|
storage:
|
||||||
|
devices_file: "data/devices.json"
|
||||||
|
templates_file: "data/capture_templates.json"
|
||||||
|
postprocessing_templates_file: "data/postprocessing_templates.json"
|
||||||
|
picture_sources_file: "data/picture_sources.json"
|
||||||
|
output_targets_file: "data/output_targets.json"
|
||||||
|
pattern_templates_file: "data/pattern_templates.json"
|
||||||
|
|
||||||
|
mqtt:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
logging:
|
||||||
|
format: "text"
|
||||||
|
file: "logs/wled_controller.log"
|
||||||
|
max_size_mb: 100
|
||||||
|
backup_count: 5
|
||||||
@@ -15,7 +15,7 @@ storage:
|
|||||||
templates_file: "data/capture_templates.json"
|
templates_file: "data/capture_templates.json"
|
||||||
postprocessing_templates_file: "data/postprocessing_templates.json"
|
postprocessing_templates_file: "data/postprocessing_templates.json"
|
||||||
picture_sources_file: "data/picture_sources.json"
|
picture_sources_file: "data/picture_sources.json"
|
||||||
picture_targets_file: "data/picture_targets.json"
|
output_targets_file: "data/output_targets.json"
|
||||||
pattern_templates_file: "data/pattern_templates.json"
|
pattern_templates_file: "data/pattern_templates.json"
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
43
server/esbuild.mjs
Normal file
43
server/esbuild.mjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as esbuild from 'esbuild';
|
||||||
|
|
||||||
|
const srcDir = 'src/wled_controller/static';
|
||||||
|
const outDir = `${srcDir}/dist`;
|
||||||
|
|
||||||
|
const watch = process.argv.includes('--watch');
|
||||||
|
|
||||||
|
/** @type {esbuild.BuildOptions} */
|
||||||
|
const jsOpts = {
|
||||||
|
entryPoints: [`${srcDir}/js/app.ts`],
|
||||||
|
bundle: true,
|
||||||
|
format: 'iife',
|
||||||
|
outfile: `${outDir}/app.bundle.js`,
|
||||||
|
minify: true,
|
||||||
|
sourcemap: true,
|
||||||
|
target: ['es2020'],
|
||||||
|
logLevel: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {esbuild.BuildOptions} */
|
||||||
|
const cssOpts = {
|
||||||
|
entryPoints: [`${srcDir}/css/all.css`],
|
||||||
|
bundle: true,
|
||||||
|
outdir: outDir,
|
||||||
|
outbase: `${srcDir}/css`,
|
||||||
|
minify: true,
|
||||||
|
sourcemap: true,
|
||||||
|
logLevel: 'info',
|
||||||
|
loader: { '.woff2': 'file' },
|
||||||
|
assetNames: '[name]',
|
||||||
|
entryNames: 'app.bundle',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (watch) {
|
||||||
|
const jsCtx = await esbuild.context(jsOpts);
|
||||||
|
const cssCtx = await esbuild.context(cssOpts);
|
||||||
|
await jsCtx.watch();
|
||||||
|
await cssCtx.watch();
|
||||||
|
console.log('Watching for changes...');
|
||||||
|
} else {
|
||||||
|
await esbuild.build(jsOpts);
|
||||||
|
await esbuild.build(cssOpts);
|
||||||
|
}
|
||||||
754
server/package-lock.json
generated
Normal file
754
server/package-lock.json
generated
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 2,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"elkjs": "^0.11.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.27.4",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||||
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/elkjs": {
|
||||||
|
"version": "0.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
|
||||||
|
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.4",
|
||||||
|
"@esbuild/android-arm": "0.27.4",
|
||||||
|
"@esbuild/android-arm64": "0.27.4",
|
||||||
|
"@esbuild/android-x64": "0.27.4",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.4",
|
||||||
|
"@esbuild/darwin-x64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.4",
|
||||||
|
"@esbuild/linux-arm": "0.27.4",
|
||||||
|
"@esbuild/linux-arm64": "0.27.4",
|
||||||
|
"@esbuild/linux-ia32": "0.27.4",
|
||||||
|
"@esbuild/linux-loong64": "0.27.4",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.4",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.4",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.4",
|
||||||
|
"@esbuild/linux-s390x": "0.27.4",
|
||||||
|
"@esbuild/linux-x64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.4",
|
||||||
|
"@esbuild/sunos-x64": "0.27.4",
|
||||||
|
"@esbuild/win32-arm64": "0.27.4",
|
||||||
|
"@esbuild/win32-ia32": "0.27.4",
|
||||||
|
"@esbuild/win32-x64": "0.27.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/android-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/android-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||||
|
},
|
||||||
|
"chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"requires": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"elkjs": {
|
||||||
|
"version": "0.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
|
||||||
|
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="
|
||||||
|
},
|
||||||
|
"esbuild": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.4",
|
||||||
|
"@esbuild/android-arm": "0.27.4",
|
||||||
|
"@esbuild/android-arm64": "0.27.4",
|
||||||
|
"@esbuild/android-x64": "0.27.4",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.4",
|
||||||
|
"@esbuild/darwin-x64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.4",
|
||||||
|
"@esbuild/linux-arm": "0.27.4",
|
||||||
|
"@esbuild/linux-arm64": "0.27.4",
|
||||||
|
"@esbuild/linux-ia32": "0.27.4",
|
||||||
|
"@esbuild/linux-loong64": "0.27.4",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.4",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.4",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.4",
|
||||||
|
"@esbuild/linux-s390x": "0.27.4",
|
||||||
|
"@esbuild/linux-x64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.4",
|
||||||
|
"@esbuild/sunos-x64": "0.27.4",
|
||||||
|
"@esbuild/win32-arm64": "0.27.4",
|
||||||
|
"@esbuild/win32-ia32": "0.27.4",
|
||||||
|
"@esbuild/win32-x64": "0.27.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
server/package.json
Normal file
26
server/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "High-performance FastAPI server that captures screen content and controls WLED devices for ambient lighting.",
|
||||||
|
"main": "index.js",
|
||||||
|
"directories": {
|
||||||
|
"doc": "docs",
|
||||||
|
"test": "tests"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node esbuild.mjs",
|
||||||
|
"watch": "node esbuild.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.27.4",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"elkjs": "^0.11.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,11 +36,16 @@ dependencies = [
|
|||||||
"python-json-logger>=3.1.0",
|
"python-json-logger>=3.1.0",
|
||||||
"python-dateutil>=2.9.0",
|
"python-dateutil>=2.9.0",
|
||||||
"python-multipart>=0.0.12",
|
"python-multipart>=0.0.12",
|
||||||
|
"jinja2>=3.1.0",
|
||||||
"wmi>=1.5.1; sys_platform == 'win32'",
|
"wmi>=1.5.1; sys_platform == 'win32'",
|
||||||
"zeroconf>=0.131.0",
|
"zeroconf>=0.131.0",
|
||||||
"pyserial>=3.5",
|
"pyserial>=3.5",
|
||||||
"psutil>=5.9.0",
|
"psutil>=5.9.0",
|
||||||
"nvidia-ml-py>=12.0.0; sys_platform == 'win32'",
|
"nvidia-ml-py>=12.0.0",
|
||||||
|
"PyAudioWPatch>=0.2.12; sys_platform == 'win32'",
|
||||||
|
"sounddevice>=0.5",
|
||||||
|
"aiomqtt>=2.0.0",
|
||||||
|
"openrgb-python>=0.2.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -52,6 +57,14 @@ dev = [
|
|||||||
"black>=24.0.0",
|
"black>=24.0.0",
|
||||||
"ruff>=0.6.0",
|
"ruff>=0.6.0",
|
||||||
]
|
]
|
||||||
|
camera = [
|
||||||
|
"opencv-python-headless>=4.8.0",
|
||||||
|
]
|
||||||
|
# OS notification capture
|
||||||
|
notifications = [
|
||||||
|
"winsdk>=1.0.0b10; sys_platform == 'win32'",
|
||||||
|
"dbus-next>=0.2.3; sys_platform == 'linux'",
|
||||||
|
]
|
||||||
# High-performance screen capture engines (Windows only)
|
# High-performance screen capture engines (Windows only)
|
||||||
perf = [
|
perf = [
|
||||||
"dxcam>=0.0.5; sys_platform == 'win32'",
|
"dxcam>=0.0.5; sys_platform == 'win32'",
|
||||||
|
|||||||
37
server/restart.ps1
Normal file
37
server/restart.ps1
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Restart the WLED Screen Controller server
|
||||||
|
# Stop any running instance
|
||||||
|
$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||||
|
Where-Object { $_.CommandLine -like '*wled_controller.main*' }
|
||||||
|
foreach ($p in $procs) {
|
||||||
|
Write-Host "Stopping server (PID $($p.ProcessId))..."
|
||||||
|
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
if ($procs) { Start-Sleep -Seconds 2 }
|
||||||
|
|
||||||
|
# Merge registry PATH with current PATH so newly-installed tools (e.g. scrcpy) are visible
|
||||||
|
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||||
|
if ($regUser) {
|
||||||
|
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
|
||||||
|
foreach ($dir in ($regUser -split ';')) {
|
||||||
|
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
|
||||||
|
$env:PATH = "$env:PATH;$dir"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start server detached
|
||||||
|
Write-Host "Starting server..."
|
||||||
|
Start-Process -FilePath python -ArgumentList '-m', 'wled_controller.main' `
|
||||||
|
-WorkingDirectory 'c:\Users\Alexei\Documents\wled-screen-controller\server' `
|
||||||
|
-WindowStyle Hidden
|
||||||
|
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
|
# Verify it's running
|
||||||
|
$check = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||||
|
Where-Object { $_.CommandLine -like '*wled_controller.main*' }
|
||||||
|
if ($check) {
|
||||||
|
Write-Host "Server started (PID $($check[0].ProcessId))"
|
||||||
|
} else {
|
||||||
|
Write-Host "WARNING: Server does not appear to be running!"
|
||||||
|
}
|
||||||
27
server/restart.sh
Normal file
27
server/restart.sh
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Restart the WLED Screen Controller server (Linux/macOS)
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# Stop any running instance
|
||||||
|
PIDS=$(pgrep -f 'wled_controller\.main' 2>/dev/null || true)
|
||||||
|
if [ -n "$PIDS" ]; then
|
||||||
|
echo "Stopping server (PID $PIDS)..."
|
||||||
|
pkill -f 'wled_controller\.main' 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start server detached
|
||||||
|
echo "Starting server..."
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
nohup python -m wled_controller.main > /dev/null 2>&1 &
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Verify it's running
|
||||||
|
NEW_PID=$(pgrep -f 'wled_controller\.main' 2>/dev/null || true)
|
||||||
|
if [ -n "$NEW_PID" ]; then
|
||||||
|
echo "Server started (PID $NEW_PID)"
|
||||||
|
else
|
||||||
|
echo "WARNING: Server does not appear to be running!"
|
||||||
|
fi
|
||||||
@@ -8,8 +8,17 @@ 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.picture_targets import router as picture_targets_router
|
from .routes.output_targets import router as output_targets_router
|
||||||
from .routes.profiles import router as profiles_router
|
from .routes.color_strip_sources import router as color_strip_sources_router
|
||||||
|
from .routes.audio import router as audio_router
|
||||||
|
from .routes.audio_sources import router as audio_sources_router
|
||||||
|
from .routes.audio_templates import router as audio_templates_router
|
||||||
|
from .routes.value_sources import router as value_sources_router
|
||||||
|
from .routes.automations import router as automations_router
|
||||||
|
from .routes.scene_presets import router as scene_presets_router
|
||||||
|
from .routes.webhooks import router as webhooks_router
|
||||||
|
from .routes.sync_clocks import router as sync_clocks_router
|
||||||
|
from .routes.color_strip_processing import router as cspt_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -18,7 +27,16 @@ router.include_router(templates_router)
|
|||||||
router.include_router(postprocessing_router)
|
router.include_router(postprocessing_router)
|
||||||
router.include_router(pattern_templates_router)
|
router.include_router(pattern_templates_router)
|
||||||
router.include_router(picture_sources_router)
|
router.include_router(picture_sources_router)
|
||||||
router.include_router(picture_targets_router)
|
router.include_router(color_strip_sources_router)
|
||||||
router.include_router(profiles_router)
|
router.include_router(audio_router)
|
||||||
|
router.include_router(audio_sources_router)
|
||||||
|
router.include_router(audio_templates_router)
|
||||||
|
router.include_router(value_sources_router)
|
||||||
|
router.include_router(output_targets_router)
|
||||||
|
router.include_router(automations_router)
|
||||||
|
router.include_router(scene_presets_router)
|
||||||
|
router.include_router(webhooks_router)
|
||||||
|
router.include_router(sync_clocks_router)
|
||||||
|
router.include_router(cspt_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def verify_api_key(
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not authenticated_as:
|
if not authenticated_as:
|
||||||
logger.warning(f"Invalid API key attempt: {token[:8]}...")
|
logger.warning("Invalid API key attempt")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid API key",
|
detail="Invalid API key",
|
||||||
@@ -75,3 +75,16 @@ def verify_api_key(
|
|||||||
# Dependency for protected routes
|
# Dependency for protected routes
|
||||||
# Returns the label/identifier of the authenticated client
|
# Returns the label/identifier of the authenticated client
|
||||||
AuthRequired = Annotated[str, Depends(verify_api_key)]
|
AuthRequired = Annotated[str, Depends(verify_api_key)]
|
||||||
|
|
||||||
|
|
||||||
|
def verify_ws_token(token: str) -> bool:
|
||||||
|
"""Check a WebSocket query-param token against configured API keys.
|
||||||
|
|
||||||
|
Use this for WebSocket endpoints where FastAPI's Depends() isn't available.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
if token and config.auth.api_keys:
|
||||||
|
for _label, api_key in config.auth.api_keys.items():
|
||||||
|
if secrets.compare_digest(token, api_key):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
"""Dependency injection for API routes."""
|
"""Dependency injection for API routes.
|
||||||
|
|
||||||
|
Uses a registry dict instead of individual module-level globals.
|
||||||
|
All getter function signatures remain unchanged for FastAPI Depends() compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Type, TypeVar
|
||||||
|
|
||||||
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
|
||||||
@@ -6,83 +12,130 @@ 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
|
||||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||||
from wled_controller.storage.profile_store import ProfileStore
|
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||||
|
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
||||||
|
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||||
|
from wled_controller.storage.automation_store import AutomationStore
|
||||||
|
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||||
|
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||||
|
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||||
|
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||||
|
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||||
|
|
||||||
# Global instances (initialized in main.py)
|
T = TypeVar("T")
|
||||||
_device_store: DeviceStore | None = None
|
|
||||||
_template_store: TemplateStore | None = None
|
# Central dependency registry — keyed by type or string label
|
||||||
_pp_template_store: PostprocessingTemplateStore | None = None
|
_deps: Dict[str, Any] = {}
|
||||||
_pattern_template_store: PatternTemplateStore | None = None
|
|
||||||
_picture_source_store: PictureSourceStore | None = None
|
|
||||||
_picture_target_store: PictureTargetStore | None = None
|
def _get(key: str, label: str) -> Any:
|
||||||
_processor_manager: ProcessorManager | None = None
|
"""Get a dependency by key, raising RuntimeError if not initialized."""
|
||||||
_profile_store: ProfileStore | None = None
|
dep = _deps.get(key)
|
||||||
_profile_engine: ProfileEngine | None = None
|
if dep is None:
|
||||||
|
raise RuntimeError(f"{label} not initialized")
|
||||||
|
return dep
|
||||||
|
|
||||||
|
|
||||||
|
# ── Typed getters (unchanged signatures for FastAPI Depends()) ──────────
|
||||||
|
|
||||||
|
|
||||||
def get_device_store() -> DeviceStore:
|
def get_device_store() -> DeviceStore:
|
||||||
"""Get device store dependency."""
|
return _get("device_store", "Device store")
|
||||||
if _device_store is None:
|
|
||||||
raise RuntimeError("Device store not initialized")
|
|
||||||
return _device_store
|
|
||||||
|
|
||||||
|
|
||||||
def get_template_store() -> TemplateStore:
|
def get_template_store() -> TemplateStore:
|
||||||
"""Get template store dependency."""
|
return _get("template_store", "Template store")
|
||||||
if _template_store is None:
|
|
||||||
raise RuntimeError("Template store not initialized")
|
|
||||||
return _template_store
|
|
||||||
|
|
||||||
|
|
||||||
def get_pp_template_store() -> PostprocessingTemplateStore:
|
def get_pp_template_store() -> PostprocessingTemplateStore:
|
||||||
"""Get postprocessing template store dependency."""
|
return _get("pp_template_store", "Postprocessing template store")
|
||||||
if _pp_template_store is None:
|
|
||||||
raise RuntimeError("Postprocessing template store not initialized")
|
|
||||||
return _pp_template_store
|
|
||||||
|
|
||||||
|
|
||||||
def get_pattern_template_store() -> PatternTemplateStore:
|
def get_pattern_template_store() -> PatternTemplateStore:
|
||||||
"""Get pattern template store dependency."""
|
return _get("pattern_template_store", "Pattern template store")
|
||||||
if _pattern_template_store is None:
|
|
||||||
raise RuntimeError("Pattern template store not initialized")
|
|
||||||
return _pattern_template_store
|
|
||||||
|
|
||||||
|
|
||||||
def get_picture_source_store() -> PictureSourceStore:
|
def get_picture_source_store() -> PictureSourceStore:
|
||||||
"""Get picture source store dependency."""
|
return _get("picture_source_store", "Picture source store")
|
||||||
if _picture_source_store is None:
|
|
||||||
raise RuntimeError("Picture source store not initialized")
|
|
||||||
return _picture_source_store
|
|
||||||
|
|
||||||
|
|
||||||
def get_picture_target_store() -> PictureTargetStore:
|
def get_output_target_store() -> OutputTargetStore:
|
||||||
"""Get picture target store dependency."""
|
return _get("output_target_store", "Output target store")
|
||||||
if _picture_target_store is None:
|
|
||||||
raise RuntimeError("Picture target store not initialized")
|
|
||||||
return _picture_target_store
|
def get_color_strip_store() -> ColorStripStore:
|
||||||
|
return _get("color_strip_store", "Color strip store")
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_source_store() -> AudioSourceStore:
|
||||||
|
return _get("audio_source_store", "Audio source store")
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_template_store() -> AudioTemplateStore:
|
||||||
|
return _get("audio_template_store", "Audio template store")
|
||||||
|
|
||||||
|
|
||||||
|
def get_value_source_store() -> ValueSourceStore:
|
||||||
|
return _get("value_source_store", "Value source store")
|
||||||
|
|
||||||
|
|
||||||
def get_processor_manager() -> ProcessorManager:
|
def get_processor_manager() -> ProcessorManager:
|
||||||
"""Get processor manager dependency."""
|
return _get("processor_manager", "Processor manager")
|
||||||
if _processor_manager is None:
|
|
||||||
raise RuntimeError("Processor manager not initialized")
|
|
||||||
return _processor_manager
|
|
||||||
|
|
||||||
|
|
||||||
def get_profile_store() -> ProfileStore:
|
def get_automation_store() -> AutomationStore:
|
||||||
"""Get profile store dependency."""
|
return _get("automation_store", "Automation store")
|
||||||
if _profile_store is None:
|
|
||||||
raise RuntimeError("Profile store not initialized")
|
|
||||||
return _profile_store
|
|
||||||
|
|
||||||
|
|
||||||
def get_profile_engine() -> ProfileEngine:
|
def get_scene_preset_store() -> ScenePresetStore:
|
||||||
"""Get profile engine dependency."""
|
return _get("scene_preset_store", "Scene preset store")
|
||||||
if _profile_engine is None:
|
|
||||||
raise RuntimeError("Profile engine not initialized")
|
|
||||||
return _profile_engine
|
def get_automation_engine() -> AutomationEngine:
|
||||||
|
return _get("automation_engine", "Automation engine")
|
||||||
|
|
||||||
|
|
||||||
|
def get_auto_backup_engine() -> AutoBackupEngine:
|
||||||
|
return _get("auto_backup_engine", "Auto-backup engine")
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_clock_store() -> SyncClockStore:
|
||||||
|
return _get("sync_clock_store", "Sync clock store")
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_clock_manager() -> SyncClockManager:
|
||||||
|
return _get("sync_clock_manager", "Sync clock manager")
|
||||||
|
|
||||||
|
|
||||||
|
def get_cspt_store() -> ColorStripProcessingTemplateStore:
|
||||||
|
return _get("cspt_store", "Color strip processing template store")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||||
|
"""Fire an entity_changed event via the ProcessorManager event bus.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_type: e.g. "device", "output_target", "color_strip_source"
|
||||||
|
action: "created", "updated", or "deleted"
|
||||||
|
entity_id: The entity's unique ID
|
||||||
|
"""
|
||||||
|
pm = _deps.get("processor_manager")
|
||||||
|
if pm is not None:
|
||||||
|
pm.fire_event({
|
||||||
|
"type": "entity_changed",
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"action": action,
|
||||||
|
"id": entity_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Initialization ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def init_dependencies(
|
def init_dependencies(
|
||||||
@@ -92,20 +145,37 @@ def init_dependencies(
|
|||||||
pp_template_store: PostprocessingTemplateStore | None = None,
|
pp_template_store: PostprocessingTemplateStore | None = None,
|
||||||
pattern_template_store: PatternTemplateStore | None = None,
|
pattern_template_store: PatternTemplateStore | None = None,
|
||||||
picture_source_store: PictureSourceStore | None = None,
|
picture_source_store: PictureSourceStore | None = None,
|
||||||
picture_target_store: PictureTargetStore | None = None,
|
output_target_store: OutputTargetStore | None = None,
|
||||||
profile_store: ProfileStore | None = None,
|
color_strip_store: ColorStripStore | None = None,
|
||||||
profile_engine: ProfileEngine | None = None,
|
audio_source_store: AudioSourceStore | None = None,
|
||||||
|
audio_template_store: AudioTemplateStore | None = None,
|
||||||
|
value_source_store: ValueSourceStore | None = None,
|
||||||
|
automation_store: AutomationStore | None = None,
|
||||||
|
scene_preset_store: ScenePresetStore | None = None,
|
||||||
|
automation_engine: AutomationEngine | None = None,
|
||||||
|
auto_backup_engine: AutoBackupEngine | None = None,
|
||||||
|
sync_clock_store: SyncClockStore | None = None,
|
||||||
|
sync_clock_manager: SyncClockManager | None = None,
|
||||||
|
cspt_store: ColorStripProcessingTemplateStore | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
global _device_store, _template_store, _processor_manager
|
_deps.update({
|
||||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
"device_store": device_store,
|
||||||
global _profile_store, _profile_engine
|
"template_store": template_store,
|
||||||
_device_store = device_store
|
"processor_manager": processor_manager,
|
||||||
_template_store = template_store
|
"pp_template_store": pp_template_store,
|
||||||
_processor_manager = processor_manager
|
"pattern_template_store": pattern_template_store,
|
||||||
_pp_template_store = pp_template_store
|
"picture_source_store": picture_source_store,
|
||||||
_pattern_template_store = pattern_template_store
|
"output_target_store": output_target_store,
|
||||||
_picture_source_store = picture_source_store
|
"color_strip_store": color_strip_store,
|
||||||
_picture_target_store = picture_target_store
|
"audio_source_store": audio_source_store,
|
||||||
_profile_store = profile_store
|
"audio_template_store": audio_template_store,
|
||||||
_profile_engine = profile_engine
|
"value_source_store": value_source_store,
|
||||||
|
"automation_store": automation_store,
|
||||||
|
"scene_preset_store": scene_preset_store,
|
||||||
|
"automation_engine": automation_engine,
|
||||||
|
"auto_backup_engine": auto_backup_engine,
|
||||||
|
"sync_clock_store": sync_clock_store,
|
||||||
|
"sync_clock_manager": sync_clock_manager,
|
||||||
|
"cspt_store": cspt_store,
|
||||||
|
})
|
||||||
|
|||||||
230
server/src/wled_controller/api/routes/_test_helpers.py
Normal file
230
server/src/wled_controller/api/routes/_test_helpers.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""Shared helpers for WebSocket-based capture test endpoints."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from starlette.websockets import WebSocket
|
||||||
|
|
||||||
|
from wled_controller.config import get_config
|
||||||
|
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
PREVIEW_INTERVAL = 0.1 # seconds between intermediate thumbnail sends
|
||||||
|
PREVIEW_MAX_WIDTH = 640 # px for intermediate thumbnails
|
||||||
|
FINAL_THUMBNAIL_WIDTH = 640 # px for the final thumbnail
|
||||||
|
FINAL_JPEG_QUALITY = 90
|
||||||
|
PREVIEW_JPEG_QUALITY = 70
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_ws_token(token: str) -> bool:
|
||||||
|
"""Check a WebSocket query-param token against configured API keys.
|
||||||
|
|
||||||
|
Delegates to the canonical implementation in auth module.
|
||||||
|
"""
|
||||||
|
from wled_controller.api.auth import verify_ws_token
|
||||||
|
return verify_ws_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
|
||||||
|
"""Encode a PIL image as a JPEG base64 data URI."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
pil_image.save(buf, format="JPEG", quality=quality)
|
||||||
|
buf.seek(0)
|
||||||
|
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||||
|
return f"data:image/jpeg;base64,{b64}"
|
||||||
|
|
||||||
|
|
||||||
|
def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int = 80) -> bytes:
|
||||||
|
"""Encode a numpy RGB image to JPEG bytes, optionally downscaling."""
|
||||||
|
import cv2
|
||||||
|
if max_width and image.shape[1] > max_width:
|
||||||
|
scale = max_width / image.shape[1]
|
||||||
|
new_h = int(image.shape[0] * scale)
|
||||||
|
image = cv2.resize(image, (max_width, new_h), interpolation=cv2.INTER_AREA)
|
||||||
|
# RGB → BGR for OpenCV JPEG encoding
|
||||||
|
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
||||||
|
_, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
||||||
|
return buf.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image:
|
||||||
|
"""Create a thumbnail copy of the image, preserving aspect ratio."""
|
||||||
|
thumb = pil_image.copy()
|
||||||
|
aspect = pil_image.height / pil_image.width
|
||||||
|
thumb.thumbnail((max_width, int(max_width * aspect)), Image.Resampling.LANCZOS)
|
||||||
|
return thumb
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_pp_filters(pil_image: Image.Image, flat_filters: list) -> Image.Image:
|
||||||
|
"""Apply postprocessing filter instances to a PIL image."""
|
||||||
|
if not flat_filters:
|
||||||
|
return pil_image
|
||||||
|
pool = ImagePool()
|
||||||
|
arr = np.array(pil_image)
|
||||||
|
for fi in flat_filters:
|
||||||
|
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
|
result = f.process_image(arr, pool)
|
||||||
|
if result is not None:
|
||||||
|
arr = result
|
||||||
|
return Image.fromarray(arr)
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_capture_test(
|
||||||
|
websocket: WebSocket,
|
||||||
|
engine_factory: Callable,
|
||||||
|
duration: float,
|
||||||
|
pp_filters: Optional[list] = None,
|
||||||
|
preview_width: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run a capture test, streaming intermediate thumbnails and a final full-res frame.
|
||||||
|
|
||||||
|
The engine is created and used entirely within a background thread to avoid
|
||||||
|
thread-affinity issues (e.g. MSS uses thread-local state).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: Accepted WebSocket connection.
|
||||||
|
engine_factory: Zero-arg callable that returns an initialized engine stream
|
||||||
|
(with .capture_frame() and .cleanup() methods). Called inside the
|
||||||
|
capture thread so thread-local resources work correctly.
|
||||||
|
duration: Test duration in seconds.
|
||||||
|
pp_filters: Optional list of resolved filter instances to apply to frames.
|
||||||
|
"""
|
||||||
|
thumb_width = preview_width or PREVIEW_MAX_WIDTH
|
||||||
|
|
||||||
|
# Shared state between capture thread and async loop
|
||||||
|
latest_frame = None # PIL Image (converted from numpy)
|
||||||
|
frame_count = 0
|
||||||
|
total_capture_time = 0.0
|
||||||
|
stop_event = threading.Event()
|
||||||
|
done_event = threading.Event()
|
||||||
|
init_error = None # set if engine_factory fails
|
||||||
|
|
||||||
|
def _capture_loop():
|
||||||
|
nonlocal latest_frame, frame_count, total_capture_time, init_error
|
||||||
|
stream = None
|
||||||
|
try:
|
||||||
|
stream = engine_factory()
|
||||||
|
start = time.perf_counter()
|
||||||
|
end = start + duration
|
||||||
|
while time.perf_counter() < end and not stop_event.is_set():
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
capture = stream.capture_frame()
|
||||||
|
t1 = time.perf_counter()
|
||||||
|
if capture is None:
|
||||||
|
time.sleep(0.005)
|
||||||
|
continue
|
||||||
|
total_capture_time += t1 - t0
|
||||||
|
frame_count += 1
|
||||||
|
# Convert numpy → PIL once in the capture thread
|
||||||
|
if isinstance(capture.image, np.ndarray):
|
||||||
|
latest_frame = Image.fromarray(capture.image)
|
||||||
|
else:
|
||||||
|
latest_frame = capture.image
|
||||||
|
except Exception as e:
|
||||||
|
init_error = str(e)
|
||||||
|
logger.error(f"Capture thread error: {e}")
|
||||||
|
finally:
|
||||||
|
if stream:
|
||||||
|
try:
|
||||||
|
stream.cleanup()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
done_event.set()
|
||||||
|
|
||||||
|
# Start capture in background thread
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
capture_future = loop.run_in_executor(None, _capture_loop)
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
last_sent_frame = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Stream intermediate previews
|
||||||
|
while not done_event.is_set():
|
||||||
|
await asyncio.sleep(PREVIEW_INTERVAL)
|
||||||
|
|
||||||
|
# Check for init error
|
||||||
|
if init_error:
|
||||||
|
stop_event.set()
|
||||||
|
await capture_future
|
||||||
|
await websocket.send_json({"type": "error", "detail": init_error})
|
||||||
|
return
|
||||||
|
|
||||||
|
frame = latest_frame
|
||||||
|
if frame is not None and frame is not last_sent_frame:
|
||||||
|
last_sent_frame = frame
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
|
fc = frame_count
|
||||||
|
tc = total_capture_time
|
||||||
|
# Encode preview thumbnail (small + fast)
|
||||||
|
thumb = _make_thumbnail(frame, thumb_width)
|
||||||
|
if pp_filters:
|
||||||
|
thumb = _apply_pp_filters(thumb, pp_filters)
|
||||||
|
thumb_uri = _encode_jpeg(thumb, PREVIEW_JPEG_QUALITY)
|
||||||
|
fps = fc / elapsed if elapsed > 0 else 0
|
||||||
|
avg_ms = (tc / fc * 1000) if fc > 0 else 0
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "frame",
|
||||||
|
"thumbnail": thumb_uri,
|
||||||
|
"frame_count": fc,
|
||||||
|
"elapsed_s": round(elapsed, 2),
|
||||||
|
"fps": round(fps, 1),
|
||||||
|
"avg_capture_ms": round(avg_ms, 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Wait for capture thread to fully finish
|
||||||
|
await capture_future
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
if init_error:
|
||||||
|
await websocket.send_json({"type": "error", "detail": init_error})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send final result
|
||||||
|
final_frame = latest_frame
|
||||||
|
if final_frame is None:
|
||||||
|
await websocket.send_json({"type": "error", "detail": "No frames captured"})
|
||||||
|
return
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
|
fc = frame_count
|
||||||
|
tc = total_capture_time
|
||||||
|
fps = fc / elapsed if elapsed > 0 else 0
|
||||||
|
avg_ms = (tc / fc * 1000) if fc > 0 else 0
|
||||||
|
|
||||||
|
# Apply PP filters to final images
|
||||||
|
if pp_filters:
|
||||||
|
final_frame = _apply_pp_filters(final_frame, pp_filters)
|
||||||
|
|
||||||
|
w, h = final_frame.size
|
||||||
|
|
||||||
|
full_uri = _encode_jpeg(final_frame, FINAL_JPEG_QUALITY)
|
||||||
|
thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH)
|
||||||
|
thumb_uri = _encode_jpeg(thumb, 85)
|
||||||
|
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "result",
|
||||||
|
"full_image": full_uri,
|
||||||
|
"thumbnail": thumb_uri,
|
||||||
|
"width": w,
|
||||||
|
"height": h,
|
||||||
|
"frame_count": fc,
|
||||||
|
"elapsed_s": round(elapsed, 2),
|
||||||
|
"fps": round(fps, 1),
|
||||||
|
"avg_capture_ms": round(avg_ms, 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# WebSocket disconnect or send error — signal capture thread to stop
|
||||||
|
stop_event.set()
|
||||||
|
await capture_future
|
||||||
|
raise
|
||||||
34
server/src/wled_controller/api/routes/audio.py
Normal file
34
server/src/wled_controller/api/routes/audio.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Audio device routes: enumerate available audio devices."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/audio-devices", tags=["Audio"])
|
||||||
|
async def list_audio_devices(_auth: AuthRequired):
|
||||||
|
"""List available audio input/output devices for audio-reactive sources.
|
||||||
|
|
||||||
|
Returns a deduped flat list (backward compat) plus a ``by_engine`` dict
|
||||||
|
with per-engine device lists (no cross-engine dedup) so the frontend can
|
||||||
|
filter by the selected audio template's engine type.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
devices, by_engine = await asyncio.to_thread(
|
||||||
|
lambda: (
|
||||||
|
AudioCaptureManager.enumerate_devices(),
|
||||||
|
AudioCaptureManager.enumerate_devices_by_engine(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"devices": devices,
|
||||||
|
"count": len(devices),
|
||||||
|
"by_engine": by_engine,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"devices": [], "count": 0, "by_engine": {}, "error": str(e)}
|
||||||
251
server/src/wled_controller/api/routes/audio_sources.py
Normal file
251
server/src/wled_controller/api/routes/audio_sources.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_audio_source_store,
|
||||||
|
get_audio_template_store,
|
||||||
|
get_color_strip_store,
|
||||||
|
get_processor_manager,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.audio_sources import (
|
||||||
|
AudioSourceCreate,
|
||||||
|
AudioSourceListResponse,
|
||||||
|
AudioSourceResponse,
|
||||||
|
AudioSourceUpdate,
|
||||||
|
)
|
||||||
|
from wled_controller.storage.audio_source import AudioSource
|
||||||
|
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||||
|
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||||
|
"""Convert an AudioSource to an AudioSourceResponse."""
|
||||||
|
return AudioSourceResponse(
|
||||||
|
id=source.id,
|
||||||
|
name=source.name,
|
||||||
|
source_type=source.source_type,
|
||||||
|
device_index=getattr(source, "device_index", None),
|
||||||
|
is_loopback=getattr(source, "is_loopback", None),
|
||||||
|
audio_template_id=getattr(source, "audio_template_id", None),
|
||||||
|
audio_source_id=getattr(source, "audio_source_id", None),
|
||||||
|
channel=getattr(source, "channel", None),
|
||||||
|
description=source.description,
|
||||||
|
tags=source.tags,
|
||||||
|
created_at=source.created_at,
|
||||||
|
updated_at=source.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
|
||||||
|
async def list_audio_sources(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel or mono"),
|
||||||
|
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||||
|
):
|
||||||
|
"""List all audio sources, optionally filtered by type."""
|
||||||
|
sources = store.get_all_sources()
|
||||||
|
if source_type:
|
||||||
|
sources = [s for s in sources if s.source_type == source_type]
|
||||||
|
return AudioSourceListResponse(
|
||||||
|
sources=[_to_response(s) for s in sources],
|
||||||
|
count=len(sources),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/audio-sources", response_model=AudioSourceResponse, status_code=201, tags=["Audio Sources"])
|
||||||
|
async def create_audio_source(
|
||||||
|
data: AudioSourceCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||||
|
):
|
||||||
|
"""Create a new audio source."""
|
||||||
|
try:
|
||||||
|
source = store.create_source(
|
||||||
|
name=data.name,
|
||||||
|
source_type=data.source_type,
|
||||||
|
device_index=data.device_index,
|
||||||
|
is_loopback=data.is_loopback,
|
||||||
|
audio_source_id=data.audio_source_id,
|
||||||
|
channel=data.channel,
|
||||||
|
description=data.description,
|
||||||
|
audio_template_id=data.audio_template_id,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
fire_entity_event("audio_source", "created", source.id)
|
||||||
|
return _to_response(source)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
|
||||||
|
async def get_audio_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||||
|
):
|
||||||
|
"""Get an audio source by ID."""
|
||||||
|
try:
|
||||||
|
source = store.get_source(source_id)
|
||||||
|
return _to_response(source)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
|
||||||
|
async def update_audio_source(
|
||||||
|
source_id: str,
|
||||||
|
data: AudioSourceUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||||
|
):
|
||||||
|
"""Update an existing audio source."""
|
||||||
|
try:
|
||||||
|
source = store.update_source(
|
||||||
|
source_id=source_id,
|
||||||
|
name=data.name,
|
||||||
|
device_index=data.device_index,
|
||||||
|
is_loopback=data.is_loopback,
|
||||||
|
audio_source_id=data.audio_source_id,
|
||||||
|
channel=data.channel,
|
||||||
|
description=data.description,
|
||||||
|
audio_template_id=data.audio_template_id,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
fire_entity_event("audio_source", "updated", source_id)
|
||||||
|
return _to_response(source)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/audio-sources/{source_id}", status_code=204, tags=["Audio Sources"])
|
||||||
|
async def delete_audio_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||||
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
):
|
||||||
|
"""Delete an audio source."""
|
||||||
|
try:
|
||||||
|
# Check if any CSS entities reference this audio source
|
||||||
|
from wled_controller.storage.color_strip_source import AudioColorStripSource
|
||||||
|
for css in css_store.get_all_sources():
|
||||||
|
if isinstance(css, AudioColorStripSource) and getattr(css, "audio_source_id", None) == source_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot delete: referenced by color strip source '{css.name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
store.delete_source(source_id)
|
||||||
|
fire_entity_event("audio_source", "deleted", 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))
|
||||||
|
|
||||||
|
|
||||||
|
# ===== REAL-TIME AUDIO TEST WEBSOCKET =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/audio-sources/{source_id}/test/ws")
|
||||||
|
async def test_audio_source_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
source_id: str,
|
||||||
|
token: str = Query(""),
|
||||||
|
):
|
||||||
|
"""WebSocket for real-time audio spectrum analysis. Auth via ?token=<api_key>.
|
||||||
|
|
||||||
|
Resolves the audio source to its device, acquires a ManagedAudioStream
|
||||||
|
(ref-counted — shares with running targets), and streams AudioAnalysis
|
||||||
|
snapshots as JSON at ~20 Hz.
|
||||||
|
"""
|
||||||
|
from wled_controller.api.auth import verify_ws_token
|
||||||
|
if not verify_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve source → device info
|
||||||
|
store = get_audio_source_store()
|
||||||
|
template_store = get_audio_template_store()
|
||||||
|
manager = get_processor_manager()
|
||||||
|
|
||||||
|
try:
|
||||||
|
device_index, is_loopback, channel, audio_template_id = store.resolve_audio_source(source_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve template → engine_type + config
|
||||||
|
engine_type = None
|
||||||
|
engine_config = None
|
||||||
|
if audio_template_id:
|
||||||
|
try:
|
||||||
|
template = template_store.get_template(audio_template_id)
|
||||||
|
engine_type = template.engine_type
|
||||||
|
engine_config = template.engine_config
|
||||||
|
except ValueError:
|
||||||
|
pass # Fall back to best available engine
|
||||||
|
|
||||||
|
# Acquire shared audio stream
|
||||||
|
audio_mgr = manager.audio_capture_manager
|
||||||
|
try:
|
||||||
|
stream = audio_mgr.acquire(device_index, is_loopback, engine_type, engine_config)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await websocket.close(code=4003, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"Audio test WebSocket connected for source {source_id}")
|
||||||
|
|
||||||
|
last_ts = 0.0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
analysis = stream.get_latest_analysis()
|
||||||
|
if analysis is not None and analysis.timestamp != last_ts:
|
||||||
|
last_ts = analysis.timestamp
|
||||||
|
|
||||||
|
# Select channel-specific data
|
||||||
|
if channel == "left":
|
||||||
|
spectrum = analysis.left_spectrum
|
||||||
|
rms = analysis.left_rms
|
||||||
|
elif channel == "right":
|
||||||
|
spectrum = analysis.right_spectrum
|
||||||
|
rms = analysis.right_rms
|
||||||
|
else:
|
||||||
|
spectrum = analysis.spectrum
|
||||||
|
rms = analysis.rms
|
||||||
|
|
||||||
|
await websocket.send_json({
|
||||||
|
"spectrum": spectrum.tolist(),
|
||||||
|
"rms": round(rms, 4),
|
||||||
|
"peak": round(analysis.peak, 4),
|
||||||
|
"beat": analysis.beat,
|
||||||
|
"beat_intensity": round(analysis.beat_intensity, 4),
|
||||||
|
})
|
||||||
|
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Audio test WebSocket error for {source_id}: {e}")
|
||||||
|
finally:
|
||||||
|
audio_mgr.release(device_index, is_loopback, engine_type)
|
||||||
|
logger.info(f"Audio test WebSocket disconnected for source {source_id}")
|
||||||
246
server/src/wled_controller/api/routes/audio_templates.py
Normal file
246
server/src/wled_controller/api/routes/audio_templates.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"""Audio capture template and engine routes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import fire_entity_event, get_audio_template_store, get_audio_source_store, get_processor_manager
|
||||||
|
from wled_controller.api.schemas.audio_templates import (
|
||||||
|
AudioEngineInfo,
|
||||||
|
AudioEngineListResponse,
|
||||||
|
AudioTemplateCreate,
|
||||||
|
AudioTemplateListResponse,
|
||||||
|
AudioTemplateResponse,
|
||||||
|
AudioTemplateUpdate,
|
||||||
|
)
|
||||||
|
from wled_controller.core.audio.factory import AudioEngineRegistry
|
||||||
|
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
||||||
|
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ===== AUDIO TEMPLATE ENDPOINTS =====
|
||||||
|
|
||||||
|
@router.get("/api/v1/audio-templates", response_model=AudioTemplateListResponse, tags=["Audio Templates"])
|
||||||
|
async def list_audio_templates(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AudioTemplateStore = Depends(get_audio_template_store),
|
||||||
|
):
|
||||||
|
"""List all audio capture templates."""
|
||||||
|
try:
|
||||||
|
templates = store.get_all_templates()
|
||||||
|
responses = [
|
||||||
|
AudioTemplateResponse(
|
||||||
|
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||||
|
engine_config=t.engine_config, tags=t.tags,
|
||||||
|
created_at=t.created_at,
|
||||||
|
updated_at=t.updated_at, description=t.description,
|
||||||
|
)
|
||||||
|
for t in templates
|
||||||
|
]
|
||||||
|
return AudioTemplateListResponse(templates=responses, count=len(responses))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list audio templates: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201)
|
||||||
|
async def create_audio_template(
|
||||||
|
data: AudioTemplateCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AudioTemplateStore = Depends(get_audio_template_store),
|
||||||
|
):
|
||||||
|
"""Create a new audio capture template."""
|
||||||
|
try:
|
||||||
|
template = store.create_template(
|
||||||
|
name=data.name, engine_type=data.engine_type,
|
||||||
|
engine_config=data.engine_config, description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
fire_entity_event("audio_template", "created", template.id)
|
||||||
|
return AudioTemplateResponse(
|
||||||
|
id=template.id, name=template.name, engine_type=template.engine_type,
|
||||||
|
engine_config=template.engine_config, tags=template.tags,
|
||||||
|
created_at=template.created_at,
|
||||||
|
updated_at=template.updated_at, description=template.description,
|
||||||
|
)
|
||||||
|
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 Exception as e:
|
||||||
|
logger.error(f"Failed to create audio template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
|
||||||
|
async def get_audio_template(
|
||||||
|
template_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AudioTemplateStore = Depends(get_audio_template_store),
|
||||||
|
):
|
||||||
|
"""Get audio template by ID."""
|
||||||
|
try:
|
||||||
|
t = store.get_template(template_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
|
||||||
|
return AudioTemplateResponse(
|
||||||
|
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||||
|
engine_config=t.engine_config, tags=t.tags,
|
||||||
|
created_at=t.created_at,
|
||||||
|
updated_at=t.updated_at, description=t.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
|
||||||
|
async def update_audio_template(
|
||||||
|
template_id: str,
|
||||||
|
data: AudioTemplateUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AudioTemplateStore = Depends(get_audio_template_store),
|
||||||
|
):
|
||||||
|
"""Update an audio template."""
|
||||||
|
try:
|
||||||
|
t = store.update_template(
|
||||||
|
template_id=template_id, name=data.name,
|
||||||
|
engine_type=data.engine_type, engine_config=data.engine_config,
|
||||||
|
description=data.description, tags=data.tags,
|
||||||
|
)
|
||||||
|
fire_entity_event("audio_template", "updated", template_id)
|
||||||
|
return AudioTemplateResponse(
|
||||||
|
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||||
|
engine_config=t.engine_config, tags=t.tags,
|
||||||
|
created_at=t.created_at,
|
||||||
|
updated_at=t.updated_at, description=t.description,
|
||||||
|
)
|
||||||
|
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 Exception as e:
|
||||||
|
logger.error(f"Failed to update audio template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/audio-templates/{template_id}", status_code=204, tags=["Audio Templates"])
|
||||||
|
async def delete_audio_template(
|
||||||
|
template_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AudioTemplateStore = Depends(get_audio_template_store),
|
||||||
|
audio_source_store: AudioSourceStore = Depends(get_audio_source_store),
|
||||||
|
):
|
||||||
|
"""Delete an audio template."""
|
||||||
|
try:
|
||||||
|
store.delete_template(template_id, audio_source_store=audio_source_store)
|
||||||
|
fire_entity_event("audio_template", "deleted", template_id)
|
||||||
|
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 Exception as e:
|
||||||
|
logger.error(f"Failed to delete audio template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ===== AUDIO ENGINE ENDPOINTS =====
|
||||||
|
|
||||||
|
@router.get("/api/v1/audio-engines", response_model=AudioEngineListResponse, tags=["Audio Templates"])
|
||||||
|
async def list_audio_engines(_auth: AuthRequired):
|
||||||
|
"""List all registered audio capture engines."""
|
||||||
|
try:
|
||||||
|
available_set = set(AudioEngineRegistry.get_available_engines())
|
||||||
|
all_engines = AudioEngineRegistry.get_all_engines()
|
||||||
|
|
||||||
|
engines = []
|
||||||
|
for engine_type, engine_class in all_engines.items():
|
||||||
|
engines.append(
|
||||||
|
AudioEngineInfo(
|
||||||
|
type=engine_type,
|
||||||
|
name=engine_type.upper(),
|
||||||
|
default_config=engine_class.get_default_config(),
|
||||||
|
available=(engine_type in available_set),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AudioEngineListResponse(engines=engines, count=len(engines))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list audio engines: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ===== REAL-TIME AUDIO TEMPLATE TEST WEBSOCKET =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/audio-templates/{template_id}/test/ws")
|
||||||
|
async def test_audio_template_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
template_id: str,
|
||||||
|
token: str = Query(""),
|
||||||
|
device_index: int = Query(-1),
|
||||||
|
is_loopback: int = Query(1),
|
||||||
|
):
|
||||||
|
"""WebSocket for real-time audio spectrum test of a template with a chosen device.
|
||||||
|
|
||||||
|
Auth via ?token=<api_key>. Device specified via ?device_index=N&is_loopback=0|1.
|
||||||
|
Streams AudioAnalysis snapshots as JSON at ~20 Hz.
|
||||||
|
"""
|
||||||
|
from wled_controller.api.auth import verify_ws_token
|
||||||
|
if not verify_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve template
|
||||||
|
store = get_audio_template_store()
|
||||||
|
try:
|
||||||
|
template = store.get_template(template_id)
|
||||||
|
except ValueError:
|
||||||
|
await websocket.close(code=4004, reason="Template not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Acquire shared audio stream
|
||||||
|
manager = get_processor_manager()
|
||||||
|
audio_mgr = manager.audio_capture_manager
|
||||||
|
loopback = is_loopback != 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
stream = audio_mgr.acquire(device_index, loopback, template.engine_type, template.engine_config)
|
||||||
|
except RuntimeError as e:
|
||||||
|
await websocket.close(code=4003, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"Audio template test WS connected: template={template_id} device={device_index} loopback={loopback}")
|
||||||
|
|
||||||
|
last_ts = 0.0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
analysis = stream.get_latest_analysis()
|
||||||
|
if analysis is not None and analysis.timestamp != last_ts:
|
||||||
|
last_ts = analysis.timestamp
|
||||||
|
await websocket.send_json({
|
||||||
|
"spectrum": analysis.spectrum.tolist(),
|
||||||
|
"rms": round(analysis.rms, 4),
|
||||||
|
"peak": round(analysis.peak, 4),
|
||||||
|
"beat": analysis.beat,
|
||||||
|
"beat_intensity": round(analysis.beat_intensity, 4),
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Audio template test WS error: {e}")
|
||||||
|
finally:
|
||||||
|
audio_mgr.release(device_index, loopback, template.engine_type)
|
||||||
|
logger.info(f"Audio template test WS disconnected: template={template_id}")
|
||||||
363
server/src/wled_controller/api/routes/automations.py
Normal file
363
server/src/wled_controller/api/routes/automations.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
"""Automation management API routes."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_automation_engine,
|
||||||
|
get_automation_store,
|
||||||
|
get_scene_preset_store,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.automations import (
|
||||||
|
AutomationCreate,
|
||||||
|
AutomationListResponse,
|
||||||
|
AutomationResponse,
|
||||||
|
AutomationUpdate,
|
||||||
|
ConditionSchema,
|
||||||
|
)
|
||||||
|
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||||
|
from wled_controller.storage.automation import (
|
||||||
|
AlwaysCondition,
|
||||||
|
ApplicationCondition,
|
||||||
|
Condition,
|
||||||
|
DisplayStateCondition,
|
||||||
|
MQTTCondition,
|
||||||
|
StartupCondition,
|
||||||
|
SystemIdleCondition,
|
||||||
|
TimeOfDayCondition,
|
||||||
|
WebhookCondition,
|
||||||
|
)
|
||||||
|
from wled_controller.storage.automation_store import AutomationStore
|
||||||
|
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Helpers =====
|
||||||
|
|
||||||
|
def _condition_from_schema(s: ConditionSchema) -> Condition:
|
||||||
|
if s.condition_type == "always":
|
||||||
|
return AlwaysCondition()
|
||||||
|
if s.condition_type == "application":
|
||||||
|
return ApplicationCondition(
|
||||||
|
apps=s.apps or [],
|
||||||
|
match_type=s.match_type or "running",
|
||||||
|
)
|
||||||
|
if s.condition_type == "time_of_day":
|
||||||
|
return TimeOfDayCondition(
|
||||||
|
start_time=s.start_time or "00:00",
|
||||||
|
end_time=s.end_time or "23:59",
|
||||||
|
)
|
||||||
|
if s.condition_type == "system_idle":
|
||||||
|
return SystemIdleCondition(
|
||||||
|
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
||||||
|
when_idle=s.when_idle if s.when_idle is not None else True,
|
||||||
|
)
|
||||||
|
if s.condition_type == "display_state":
|
||||||
|
return DisplayStateCondition(
|
||||||
|
state=s.state or "on",
|
||||||
|
)
|
||||||
|
if s.condition_type == "mqtt":
|
||||||
|
return MQTTCondition(
|
||||||
|
topic=s.topic or "",
|
||||||
|
payload=s.payload or "",
|
||||||
|
match_mode=s.match_mode or "exact",
|
||||||
|
)
|
||||||
|
if s.condition_type == "webhook":
|
||||||
|
return WebhookCondition(
|
||||||
|
token=s.token or secrets.token_hex(16),
|
||||||
|
)
|
||||||
|
if s.condition_type == "startup":
|
||||||
|
return StartupCondition()
|
||||||
|
raise ValueError(f"Unknown condition type: {s.condition_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def _condition_to_schema(c: Condition) -> ConditionSchema:
|
||||||
|
d = c.to_dict()
|
||||||
|
return ConditionSchema(**d)
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_to_response(automation, engine: AutomationEngine, request: Request = None) -> AutomationResponse:
|
||||||
|
state = engine.get_automation_state(automation.id)
|
||||||
|
|
||||||
|
# Build webhook URL from the first webhook condition (if any)
|
||||||
|
webhook_url = None
|
||||||
|
for c in automation.conditions:
|
||||||
|
if isinstance(c, WebhookCondition) and c.token:
|
||||||
|
# Prefer configured external URL, fall back to request base URL
|
||||||
|
from wled_controller.api.routes.system import load_external_url
|
||||||
|
ext = load_external_url()
|
||||||
|
if ext:
|
||||||
|
webhook_url = ext + f"/api/v1/webhooks/{c.token}"
|
||||||
|
elif request:
|
||||||
|
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
|
||||||
|
else:
|
||||||
|
webhook_url = f"/api/v1/webhooks/{c.token}"
|
||||||
|
break
|
||||||
|
|
||||||
|
return AutomationResponse(
|
||||||
|
id=automation.id,
|
||||||
|
name=automation.name,
|
||||||
|
enabled=automation.enabled,
|
||||||
|
condition_logic=automation.condition_logic,
|
||||||
|
conditions=[_condition_to_schema(c) for c in automation.conditions],
|
||||||
|
scene_preset_id=automation.scene_preset_id,
|
||||||
|
deactivation_mode=automation.deactivation_mode,
|
||||||
|
deactivation_scene_preset_id=automation.deactivation_scene_preset_id,
|
||||||
|
webhook_url=webhook_url,
|
||||||
|
is_active=state["is_active"],
|
||||||
|
last_activated_at=state.get("last_activated_at"),
|
||||||
|
last_deactivated_at=state.get("last_deactivated_at"),
|
||||||
|
tags=automation.tags,
|
||||||
|
created_at=automation.created_at,
|
||||||
|
updated_at=automation.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_condition_logic(logic: str) -> None:
|
||||||
|
if logic not in ("or", "and"):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_scene_refs(
|
||||||
|
scene_preset_id: str | None,
|
||||||
|
deactivation_scene_preset_id: str | None,
|
||||||
|
scene_store: ScenePresetStore,
|
||||||
|
) -> None:
|
||||||
|
"""Validate that referenced scene preset IDs exist."""
|
||||||
|
for sid, label in [
|
||||||
|
(scene_preset_id, "scene_preset_id"),
|
||||||
|
(deactivation_scene_preset_id, "deactivation_scene_preset_id"),
|
||||||
|
]:
|
||||||
|
if sid is not None:
|
||||||
|
try:
|
||||||
|
scene_store.get_preset(sid)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Scene preset not found: {sid} ({label})")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CRUD Endpoints =====
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/automations",
|
||||||
|
response_model=AutomationResponse,
|
||||||
|
tags=["Automations"],
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_automation(
|
||||||
|
request: Request,
|
||||||
|
data: AutomationCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AutomationStore = Depends(get_automation_store),
|
||||||
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
|
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
):
|
||||||
|
"""Create a new automation."""
|
||||||
|
_validate_condition_logic(data.condition_logic)
|
||||||
|
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conditions = [_condition_from_schema(c) for c in data.conditions]
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
automation = store.create_automation(
|
||||||
|
name=data.name,
|
||||||
|
enabled=data.enabled,
|
||||||
|
condition_logic=data.condition_logic,
|
||||||
|
conditions=conditions,
|
||||||
|
scene_preset_id=data.scene_preset_id,
|
||||||
|
deactivation_mode=data.deactivation_mode,
|
||||||
|
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
if automation.enabled:
|
||||||
|
await engine.trigger_evaluate()
|
||||||
|
|
||||||
|
fire_entity_event("automation", "created", automation.id)
|
||||||
|
return _automation_to_response(automation, engine, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/automations",
|
||||||
|
response_model=AutomationListResponse,
|
||||||
|
tags=["Automations"],
|
||||||
|
)
|
||||||
|
async def list_automations(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AutomationStore = Depends(get_automation_store),
|
||||||
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
|
):
|
||||||
|
"""List all automations."""
|
||||||
|
automations = store.get_all_automations()
|
||||||
|
return AutomationListResponse(
|
||||||
|
automations=[_automation_to_response(a, engine, request) for a in automations],
|
||||||
|
count=len(automations),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/automations/{automation_id}",
|
||||||
|
response_model=AutomationResponse,
|
||||||
|
tags=["Automations"],
|
||||||
|
)
|
||||||
|
async def get_automation(
|
||||||
|
request: Request,
|
||||||
|
automation_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AutomationStore = Depends(get_automation_store),
|
||||||
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
|
):
|
||||||
|
"""Get a single automation."""
|
||||||
|
try:
|
||||||
|
automation = store.get_automation(automation_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return _automation_to_response(automation, engine, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/automations/{automation_id}",
|
||||||
|
response_model=AutomationResponse,
|
||||||
|
tags=["Automations"],
|
||||||
|
)
|
||||||
|
async def update_automation(
|
||||||
|
request: Request,
|
||||||
|
automation_id: str,
|
||||||
|
data: AutomationUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AutomationStore = Depends(get_automation_store),
|
||||||
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
|
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
):
|
||||||
|
"""Update an automation."""
|
||||||
|
if data.condition_logic is not None:
|
||||||
|
_validate_condition_logic(data.condition_logic)
|
||||||
|
|
||||||
|
# Validate scene refs (only the ones being updated)
|
||||||
|
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
|
||||||
|
|
||||||
|
conditions = None
|
||||||
|
if data.conditions is not None:
|
||||||
|
try:
|
||||||
|
conditions = [_condition_from_schema(c) for c in data.conditions]
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# If disabling, deactivate first
|
||||||
|
if data.enabled is False:
|
||||||
|
await engine.deactivate_if_active(automation_id)
|
||||||
|
|
||||||
|
# Build update kwargs — use sentinel for Optional[str] fields
|
||||||
|
update_kwargs = dict(
|
||||||
|
automation_id=automation_id,
|
||||||
|
name=data.name,
|
||||||
|
enabled=data.enabled,
|
||||||
|
condition_logic=data.condition_logic,
|
||||||
|
conditions=conditions,
|
||||||
|
deactivation_mode=data.deactivation_mode,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
if data.scene_preset_id is not None:
|
||||||
|
update_kwargs["scene_preset_id"] = data.scene_preset_id
|
||||||
|
if data.deactivation_scene_preset_id is not None:
|
||||||
|
update_kwargs["deactivation_scene_preset_id"] = data.deactivation_scene_preset_id
|
||||||
|
|
||||||
|
automation = store.update_automation(**update_kwargs)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
# Re-evaluate immediately if automation is enabled (may have new conditions/scene)
|
||||||
|
if automation.enabled:
|
||||||
|
await engine.trigger_evaluate()
|
||||||
|
|
||||||
|
fire_entity_event("automation", "updated", automation_id)
|
||||||
|
return _automation_to_response(automation, engine, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/automations/{automation_id}",
|
||||||
|
status_code=204,
|
||||||
|
tags=["Automations"],
|
||||||
|
)
|
||||||
|
async def delete_automation(
|
||||||
|
automation_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AutomationStore = Depends(get_automation_store),
|
||||||
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
|
):
|
||||||
|
"""Delete an automation."""
|
||||||
|
# Deactivate first
|
||||||
|
await engine.deactivate_if_active(automation_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
store.delete_automation(automation_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("automation", "deleted", automation_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Enable/Disable =====
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/automations/{automation_id}/enable",
|
||||||
|
response_model=AutomationResponse,
|
||||||
|
tags=["Automations"],
|
||||||
|
)
|
||||||
|
async def enable_automation(
|
||||||
|
request: Request,
|
||||||
|
automation_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AutomationStore = Depends(get_automation_store),
|
||||||
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
|
):
|
||||||
|
"""Enable an automation."""
|
||||||
|
try:
|
||||||
|
automation = store.update_automation(automation_id=automation_id, enabled=True)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
# Evaluate immediately so scene activates without waiting for the next poll cycle
|
||||||
|
await engine.trigger_evaluate()
|
||||||
|
|
||||||
|
return _automation_to_response(automation, engine, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/automations/{automation_id}/disable",
|
||||||
|
response_model=AutomationResponse,
|
||||||
|
tags=["Automations"],
|
||||||
|
)
|
||||||
|
async def disable_automation(
|
||||||
|
request: Request,
|
||||||
|
automation_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AutomationStore = Depends(get_automation_store),
|
||||||
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
|
):
|
||||||
|
"""Disable an automation and deactivate it."""
|
||||||
|
await engine.deactivate_if_active(automation_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
automation = store.update_automation(automation_id=automation_id, enabled=False)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return _automation_to_response(automation, engine, request)
|
||||||
275
server/src/wled_controller/api/routes/color_strip_processing.py
Normal file
275
server/src/wled_controller/api/routes/color_strip_processing.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"""Color strip processing template routes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json as _json
|
||||||
|
import time as _time
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
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_cspt_store,
|
||||||
|
get_device_store,
|
||||||
|
get_processor_manager,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.filters import FilterInstanceSchema
|
||||||
|
from wled_controller.api.schemas.color_strip_processing import (
|
||||||
|
ColorStripProcessingTemplateCreate,
|
||||||
|
ColorStripProcessingTemplateListResponse,
|
||||||
|
ColorStripProcessingTemplateResponse,
|
||||||
|
ColorStripProcessingTemplateUpdate,
|
||||||
|
)
|
||||||
|
from wled_controller.core.filters import FilterInstance
|
||||||
|
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||||
|
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||||
|
from wled_controller.storage import DeviceStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
|
||||||
|
"""Convert a ColorStripProcessingTemplate to its API response."""
|
||||||
|
return ColorStripProcessingTemplateResponse(
|
||||||
|
id=t.id,
|
||||||
|
name=t.name,
|
||||||
|
filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters],
|
||||||
|
created_at=t.created_at,
|
||||||
|
updated_at=t.updated_at,
|
||||||
|
description=t.description,
|
||||||
|
tags=t.tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateListResponse, tags=["Color Strip Processing"])
|
||||||
|
async def list_cspt(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||||
|
):
|
||||||
|
"""List all color strip processing templates."""
|
||||||
|
try:
|
||||||
|
templates = store.get_all_templates()
|
||||||
|
responses = [_cspt_to_response(t) for t in templates]
|
||||||
|
return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list color strip processing templates: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201)
|
||||||
|
async def create_cspt(
|
||||||
|
data: ColorStripProcessingTemplateCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||||
|
):
|
||||||
|
"""Create a new color strip processing template."""
|
||||||
|
try:
|
||||||
|
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters]
|
||||||
|
template = store.create_template(
|
||||||
|
name=data.name,
|
||||||
|
filters=filters,
|
||||||
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
fire_entity_event("cspt", "created", template.id)
|
||||||
|
return _cspt_to_response(template)
|
||||||
|
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 Exception as e:
|
||||||
|
logger.error(f"Failed to create color strip processing template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
|
||||||
|
async def get_cspt(
|
||||||
|
template_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||||
|
):
|
||||||
|
"""Get color strip processing template by ID."""
|
||||||
|
try:
|
||||||
|
template = store.get_template(template_id)
|
||||||
|
return _cspt_to_response(template)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Color strip processing template {template_id} not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
|
||||||
|
async def update_cspt(
|
||||||
|
template_id: str,
|
||||||
|
data: ColorStripProcessingTemplateUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||||
|
):
|
||||||
|
"""Update a color strip processing template."""
|
||||||
|
try:
|
||||||
|
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None
|
||||||
|
template = store.update_template(
|
||||||
|
template_id=template_id,
|
||||||
|
name=data.name,
|
||||||
|
filters=filters,
|
||||||
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
fire_entity_event("cspt", "updated", template_id)
|
||||||
|
return _cspt_to_response(template)
|
||||||
|
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 Exception as e:
|
||||||
|
logger.error(f"Failed to update color strip processing template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"])
|
||||||
|
async def delete_cspt(
|
||||||
|
template_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||||
|
device_store: DeviceStore = Depends(get_device_store),
|
||||||
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
):
|
||||||
|
"""Delete a color strip processing template."""
|
||||||
|
try:
|
||||||
|
refs = store.get_references(template_id, device_store=device_store, css_store=css_store)
|
||||||
|
if refs:
|
||||||
|
names = ", ".join(refs)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Cannot delete: template is referenced by: {names}. "
|
||||||
|
"Please reassign before deleting.",
|
||||||
|
)
|
||||||
|
store.delete_template(template_id)
|
||||||
|
fire_entity_event("cspt", "deleted", template_id)
|
||||||
|
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 Exception as e:
|
||||||
|
logger.error(f"Failed to delete color strip processing template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test / Preview WebSocket ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/color-strip-processing-templates/{template_id}/test/ws")
|
||||||
|
async def test_cspt_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
template_id: str,
|
||||||
|
token: str = Query(""),
|
||||||
|
input_source_id: str = Query(""),
|
||||||
|
led_count: int = Query(100),
|
||||||
|
fps: int = Query(20),
|
||||||
|
):
|
||||||
|
"""WebSocket for real-time CSPT preview.
|
||||||
|
|
||||||
|
Takes an input CSS source, applies the CSPT filter chain, and streams
|
||||||
|
the processed RGB frames. Auth via ``?token=<api_key>``.
|
||||||
|
"""
|
||||||
|
from wled_controller.api.auth import verify_ws_token
|
||||||
|
from wled_controller.core.filters import FilterRegistry
|
||||||
|
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||||
|
|
||||||
|
if not verify_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate template exists
|
||||||
|
cspt_store = get_cspt_store()
|
||||||
|
try:
|
||||||
|
template = cspt_store.get_template(template_id)
|
||||||
|
except (ValueError, RuntimeError) as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not input_source_id:
|
||||||
|
await websocket.close(code=4003, reason="input_source_id is required")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate input source exists
|
||||||
|
css_store = get_color_strip_store()
|
||||||
|
try:
|
||||||
|
input_source = css_store.get_source(input_source_id)
|
||||||
|
except (ValueError, RuntimeError) as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve filter chain
|
||||||
|
try:
|
||||||
|
resolved = cspt_store.resolve_filter_instances(template.filters)
|
||||||
|
filters = [FilterRegistry.create_instance(fi.filter_id, fi.options) for fi in resolved]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"CSPT test: failed to resolve filters for {template_id}: {e}")
|
||||||
|
await websocket.close(code=4003, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Acquire input stream
|
||||||
|
manager: ProcessorManager = get_processor_manager()
|
||||||
|
csm = manager.color_strip_stream_manager
|
||||||
|
consumer_id = f"__cspt_test_{_uuid.uuid4().hex[:8]}__"
|
||||||
|
try:
|
||||||
|
stream = csm.acquire(input_source_id, consumer_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"CSPT test: failed to acquire input stream for {input_source_id}: {e}")
|
||||||
|
await websocket.close(code=4003, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Configure LED count for auto-sizing streams
|
||||||
|
if hasattr(stream, "configure"):
|
||||||
|
stream.configure(max(1, led_count))
|
||||||
|
|
||||||
|
fps = max(1, min(60, fps))
|
||||||
|
frame_interval = 1.0 / fps
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"CSPT test WS connected: template={template_id}, input={input_source_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send metadata
|
||||||
|
meta = {
|
||||||
|
"type": "meta",
|
||||||
|
"source_type": input_source.source_type,
|
||||||
|
"source_name": input_source.name,
|
||||||
|
"template_name": template.name,
|
||||||
|
"led_count": stream.led_count,
|
||||||
|
"filter_count": len(filters),
|
||||||
|
}
|
||||||
|
await websocket.send_text(_json.dumps(meta))
|
||||||
|
|
||||||
|
# Stream processed frames
|
||||||
|
while True:
|
||||||
|
colors = stream.get_latest_colors()
|
||||||
|
if colors is not None:
|
||||||
|
# Apply CSPT filters
|
||||||
|
for flt in filters:
|
||||||
|
try:
|
||||||
|
result = flt.process_strip(colors)
|
||||||
|
if result is not None:
|
||||||
|
colors = result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await websocket.send_bytes(colors.tobytes())
|
||||||
|
await asyncio.sleep(frame_interval)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"CSPT test WS error: {e}")
|
||||||
|
finally:
|
||||||
|
csm.release(input_source_id, consumer_id)
|
||||||
|
logger.info(f"CSPT test WS disconnected: template={template_id}")
|
||||||
1002
server/src/wled_controller/api/routes/color_strip_sources.py
Normal file
1002
server/src/wled_controller/api/routes/color_strip_sources.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
"""Device routes: CRUD, health state, brightness, power, calibration."""
|
"""Device routes: CRUD, health state, brightness, power, calibration, WS stream."""
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.core.devices.led_client import (
|
from wled_controller.core.devices.led_client import (
|
||||||
@@ -10,14 +10,12 @@ from wled_controller.core.devices.led_client import (
|
|||||||
get_provider,
|
get_provider,
|
||||||
)
|
)
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_picture_target_store,
|
get_output_target_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.devices import (
|
from wled_controller.api.schemas.devices import (
|
||||||
Calibration as CalibrationSchema,
|
|
||||||
CalibrationTestModeRequest,
|
|
||||||
CalibrationTestModeResponse,
|
|
||||||
DeviceCreate,
|
DeviceCreate,
|
||||||
DeviceListResponse,
|
DeviceListResponse,
|
||||||
DeviceResponse,
|
DeviceResponse,
|
||||||
@@ -25,16 +23,14 @@ from wled_controller.api.schemas.devices import (
|
|||||||
DeviceUpdate,
|
DeviceUpdate,
|
||||||
DiscoveredDeviceResponse,
|
DiscoveredDeviceResponse,
|
||||||
DiscoverDevicesResponse,
|
DiscoverDevicesResponse,
|
||||||
StaticColorUpdate,
|
OpenRGBZoneResponse,
|
||||||
)
|
OpenRGBZonesResponse,
|
||||||
from wled_controller.core.capture.calibration import (
|
|
||||||
calibration_from_dict,
|
|
||||||
calibration_to_dict,
|
|
||||||
)
|
)
|
||||||
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
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -52,9 +48,23 @@ def _device_to_response(device) -> DeviceResponse:
|
|||||||
enabled=device.enabled,
|
enabled=device.enabled,
|
||||||
baud_rate=device.baud_rate,
|
baud_rate=device.baud_rate,
|
||||||
auto_shutdown=device.auto_shutdown,
|
auto_shutdown=device.auto_shutdown,
|
||||||
static_color=list(device.static_color) if device.static_color else None,
|
send_latency_ms=device.send_latency_ms,
|
||||||
|
rgbw=device.rgbw,
|
||||||
|
zone_mode=device.zone_mode,
|
||||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
tags=device.tags,
|
||||||
|
dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'),
|
||||||
|
dmx_start_universe=getattr(device, 'dmx_start_universe', 0),
|
||||||
|
dmx_start_channel=getattr(device, 'dmx_start_channel', 1),
|
||||||
|
espnow_peer_mac=getattr(device, 'espnow_peer_mac', ''),
|
||||||
|
espnow_channel=getattr(device, 'espnow_channel', 1),
|
||||||
|
hue_username=getattr(device, 'hue_username', ''),
|
||||||
|
hue_client_key=getattr(device, 'hue_client_key', ''),
|
||||||
|
hue_entertainment_group_id=getattr(device, 'hue_entertainment_group_id', ''),
|
||||||
|
spi_speed_hz=getattr(device, 'spi_speed_hz', 800000),
|
||||||
|
spi_led_type=getattr(device, 'spi_led_type', 'WS2812B'),
|
||||||
|
chroma_device_type=getattr(device, 'chroma_device_type', 'chromalink'),
|
||||||
|
gamesense_device_type=getattr(device, 'gamesense_device_type', 'keyboard'),
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
)
|
)
|
||||||
@@ -113,10 +123,10 @@ async def create_device(
|
|||||||
detail=f"Failed to connect to {device_type} device at {device_url}: {e}"
|
detail=f"Failed to connect to {device_type} device at {device_url}: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resolve auto_shutdown default: True for adalight, False otherwise
|
# Resolve auto_shutdown default: False for all types
|
||||||
auto_shutdown = device_data.auto_shutdown
|
auto_shutdown = device_data.auto_shutdown
|
||||||
if auto_shutdown is None:
|
if auto_shutdown is None:
|
||||||
auto_shutdown = device_type == "adalight"
|
auto_shutdown = False
|
||||||
|
|
||||||
# Create device in storage
|
# Create device in storage
|
||||||
device = store.create_device(
|
device = store.create_device(
|
||||||
@@ -126,19 +136,40 @@ async def create_device(
|
|||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
baud_rate=device_data.baud_rate,
|
baud_rate=device_data.baud_rate,
|
||||||
auto_shutdown=auto_shutdown,
|
auto_shutdown=auto_shutdown,
|
||||||
|
send_latency_ms=device_data.send_latency_ms or 0,
|
||||||
|
rgbw=device_data.rgbw or False,
|
||||||
|
zone_mode=device_data.zone_mode or "combined",
|
||||||
|
tags=device_data.tags,
|
||||||
|
dmx_protocol=device_data.dmx_protocol or "artnet",
|
||||||
|
dmx_start_universe=device_data.dmx_start_universe or 0,
|
||||||
|
dmx_start_channel=device_data.dmx_start_channel or 1,
|
||||||
|
espnow_peer_mac=device_data.espnow_peer_mac or "",
|
||||||
|
espnow_channel=device_data.espnow_channel or 1,
|
||||||
|
hue_username=device_data.hue_username or "",
|
||||||
|
hue_client_key=device_data.hue_client_key or "",
|
||||||
|
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
|
||||||
|
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
||||||
|
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||||
|
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||||
|
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# WS devices: auto-set URL to ws://{device_id}
|
||||||
|
if device_type == "ws":
|
||||||
|
device = store.update_device(device.id, url=f"ws://{device.id}")
|
||||||
|
|
||||||
# Register in processor manager for health monitoring
|
# Register in processor manager for health monitoring
|
||||||
manager.add_device(
|
manager.add_device(
|
||||||
device_id=device.id,
|
device_id=device.id,
|
||||||
device_url=device.url,
|
device_url=device.url,
|
||||||
led_count=device.led_count,
|
led_count=device.led_count,
|
||||||
calibration=device.calibration,
|
|
||||||
device_type=device.device_type,
|
device_type=device.device_type,
|
||||||
baud_rate=device.baud_rate,
|
baud_rate=device.baud_rate,
|
||||||
auto_shutdown=device.auto_shutdown,
|
auto_shutdown=device.auto_shutdown,
|
||||||
|
zone_mode=device.zone_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fire_entity_event("device", "created", device.id)
|
||||||
return _device_to_response(device)
|
return _device_to_response(device)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -213,6 +244,62 @@ async def discover_devices(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/devices/openrgb-zones", response_model=OpenRGBZonesResponse, tags=["Devices"])
|
||||||
|
async def get_openrgb_zones(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
url: str = Query(..., description="Base OpenRGB URL (e.g. openrgb://localhost:6742/0)"),
|
||||||
|
):
|
||||||
|
"""List available zones on an OpenRGB device."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from wled_controller.core.devices.openrgb_client import parse_openrgb_url
|
||||||
|
|
||||||
|
host, port, device_index, _zones = parse_openrgb_url(url)
|
||||||
|
|
||||||
|
def _fetch_zones():
|
||||||
|
from openrgb import OpenRGBClient
|
||||||
|
|
||||||
|
client = OpenRGBClient(host, port, name="WLED Controller (zones)")
|
||||||
|
try:
|
||||||
|
devices = client.devices
|
||||||
|
if device_index >= len(devices):
|
||||||
|
raise ValueError(
|
||||||
|
f"Device index {device_index} out of range "
|
||||||
|
f"(server has {len(devices)} device(s))"
|
||||||
|
)
|
||||||
|
device = devices[device_index]
|
||||||
|
zone_type_map = {0: "single", 1: "linear", 2: "matrix"}
|
||||||
|
zones = []
|
||||||
|
for z in device.zones:
|
||||||
|
zt = zone_type_map.get(getattr(z, "type", -1), "unknown")
|
||||||
|
zones.append(OpenRGBZoneResponse(
|
||||||
|
name=z.name,
|
||||||
|
led_count=len(z.leds),
|
||||||
|
zone_type=zt,
|
||||||
|
))
|
||||||
|
return device.name, zones
|
||||||
|
finally:
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
device_name, zones = await asyncio.to_thread(_fetch_zones)
|
||||||
|
return OpenRGBZonesResponse(device_name=device_name, zones=zones)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list OpenRGB zones: {e}")
|
||||||
|
raise HTTPException(status_code=502, detail=f"Cannot reach OpenRGB server: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/devices/batch/states", tags=["Devices"])
|
||||||
|
async def batch_device_states(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Get health/connection state for all devices in a single request."""
|
||||||
|
return {"states": manager.get_all_device_health_dicts()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
|
@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
|
||||||
async def get_device(
|
async def get_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -220,9 +307,10 @@ async def get_device(
|
|||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
):
|
):
|
||||||
"""Get device details by ID."""
|
"""Get device details by ID."""
|
||||||
device = store.get_device(device_id)
|
try:
|
||||||
if not device:
|
device = store.get_device(device_id)
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
return _device_to_response(device)
|
return _device_to_response(device)
|
||||||
|
|
||||||
|
|
||||||
@@ -244,6 +332,22 @@ async def update_device(
|
|||||||
led_count=update_data.led_count,
|
led_count=update_data.led_count,
|
||||||
baud_rate=update_data.baud_rate,
|
baud_rate=update_data.baud_rate,
|
||||||
auto_shutdown=update_data.auto_shutdown,
|
auto_shutdown=update_data.auto_shutdown,
|
||||||
|
send_latency_ms=update_data.send_latency_ms,
|
||||||
|
rgbw=update_data.rgbw,
|
||||||
|
zone_mode=update_data.zone_mode,
|
||||||
|
tags=update_data.tags,
|
||||||
|
dmx_protocol=update_data.dmx_protocol,
|
||||||
|
dmx_start_universe=update_data.dmx_start_universe,
|
||||||
|
dmx_start_channel=update_data.dmx_start_channel,
|
||||||
|
espnow_peer_mac=update_data.espnow_peer_mac,
|
||||||
|
espnow_channel=update_data.espnow_channel,
|
||||||
|
hue_username=update_data.hue_username,
|
||||||
|
hue_client_key=update_data.hue_client_key,
|
||||||
|
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
||||||
|
spi_speed_hz=update_data.spi_speed_hz,
|
||||||
|
spi_led_type=update_data.spi_led_type,
|
||||||
|
chroma_device_type=update_data.chroma_device_type,
|
||||||
|
gamesense_device_type=update_data.gamesense_device_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync connection info in processor manager
|
# Sync connection info in processor manager
|
||||||
@@ -257,10 +361,15 @@ async def update_device(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Sync auto_shutdown in runtime state
|
# Sync auto_shutdown and zone_mode in runtime state
|
||||||
if update_data.auto_shutdown is not None and device_id in manager._devices:
|
ds = manager.find_device_state(device_id)
|
||||||
manager._devices[device_id].auto_shutdown = update_data.auto_shutdown
|
if ds:
|
||||||
|
if update_data.auto_shutdown is not None:
|
||||||
|
ds.auto_shutdown = update_data.auto_shutdown
|
||||||
|
if update_data.zone_mode is not None:
|
||||||
|
ds.zone_mode = update_data.zone_mode
|
||||||
|
|
||||||
|
fire_entity_event("device", "updated", device_id)
|
||||||
return _device_to_response(device)
|
return _device_to_response(device)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -275,7 +384,7 @@ async def delete_device(
|
|||||||
device_id: str,
|
device_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Delete/detach a device. Returns 409 if referenced by a target."""
|
"""Delete/detach a device. Returns 409 if referenced by a target."""
|
||||||
@@ -298,6 +407,7 @@ async def delete_device(
|
|||||||
# Delete from storage
|
# Delete from storage
|
||||||
store.delete_device(device_id)
|
store.delete_device(device_id)
|
||||||
|
|
||||||
|
fire_entity_event("device", "deleted", device_id)
|
||||||
logger.info(f"Deleted device {device_id}")
|
logger.info(f"Deleted device {device_id}")
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -319,9 +429,10 @@ async def get_device_state(
|
|||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Get device health/connection state."""
|
"""Get device health/connection state."""
|
||||||
device = store.get_device(device_id)
|
try:
|
||||||
if not device:
|
device = store.get_device(device_id)
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
state = manager.get_device_health_dict(device_id)
|
state = manager.get_device_health_dict(device_id)
|
||||||
@@ -331,6 +442,27 @@ async def get_device_state(
|
|||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/devices/{device_id}/ping", response_model=DeviceStateResponse, tags=["Devices"])
|
||||||
|
async def ping_device(
|
||||||
|
device_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: DeviceStore = Depends(get_device_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Force an immediate health check on a device."""
|
||||||
|
try:
|
||||||
|
device = store.get_device(device_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = await manager.force_device_health_check(device_id)
|
||||||
|
state["device_type"] = device.device_type
|
||||||
|
return DeviceStateResponse(**state)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# ===== WLED BRIGHTNESS ENDPOINTS =====
|
# ===== WLED BRIGHTNESS ENDPOINTS =====
|
||||||
|
|
||||||
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
||||||
@@ -340,19 +472,34 @@ async def get_device_brightness(
|
|||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Get current brightness from the device."""
|
"""Get current brightness from the device.
|
||||||
device = store.get_device(device_id)
|
|
||||||
if not device:
|
Uses a server-side cache to avoid polling the physical device on every
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
frontend request — hitting the ESP32 over WiFi in the async event loop
|
||||||
|
causes ~150 ms jitter in the processing loop.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
device = store.get_device(device_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
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")
|
||||||
|
|
||||||
|
# Return cached hardware brightness if available (updated by SET endpoint)
|
||||||
|
ds = manager.find_device_state(device_id)
|
||||||
|
if ds and ds.hardware_brightness is not None:
|
||||||
|
return {"brightness": ds.hardware_brightness}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if device.device_type == "adalight":
|
|
||||||
return {"brightness": device.software_brightness}
|
|
||||||
provider = get_provider(device.device_type)
|
provider = get_provider(device.device_type)
|
||||||
bri = await provider.get_brightness(device.url)
|
bri = await provider.get_brightness(device.url)
|
||||||
|
# Cache the result so subsequent polls don't hit the device
|
||||||
|
if ds:
|
||||||
|
ds.hardware_brightness = bri
|
||||||
return {"brightness": bri}
|
return {"brightness": bri}
|
||||||
|
except NotImplementedError:
|
||||||
|
# Provider has no hardware brightness; use software brightness
|
||||||
|
return {"brightness": device.software_brightness}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get brightness for {device_id}: {e}")
|
logger.error(f"Failed to get brightness for {device_id}: {e}")
|
||||||
raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
|
raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
|
||||||
@@ -367,9 +514,10 @@ async def set_device_brightness(
|
|||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Set brightness on the device."""
|
"""Set brightness on the device."""
|
||||||
device = store.get_device(device_id)
|
try:
|
||||||
if not device:
|
device = store.get_device(device_id)
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
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")
|
||||||
|
|
||||||
@@ -378,16 +526,21 @@ async def set_device_brightness(
|
|||||||
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
|
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if device.device_type == "adalight":
|
try:
|
||||||
device.software_brightness = bri
|
provider = get_provider(device.device_type)
|
||||||
device.updated_at = __import__("datetime").datetime.utcnow()
|
await provider.set_brightness(device.url, bri)
|
||||||
store.save()
|
except NotImplementedError:
|
||||||
# Update runtime state so the processing loop picks it up
|
# Provider has no hardware brightness; use software brightness
|
||||||
if device_id in manager._devices:
|
store.update_device(device_id=device_id, software_brightness=bri)
|
||||||
manager._devices[device_id].software_brightness = bri
|
ds = manager.find_device_state(device_id)
|
||||||
return {"brightness": bri}
|
if ds:
|
||||||
provider = get_provider(device.device_type)
|
ds.software_brightness = bri
|
||||||
await provider.set_brightness(device.url, bri)
|
|
||||||
|
# Update cached hardware brightness
|
||||||
|
ds = manager.find_device_state(device_id)
|
||||||
|
if ds:
|
||||||
|
ds.hardware_brightness = bri
|
||||||
|
|
||||||
return {"brightness": bri}
|
return {"brightness": bri}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to set brightness for {device_id}: {e}")
|
logger.error(f"Failed to set brightness for {device_id}: {e}")
|
||||||
@@ -404,15 +557,16 @@ async def get_device_power(
|
|||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Get current power state from the device."""
|
"""Get current power state from the device."""
|
||||||
device = store.get_device(device_id)
|
try:
|
||||||
if not device:
|
device = store.get_device(device_id)
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
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")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Serial devices: use tracked state (no hardware query available)
|
# Serial devices: use tracked state (no hardware query available)
|
||||||
ds = manager._devices.get(device_id)
|
ds = manager.find_device_state(device_id)
|
||||||
if device.device_type in ("adalight", "ambiled") and ds:
|
if device.device_type in ("adalight", "ambiled") and ds:
|
||||||
return {"on": ds.power_on}
|
return {"on": ds.power_on}
|
||||||
|
|
||||||
@@ -433,9 +587,10 @@ async def set_device_power(
|
|||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Turn device on or off."""
|
"""Turn device on or off."""
|
||||||
device = store.get_device(device_id)
|
try:
|
||||||
if not device:
|
device = store.get_device(device_id)
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
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")
|
||||||
|
|
||||||
@@ -445,14 +600,10 @@ async def set_device_power(
|
|||||||
|
|
||||||
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
|
||||||
ds = manager._devices.get(device_id)
|
ds = manager.find_device_state(device_id)
|
||||||
if device.device_type in ("adalight", "ambiled") and ds:
|
if device.device_type in ("adalight", "ambiled") and ds:
|
||||||
if on:
|
if not on:
|
||||||
# Restore idle state (static color or stay dark)
|
await manager.send_clear_pixels(device_id)
|
||||||
if ds.static_color is not None:
|
|
||||||
await manager.send_static_color(device_id, ds.static_color)
|
|
||||||
else:
|
|
||||||
await manager._send_clear_pixels(device_id)
|
|
||||||
ds.power_on = on
|
ds.power_on = on
|
||||||
else:
|
else:
|
||||||
provider = get_provider(device.device_type)
|
provider = get_provider(device.device_type)
|
||||||
@@ -466,160 +617,47 @@ async def set_device_power(
|
|||||||
raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
|
raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ===== STATIC COLOR ENDPOINTS =====
|
# ===== WEBSOCKET DEVICE STREAM =====
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/devices/{device_id}/ws")
|
||||||
@router.get("/api/v1/devices/{device_id}/color", tags=["Settings"])
|
async def device_ws_stream(
|
||||||
async def get_device_color(
|
websocket: WebSocket,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
_auth: AuthRequired,
|
token: str = Query(""),
|
||||||
store: DeviceStore = Depends(get_device_store),
|
|
||||||
):
|
):
|
||||||
"""Get the static idle color for a device."""
|
"""WebSocket stream of LED pixel data for WS device type.
|
||||||
device = store.get_device(device_id)
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
|
||||||
if "static_color" not in get_device_capabilities(device.device_type):
|
|
||||||
raise HTTPException(status_code=400, detail="Static color is not supported for this device type")
|
|
||||||
return {"color": list(device.static_color) if device.static_color else None}
|
|
||||||
|
|
||||||
|
Wire format: [brightness_byte][R G B R G B ...]
|
||||||
|
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
|
||||||
|
|
||||||
@router.put("/api/v1/devices/{device_id}/color", tags=["Settings"])
|
store = get_device_store()
|
||||||
async def set_device_color(
|
|
||||||
device_id: str,
|
|
||||||
body: StaticColorUpdate,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: DeviceStore = Depends(get_device_store),
|
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
):
|
|
||||||
"""Set or clear the static idle color for a device."""
|
|
||||||
device = store.get_device(device_id)
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
|
||||||
if "static_color" not in get_device_capabilities(device.device_type):
|
|
||||||
raise HTTPException(status_code=400, detail="Static color is not supported for this device type")
|
|
||||||
|
|
||||||
color = None
|
|
||||||
if body.color is not None:
|
|
||||||
if len(body.color) != 3 or not all(isinstance(c, int) and 0 <= c <= 255 for c in body.color):
|
|
||||||
raise HTTPException(status_code=400, detail="color must be [R, G, B] with values 0-255")
|
|
||||||
color = tuple(body.color)
|
|
||||||
|
|
||||||
store.set_static_color(device_id, color)
|
|
||||||
|
|
||||||
# Update runtime state
|
|
||||||
ds = manager._devices.get(device_id)
|
|
||||||
if ds:
|
|
||||||
ds.static_color = color
|
|
||||||
|
|
||||||
# If device is idle, apply the color immediately
|
|
||||||
if color is not None and not manager.is_device_processing(device_id):
|
|
||||||
try:
|
|
||||||
await manager.send_static_color(device_id, color)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to apply static color immediately: {e}")
|
|
||||||
|
|
||||||
return {"color": list(color) if color else None}
|
|
||||||
|
|
||||||
|
|
||||||
# ===== CALIBRATION ENDPOINTS =====
|
|
||||||
|
|
||||||
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
|
||||||
async def get_calibration(
|
|
||||||
device_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: DeviceStore = Depends(get_device_store),
|
|
||||||
):
|
|
||||||
"""Get calibration configuration for a device."""
|
|
||||||
device = store.get_device(device_id)
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
|
||||||
|
|
||||||
return CalibrationSchema(**calibration_to_dict(device.calibration))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
|
||||||
async def update_calibration(
|
|
||||||
device_id: str,
|
|
||||||
calibration_data: CalibrationSchema,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: DeviceStore = Depends(get_device_store),
|
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
):
|
|
||||||
"""Update calibration configuration for a device."""
|
|
||||||
try:
|
|
||||||
# Convert schema to CalibrationConfig
|
|
||||||
calibration_dict = calibration_data.model_dump()
|
|
||||||
calibration = calibration_from_dict(calibration_dict)
|
|
||||||
|
|
||||||
# Update in storage
|
|
||||||
device = store.update_device(device_id, calibration=calibration)
|
|
||||||
|
|
||||||
# Update in manager (also updates active target's cached calibration)
|
|
||||||
try:
|
|
||||||
manager.update_calibration(device_id, calibration)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return CalibrationSchema(**calibration_to_dict(device.calibration))
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update calibration: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
|
||||||
"/api/v1/devices/{device_id}/calibration/test",
|
|
||||||
response_model=CalibrationTestModeResponse,
|
|
||||||
tags=["Calibration"],
|
|
||||||
)
|
|
||||||
async def set_calibration_test_mode(
|
|
||||||
device_id: str,
|
|
||||||
body: CalibrationTestModeRequest,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: DeviceStore = Depends(get_device_store),
|
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
):
|
|
||||||
"""Toggle calibration test mode for specific edges."""
|
|
||||||
try:
|
try:
|
||||||
device = store.get_device(device_id)
|
device = store.get_device(device_id)
|
||||||
if not device:
|
except ValueError:
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
await websocket.close(code=4004, reason="Device not found")
|
||||||
|
return
|
||||||
|
if device.device_type != "ws":
|
||||||
|
await websocket.close(code=4003, reason="Device is not a WebSocket device")
|
||||||
|
return
|
||||||
|
|
||||||
# Validate edge names and colors
|
await websocket.accept()
|
||||||
valid_edges = {"top", "right", "bottom", "left"}
|
|
||||||
for edge_name, color in body.edges.items():
|
|
||||||
if edge_name not in valid_edges:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(valid_edges)}"
|
|
||||||
)
|
|
||||||
if len(color) != 3 or not all(0 <= c <= 255 for c in color):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255."
|
|
||||||
)
|
|
||||||
|
|
||||||
await manager.set_test_mode(device_id, body.edges)
|
from wled_controller.core.devices.ws_client import get_ws_broadcaster
|
||||||
|
|
||||||
active_edges = list(body.edges.keys())
|
broadcaster = get_ws_broadcaster()
|
||||||
logger.info(
|
broadcaster.add_client(device_id, websocket)
|
||||||
f"Test mode {'activated' if active_edges else 'deactivated'} "
|
|
||||||
f"for device {device_id}: {active_edges}"
|
try:
|
||||||
)
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
broadcaster.remove_client(device_id, websocket)
|
||||||
|
|
||||||
return CalibrationTestModeResponse(
|
|
||||||
test_mode=len(active_edges) > 0,
|
|
||||||
active_edges=active_edges,
|
|
||||||
device_id=device_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to set test mode: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|||||||
1171
server/src/wled_controller/api/routes/output_targets.py
Normal file
1171
server/src/wled_controller/api/routes/output_targets.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,9 @@ from fastapi import APIRouter, HTTPException, Depends
|
|||||||
|
|
||||||
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,
|
||||||
get_pattern_template_store,
|
get_pattern_template_store,
|
||||||
get_picture_target_store,
|
get_output_target_store,
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.pattern_templates import (
|
from wled_controller.api.schemas.pattern_templates import (
|
||||||
PatternTemplateCreate,
|
PatternTemplateCreate,
|
||||||
@@ -13,11 +14,12 @@ from wled_controller.api.schemas.pattern_templates import (
|
|||||||
PatternTemplateResponse,
|
PatternTemplateResponse,
|
||||||
PatternTemplateUpdate,
|
PatternTemplateUpdate,
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.picture_targets import KeyColorRectangleSchema
|
from wled_controller.api.schemas.output_targets import KeyColorRectangleSchema
|
||||||
from wled_controller.storage.key_colors_picture_target import KeyColorRectangle
|
from wled_controller.storage.key_colors_output_target import KeyColorRectangle
|
||||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
|
|||||||
created_at=t.created_at,
|
created_at=t.created_at,
|
||||||
updated_at=t.updated_at,
|
updated_at=t.updated_at,
|
||||||
description=t.description,
|
description=t.description,
|
||||||
|
tags=t.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,8 +73,13 @@ async def create_pattern_template(
|
|||||||
name=data.name,
|
name=data.name,
|
||||||
rectangles=rectangles,
|
rectangles=rectangles,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pattern_template", "created", template.id)
|
||||||
return _pat_template_to_response(template)
|
return _pat_template_to_response(template)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -113,8 +121,13 @@ async def update_pattern_template(
|
|||||||
name=data.name,
|
name=data.name,
|
||||||
rectangles=rectangles,
|
rectangles=rectangles,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pattern_template", "updated", template_id)
|
||||||
return _pat_template_to_response(template)
|
return _pat_template_to_response(template)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -127,19 +140,25 @@ async def delete_pattern_template(
|
|||||||
template_id: str,
|
template_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: PatternTemplateStore = Depends(get_pattern_template_store),
|
store: PatternTemplateStore = Depends(get_pattern_template_store),
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
):
|
):
|
||||||
"""Delete a pattern template."""
|
"""Delete a pattern template."""
|
||||||
try:
|
try:
|
||||||
if store.is_referenced_by(template_id, target_store):
|
target_names = store.get_targets_referencing(template_id, target_store)
|
||||||
|
if target_names:
|
||||||
|
names = ", ".join(target_names)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail="Cannot delete pattern template: it is referenced by one or more key colors targets. "
|
detail=f"Cannot delete pattern template: it is referenced by target(s): {names}. "
|
||||||
"Please reassign those targets before deleting.",
|
"Please reassign those targets before deleting.",
|
||||||
)
|
)
|
||||||
store.delete_template(template_id)
|
store.delete_template(template_id)
|
||||||
|
fire_entity_event("pattern_template", "deleted", template_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Picture source routes."""
|
"""Picture source routes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
@@ -7,16 +8,15 @@ import time
|
|||||||
import httpx
|
import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
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_device_store,
|
fire_entity_event,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_picture_target_store,
|
get_output_target_store,
|
||||||
get_pp_template_store,
|
get_pp_template_store,
|
||||||
get_processor_manager,
|
|
||||||
get_template_store,
|
get_template_store,
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.common import (
|
from wled_controller.api.schemas.common import (
|
||||||
@@ -35,14 +35,13 @@ from wled_controller.api.schemas.picture_sources import (
|
|||||||
)
|
)
|
||||||
from wled_controller.core.capture_engines import EngineRegistry
|
from wled_controller.core.capture_engines import EngineRegistry
|
||||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||||
from wled_controller.storage import DeviceStore
|
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
|
||||||
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
|
||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
|
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -64,6 +63,15 @@ def _stream_to_response(s) -> PictureSourceResponse:
|
|||||||
created_at=s.created_at,
|
created_at=s.created_at,
|
||||||
updated_at=s.updated_at,
|
updated_at=s.updated_at,
|
||||||
description=s.description,
|
description=s.description,
|
||||||
|
tags=s.tags,
|
||||||
|
# Video fields
|
||||||
|
url=getattr(s, "url", None),
|
||||||
|
loop=getattr(s, "loop", None),
|
||||||
|
playback_speed=getattr(s, "playback_speed", None),
|
||||||
|
start_time=getattr(s, "start_time", None),
|
||||||
|
end_time=getattr(s, "end_time", None),
|
||||||
|
resolution_limit=getattr(s, "resolution_limit", None),
|
||||||
|
clock_id=getattr(s, "clock_id", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -99,23 +107,26 @@ async def validate_image(
|
|||||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||||
response = await client.get(source)
|
response = await client.get(source)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
pil_image = Image.open(io.BytesIO(response.content))
|
img_bytes = response.content
|
||||||
else:
|
else:
|
||||||
path = Path(source)
|
path = Path(source)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
|
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
|
||||||
pil_image = Image.open(path)
|
img_bytes = path
|
||||||
|
|
||||||
pil_image = pil_image.convert("RGB")
|
def _process_image(src):
|
||||||
width, height = pil_image.size
|
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||||
|
pil_image = pil_image.convert("RGB")
|
||||||
|
width, height = pil_image.size
|
||||||
|
thumb = pil_image.copy()
|
||||||
|
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
thumb.save(buf, format="JPEG", quality=80)
|
||||||
|
buf.seek(0)
|
||||||
|
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
|
||||||
|
return width, height, preview
|
||||||
|
|
||||||
# Create thumbnail preview (max 320px wide)
|
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
|
||||||
thumb = pil_image.copy()
|
|
||||||
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
thumb.save(buf, format="JPEG", quality=80)
|
|
||||||
buf.seek(0)
|
|
||||||
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
|
|
||||||
|
|
||||||
return ImageValidateResponse(
|
return ImageValidateResponse(
|
||||||
valid=True, width=width, height=height, preview=preview
|
valid=True, width=width, height=height, preview=preview
|
||||||
@@ -142,18 +153,22 @@ async def get_full_image(
|
|||||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||||
response = await client.get(source)
|
response = await client.get(source)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
pil_image = Image.open(io.BytesIO(response.content))
|
img_bytes = response.content
|
||||||
else:
|
else:
|
||||||
path = Path(source)
|
path = Path(source)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
pil_image = Image.open(path)
|
img_bytes = path
|
||||||
|
|
||||||
pil_image = pil_image.convert("RGB")
|
def _encode_full(src):
|
||||||
buf = io.BytesIO()
|
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||||
pil_image.save(buf, format="JPEG", quality=90)
|
pil_image = pil_image.convert("RGB")
|
||||||
buf.seek(0)
|
buf = io.BytesIO()
|
||||||
return Response(content=buf.getvalue(), media_type="image/jpeg")
|
pil_image.save(buf, format="JPEG", quality=90)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes)
|
||||||
|
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -200,10 +215,23 @@ async def create_picture_source(
|
|||||||
postprocessing_template_id=data.postprocessing_template_id,
|
postprocessing_template_id=data.postprocessing_template_id,
|
||||||
image_source=data.image_source,
|
image_source=data.image_source,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
# Video fields
|
||||||
|
url=data.url,
|
||||||
|
loop=data.loop,
|
||||||
|
playback_speed=data.playback_speed,
|
||||||
|
start_time=data.start_time,
|
||||||
|
end_time=data.end_time,
|
||||||
|
resolution_limit=data.resolution_limit,
|
||||||
|
clock_id=data.clock_id,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("picture_source", "created", stream.id)
|
||||||
return _stream_to_response(stream)
|
return _stream_to_response(stream)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -244,8 +272,21 @@ async def update_picture_source(
|
|||||||
postprocessing_template_id=data.postprocessing_template_id,
|
postprocessing_template_id=data.postprocessing_template_id,
|
||||||
image_source=data.image_source,
|
image_source=data.image_source,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
# Video fields
|
||||||
|
url=data.url,
|
||||||
|
loop=data.loop,
|
||||||
|
playback_speed=data.playback_speed,
|
||||||
|
start_time=data.start_time,
|
||||||
|
end_time=data.end_time,
|
||||||
|
resolution_limit=data.resolution_limit,
|
||||||
|
clock_id=data.clock_id,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("picture_source", "updated", stream_id)
|
||||||
return _stream_to_response(stream)
|
return _stream_to_response(stream)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -258,20 +299,26 @@ async def delete_picture_source(
|
|||||||
stream_id: str,
|
stream_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: PictureSourceStore = Depends(get_picture_source_store),
|
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
):
|
):
|
||||||
"""Delete a picture source."""
|
"""Delete a picture source."""
|
||||||
try:
|
try:
|
||||||
# Check if any target references this stream
|
# Check if any target references this stream
|
||||||
if store.is_referenced_by_target(stream_id, target_store):
|
target_names = store.get_targets_referencing(stream_id, target_store)
|
||||||
|
if target_names:
|
||||||
|
names = ", ".join(target_names)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail="Cannot delete picture source: it is assigned to one or more targets. "
|
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
|
||||||
"Please reassign those targets before deleting.",
|
"Please reassign those targets before deleting.",
|
||||||
)
|
)
|
||||||
store.delete_stream(stream_id)
|
store.delete_stream(stream_id)
|
||||||
|
fire_entity_event("picture_source", "deleted", stream_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -279,6 +326,52 @@ async def delete_picture_source(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"])
|
||||||
|
async def get_video_thumbnail(
|
||||||
|
stream_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||||
|
):
|
||||||
|
"""Get a thumbnail for a video picture source (first frame)."""
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from wled_controller.core.processing.video_stream import extract_thumbnail
|
||||||
|
from wled_controller.storage.picture_source import VideoCaptureSource
|
||||||
|
|
||||||
|
try:
|
||||||
|
source = store.get_stream(stream_id)
|
||||||
|
if not isinstance(source, VideoCaptureSource):
|
||||||
|
raise HTTPException(status_code=400, detail="Not a video source")
|
||||||
|
|
||||||
|
frame = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, extract_thumbnail, source.url, source.resolution_limit
|
||||||
|
)
|
||||||
|
if frame is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Could not extract thumbnail")
|
||||||
|
|
||||||
|
# Encode as JPEG
|
||||||
|
pil_img = Image.fromarray(frame)
|
||||||
|
# Resize to max 320px wide for thumbnail
|
||||||
|
if pil_img.width > 320:
|
||||||
|
ratio = 320 / pil_img.width
|
||||||
|
pil_img = pil_img.resize((320, int(pil_img.height * ratio)), Image.LANCZOS)
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
pil_img.save(buf, format="JPEG", quality=80)
|
||||||
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||||
|
|
||||||
|
return {"thumbnail": f"data:image/jpeg;base64,{b64}", "width": pil_img.width, "height": pil_img.height}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to extract video thumbnail: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
|
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
|
||||||
async def test_picture_source(
|
async def test_picture_source(
|
||||||
stream_id: str,
|
stream_id: str,
|
||||||
@@ -286,8 +379,6 @@ async def test_picture_source(
|
|||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: PictureSourceStore = Depends(get_picture_source_store),
|
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||||
template_store: TemplateStore = Depends(get_template_store),
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
processor_manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
device_store: DeviceStore = Depends(get_device_store),
|
|
||||||
pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store),
|
pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store),
|
||||||
):
|
):
|
||||||
"""Test a picture source by resolving its chain and running a capture test.
|
"""Test a picture source by resolving its chain and running a capture test.
|
||||||
@@ -302,6 +393,9 @@ async def test_picture_source(
|
|||||||
# Resolve stream chain
|
# Resolve stream chain
|
||||||
try:
|
try:
|
||||||
chain = store.resolve_stream_chain(stream_id)
|
chain = store.resolve_stream_chain(stream_id)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
@@ -323,7 +417,7 @@ async def test_picture_source(
|
|||||||
path = Path(source)
|
path = Path(source)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||||
pil_image = Image.open(path).convert("RGB")
|
pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB"))
|
||||||
|
|
||||||
actual_duration = time.perf_counter() - start_time
|
actual_duration = time.perf_counter() - start_time
|
||||||
frame_count = 1
|
frame_count = 1
|
||||||
@@ -347,19 +441,6 @@ async def test_picture_source(
|
|||||||
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
|
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(
|
stream = EngineRegistry.create_stream(
|
||||||
capture_template.engine_type, display_index, capture_template.engine_config
|
capture_template.engine_type, display_index, capture_template.engine_config
|
||||||
)
|
)
|
||||||
@@ -403,46 +484,50 @@ async def test_picture_source(
|
|||||||
else:
|
else:
|
||||||
raise ValueError("Unexpected image format from engine")
|
raise ValueError("Unexpected image format from engine")
|
||||||
|
|
||||||
# Create thumbnail
|
# Create thumbnail + encode (CPU-bound — run in thread)
|
||||||
thumbnail_width = 640
|
|
||||||
aspect_ratio = pil_image.height / pil_image.width
|
|
||||||
thumbnail_height = int(thumbnail_width * aspect_ratio)
|
|
||||||
thumbnail = pil_image.copy()
|
|
||||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Apply postprocessing filters if this is a processed stream
|
|
||||||
pp_template_ids = chain["postprocessing_template_ids"]
|
pp_template_ids = chain["postprocessing_template_ids"]
|
||||||
|
flat_filters = None
|
||||||
if pp_template_ids:
|
if pp_template_ids:
|
||||||
try:
|
try:
|
||||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||||
pool = ImagePool()
|
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||||
|
|
||||||
|
def _create_thumbnails_and_encode(pil_img, filters):
|
||||||
|
thumbnail_w = 640
|
||||||
|
aspect_ratio = pil_img.height / pil_img.width
|
||||||
|
thumbnail_h = int(thumbnail_w * aspect_ratio)
|
||||||
|
thumb = pil_img.copy()
|
||||||
|
thumb.thumbnail((thumbnail_w, thumbnail_h), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
pool = ImagePool()
|
||||||
def apply_filters(img):
|
def apply_filters(img):
|
||||||
arr = np.array(img)
|
arr = np.array(img)
|
||||||
for fi in pp_template.filters:
|
for fi in filters:
|
||||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
result = f.process_image(arr, pool)
|
result = f.process_image(arr, pool)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
arr = result
|
arr = result
|
||||||
return Image.fromarray(arr)
|
return Image.fromarray(arr)
|
||||||
|
thumb = apply_filters(thumb)
|
||||||
|
pil_img = apply_filters(pil_img)
|
||||||
|
|
||||||
thumbnail = apply_filters(thumbnail)
|
img_buffer = io.BytesIO()
|
||||||
pil_image = apply_filters(pil_image)
|
thumb.save(img_buffer, format='JPEG', quality=85)
|
||||||
except ValueError:
|
thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
|
||||||
|
|
||||||
# Encode thumbnail
|
full_buffer = io.BytesIO()
|
||||||
img_buffer = io.BytesIO()
|
pil_img.save(full_buffer, format='JPEG', quality=90)
|
||||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||||
img_buffer.seek(0)
|
|
||||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
return thumbnail_w, thumbnail_h, thumb_b64, full_b64
|
||||||
|
|
||||||
|
thumbnail_width, thumbnail_height, thumbnail_b64, full_b64 = await asyncio.to_thread(
|
||||||
|
_create_thumbnails_and_encode, pil_image, flat_filters
|
||||||
|
)
|
||||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||||
|
|
||||||
# Encode full-resolution image
|
|
||||||
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')
|
|
||||||
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||||
|
|
||||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||||
@@ -469,6 +554,9 @@ async def test_picture_source(
|
|||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
@@ -482,3 +570,180 @@ async def test_picture_source(
|
|||||||
stream.cleanup()
|
stream.cleanup()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cleaning up test stream: {e}")
|
logger.error(f"Error cleaning up test stream: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== REAL-TIME PICTURE SOURCE TEST WEBSOCKET =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/picture-sources/{stream_id}/test/ws")
|
||||||
|
async def test_picture_source_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
stream_id: str,
|
||||||
|
token: str = Query(""),
|
||||||
|
duration: float = Query(5.0),
|
||||||
|
preview_width: int = Query(0),
|
||||||
|
):
|
||||||
|
"""WebSocket for picture source test with intermediate frame previews."""
|
||||||
|
from wled_controller.api.routes._test_helpers import (
|
||||||
|
authenticate_ws_token,
|
||||||
|
stream_capture_test,
|
||||||
|
)
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
get_picture_source_store as _get_ps_store,
|
||||||
|
get_template_store as _get_t_store,
|
||||||
|
get_pp_template_store as _get_pp_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not authenticate_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
store = _get_ps_store()
|
||||||
|
template_store = _get_t_store()
|
||||||
|
pp_store = _get_pp_store()
|
||||||
|
|
||||||
|
# Resolve stream chain
|
||||||
|
try:
|
||||||
|
chain = store.resolve_stream_chain(stream_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_stream = chain["raw_stream"]
|
||||||
|
|
||||||
|
# Static images don't benefit from streaming — reject gracefully
|
||||||
|
if isinstance(raw_stream, StaticImagePictureSource):
|
||||||
|
await websocket.close(code=4003, reason="Static image streams don't support live test")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Video sources: use VideoCaptureLiveStream for test preview
|
||||||
|
if isinstance(raw_stream, VideoCaptureSource):
|
||||||
|
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
|
||||||
|
|
||||||
|
video_stream = VideoCaptureLiveStream(
|
||||||
|
url=raw_stream.url,
|
||||||
|
loop=raw_stream.loop,
|
||||||
|
playback_speed=raw_stream.playback_speed,
|
||||||
|
start_time=raw_stream.start_time,
|
||||||
|
end_time=raw_stream.end_time,
|
||||||
|
resolution_limit=raw_stream.resolution_limit,
|
||||||
|
target_fps=raw_stream.target_fps,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _encode_video_frame(image, pw):
|
||||||
|
"""Encode numpy RGB image as JPEG base64 data URI."""
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
pil = PILImage.fromarray(image)
|
||||||
|
if pw and pil.width > pw:
|
||||||
|
ratio = pw / pil.width
|
||||||
|
pil = pil.resize((pw, int(pil.height * ratio)), PILImage.LANCZOS)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
pil.save(buf, format="JPEG", quality=80)
|
||||||
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||||
|
return f"data:image/jpeg;base64,{b64}", pil.width, pil.height
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.get_event_loop().run_in_executor(None, video_stream.start)
|
||||||
|
import time as _time
|
||||||
|
fps = min(raw_stream.target_fps or 30, 30)
|
||||||
|
frame_time = 1.0 / fps
|
||||||
|
end_at = _time.monotonic() + duration
|
||||||
|
frame_count = 0
|
||||||
|
last_frame = None
|
||||||
|
while _time.monotonic() < end_at:
|
||||||
|
frame = video_stream.get_latest_frame()
|
||||||
|
if frame is not None and frame.image is not None and frame is not last_frame:
|
||||||
|
last_frame = frame
|
||||||
|
frame_count += 1
|
||||||
|
thumb, w, h = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, _encode_video_frame, frame.image, preview_width or None,
|
||||||
|
)
|
||||||
|
elapsed = duration - (end_at - _time.monotonic())
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "frame",
|
||||||
|
"thumbnail": thumb,
|
||||||
|
"width": w, "height": h,
|
||||||
|
"elapsed": round(elapsed, 1),
|
||||||
|
"frame_count": frame_count,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(frame_time)
|
||||||
|
# Send final result
|
||||||
|
if last_frame is not None:
|
||||||
|
full_img, fw, fh = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, _encode_video_frame, last_frame.image, None,
|
||||||
|
)
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "result",
|
||||||
|
"full_image": full_img,
|
||||||
|
"width": fw, "height": fh,
|
||||||
|
"total_frames": frame_count,
|
||||||
|
"duration": duration,
|
||||||
|
"avg_fps": round(frame_count / max(duration, 0.001), 1),
|
||||||
|
})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Video source test WS error for {stream_id}: {e}")
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "error", "detail": str(e)})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
video_stream.stop()
|
||||||
|
logger.info(f"Video source test WS disconnected for {stream_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(raw_stream, ScreenCapturePictureSource):
|
||||||
|
await websocket.close(code=4003, reason="Unsupported stream type for live test")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create capture engine
|
||||||
|
try:
|
||||||
|
capture_template = template_store.get_template(raw_stream.capture_template_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
if capture_template.engine_type not in EngineRegistry.get_available_engines():
|
||||||
|
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve postprocessing filters (if any)
|
||||||
|
pp_filters = None
|
||||||
|
pp_template_ids = chain.get("postprocessing_template_ids", [])
|
||||||
|
if pp_template_ids:
|
||||||
|
try:
|
||||||
|
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||||
|
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Engine factory — creates + initializes engine inside the capture thread
|
||||||
|
# to avoid thread-affinity issues (e.g. MSS uses thread-local state)
|
||||||
|
_engine_type = capture_template.engine_type
|
||||||
|
_display_index = raw_stream.display_index
|
||||||
|
_engine_config = capture_template.engine_config
|
||||||
|
|
||||||
|
def engine_factory():
|
||||||
|
s = EngineRegistry.create_stream(_engine_type, _display_index, _engine_config)
|
||||||
|
s.initialize()
|
||||||
|
return s
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"Picture source test WS connected for {stream_id} ({duration}s)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await stream_capture_test(
|
||||||
|
websocket, engine_factory, duration,
|
||||||
|
pp_filters=pp_filters,
|
||||||
|
preview_width=preview_width or None,
|
||||||
|
)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Picture source test WS error for {stream_id}: {e}")
|
||||||
|
finally:
|
||||||
|
logger.info(f"Picture source test WS disconnected for {stream_id}")
|
||||||
|
|||||||
@@ -1,873 +0,0 @@
|
|||||||
"""Picture target routes: CRUD, processing control, settings, state, metrics."""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import io
|
|
||||||
import secrets
|
|
||||||
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_pattern_template_store,
|
|
||||||
get_picture_source_store,
|
|
||||||
get_picture_target_store,
|
|
||||||
get_pp_template_store,
|
|
||||||
get_processor_manager,
|
|
||||||
get_template_store,
|
|
||||||
)
|
|
||||||
from wled_controller.api.schemas.picture_targets import (
|
|
||||||
ExtractedColorResponse,
|
|
||||||
KCTestRectangleResponse,
|
|
||||||
KCTestResponse,
|
|
||||||
KeyColorsResponse,
|
|
||||||
KeyColorsSettingsSchema,
|
|
||||||
PictureTargetCreate,
|
|
||||||
PictureTargetListResponse,
|
|
||||||
PictureTargetResponse,
|
|
||||||
PictureTargetUpdate,
|
|
||||||
ProcessingSettings as ProcessingSettingsSchema,
|
|
||||||
TargetMetricsResponse,
|
|
||||||
TargetProcessingState,
|
|
||||||
)
|
|
||||||
from wled_controller.config import get_config
|
|
||||||
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.processing_settings import ProcessingSettings
|
|
||||||
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.wled_picture_target import WledPictureTarget
|
|
||||||
from wled_controller.storage.key_colors_picture_target import (
|
|
||||||
KeyColorsSettings,
|
|
||||||
KeyColorsPictureTarget,
|
|
||||||
)
|
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
|
||||||
from wled_controller.utils import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings:
|
|
||||||
"""Convert schema ProcessingSettings to core ProcessingSettings."""
|
|
||||||
settings = ProcessingSettings(
|
|
||||||
display_index=schema.display_index,
|
|
||||||
fps=schema.fps,
|
|
||||||
interpolation_mode=schema.interpolation_mode,
|
|
||||||
brightness=schema.brightness,
|
|
||||||
smoothing=schema.smoothing,
|
|
||||||
standby_interval=schema.standby_interval,
|
|
||||||
state_check_interval=schema.state_check_interval,
|
|
||||||
)
|
|
||||||
if schema.color_correction:
|
|
||||||
settings.gamma = schema.color_correction.gamma
|
|
||||||
settings.saturation = schema.color_correction.saturation
|
|
||||||
# color_correction.brightness maps to settings.brightness
|
|
||||||
settings.brightness = schema.color_correction.brightness
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchema:
|
|
||||||
"""Convert core ProcessingSettings to schema ProcessingSettings."""
|
|
||||||
from wled_controller.api.schemas.picture_targets import ColorCorrection
|
|
||||||
return ProcessingSettingsSchema(
|
|
||||||
display_index=settings.display_index,
|
|
||||||
fps=settings.fps,
|
|
||||||
interpolation_mode=settings.interpolation_mode,
|
|
||||||
brightness=settings.brightness,
|
|
||||||
smoothing=settings.smoothing,
|
|
||||||
standby_interval=settings.standby_interval,
|
|
||||||
state_check_interval=settings.state_check_interval,
|
|
||||||
color_correction=ColorCorrection(
|
|
||||||
gamma=settings.gamma,
|
|
||||||
saturation=settings.saturation,
|
|
||||||
brightness=settings.brightness,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema:
|
|
||||||
"""Convert core KeyColorsSettings to schema."""
|
|
||||||
return KeyColorsSettingsSchema(
|
|
||||||
fps=settings.fps,
|
|
||||||
interpolation_mode=settings.interpolation_mode,
|
|
||||||
smoothing=settings.smoothing,
|
|
||||||
pattern_template_id=settings.pattern_template_id,
|
|
||||||
brightness=settings.brightness,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings:
|
|
||||||
"""Convert schema KeyColorsSettings to core."""
|
|
||||||
return KeyColorsSettings(
|
|
||||||
fps=schema.fps,
|
|
||||||
interpolation_mode=schema.interpolation_mode,
|
|
||||||
smoothing=schema.smoothing,
|
|
||||||
pattern_template_id=schema.pattern_template_id,
|
|
||||||
brightness=schema.brightness,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _target_to_response(target) -> PictureTargetResponse:
|
|
||||||
"""Convert a PictureTarget to PictureTargetResponse."""
|
|
||||||
if isinstance(target, WledPictureTarget):
|
|
||||||
return PictureTargetResponse(
|
|
||||||
id=target.id,
|
|
||||||
name=target.name,
|
|
||||||
target_type=target.target_type,
|
|
||||||
device_id=target.device_id,
|
|
||||||
picture_source_id=target.picture_source_id,
|
|
||||||
settings=_settings_to_schema(target.settings),
|
|
||||||
description=target.description,
|
|
||||||
created_at=target.created_at,
|
|
||||||
updated_at=target.updated_at,
|
|
||||||
)
|
|
||||||
elif isinstance(target, KeyColorsPictureTarget):
|
|
||||||
return PictureTargetResponse(
|
|
||||||
id=target.id,
|
|
||||||
name=target.name,
|
|
||||||
target_type=target.target_type,
|
|
||||||
picture_source_id=target.picture_source_id,
|
|
||||||
key_colors_settings=_kc_settings_to_schema(target.settings),
|
|
||||||
description=target.description,
|
|
||||||
created_at=target.created_at,
|
|
||||||
updated_at=target.updated_at,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return PictureTargetResponse(
|
|
||||||
id=target.id,
|
|
||||||
name=target.name,
|
|
||||||
target_type=target.target_type,
|
|
||||||
description=target.description,
|
|
||||||
created_at=target.created_at,
|
|
||||||
updated_at=target.updated_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ===== CRUD ENDPOINTS =====
|
|
||||||
|
|
||||||
@router.post("/api/v1/picture-targets", response_model=PictureTargetResponse, tags=["Targets"], status_code=201)
|
|
||||||
async def create_target(
|
|
||||||
data: PictureTargetCreate,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
|
||||||
device_store: DeviceStore = Depends(get_device_store),
|
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
):
|
|
||||||
"""Create a new picture target."""
|
|
||||||
try:
|
|
||||||
# Validate device exists if provided
|
|
||||||
if data.device_id:
|
|
||||||
device = device_store.get_device(data.device_id)
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
|
||||||
|
|
||||||
# Convert settings
|
|
||||||
core_settings = _settings_to_core(data.settings) if data.settings else None
|
|
||||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
|
||||||
|
|
||||||
# Create in store
|
|
||||||
target = target_store.create_target(
|
|
||||||
name=data.name,
|
|
||||||
target_type=data.target_type,
|
|
||||||
device_id=data.device_id,
|
|
||||||
picture_source_id=data.picture_source_id,
|
|
||||||
settings=core_settings,
|
|
||||||
key_colors_settings=kc_settings,
|
|
||||||
description=data.description,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register in processor manager
|
|
||||||
if isinstance(target, WledPictureTarget) and target.device_id:
|
|
||||||
try:
|
|
||||||
manager.add_target(
|
|
||||||
target_id=target.id,
|
|
||||||
device_id=target.device_id,
|
|
||||||
settings=target.settings,
|
|
||||||
picture_source_id=target.picture_source_id,
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
logger.warning(f"Could not register target {target.id} in processor manager: {e}")
|
|
||||||
elif isinstance(target, KeyColorsPictureTarget):
|
|
||||||
try:
|
|
||||||
manager.add_kc_target(
|
|
||||||
target_id=target.id,
|
|
||||||
picture_source_id=target.picture_source_id,
|
|
||||||
settings=target.settings,
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
logger.warning(f"Could not register KC target {target.id}: {e}")
|
|
||||||
|
|
||||||
return _target_to_response(target)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create target: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/picture-targets", response_model=PictureTargetListResponse, tags=["Targets"])
|
|
||||||
async def list_targets(
|
|
||||||
_auth: AuthRequired,
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
|
||||||
):
|
|
||||||
"""List all picture targets."""
|
|
||||||
targets = target_store.get_all_targets()
|
|
||||||
responses = [_target_to_response(t) for t in targets]
|
|
||||||
return PictureTargetListResponse(targets=responses, count=len(responses))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/picture-targets/{target_id}", response_model=PictureTargetResponse, tags=["Targets"])
|
|
||||||
async def get_target(
|
|
||||||
target_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
|
||||||
):
|
|
||||||
"""Get a picture target by ID."""
|
|
||||||
try:
|
|
||||||
target = target_store.get_target(target_id)
|
|
||||||
return _target_to_response(target)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/v1/picture-targets/{target_id}", response_model=PictureTargetResponse, tags=["Targets"])
|
|
||||||
async def update_target(
|
|
||||||
target_id: str,
|
|
||||||
data: PictureTargetUpdate,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
|
||||||
device_store: DeviceStore = Depends(get_device_store),
|
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
):
|
|
||||||
"""Update a picture target."""
|
|
||||||
try:
|
|
||||||
# Validate device exists if changing
|
|
||||||
if data.device_id is not None and data.device_id:
|
|
||||||
device = device_store.get_device(data.device_id)
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
|
||||||
|
|
||||||
# Convert settings
|
|
||||||
core_settings = _settings_to_core(data.settings) if data.settings else None
|
|
||||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
|
||||||
|
|
||||||
# Update in store
|
|
||||||
target = target_store.update_target(
|
|
||||||
target_id=target_id,
|
|
||||||
name=data.name,
|
|
||||||
device_id=data.device_id,
|
|
||||||
picture_source_id=data.picture_source_id,
|
|
||||||
settings=core_settings,
|
|
||||||
key_colors_settings=kc_settings,
|
|
||||||
description=data.description,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sync processor manager (unified API handles both target types)
|
|
||||||
try:
|
|
||||||
if data.settings is not None or data.key_colors_settings is not None:
|
|
||||||
manager.update_target_settings(target_id, target.settings)
|
|
||||||
if data.picture_source_id is not None:
|
|
||||||
manager.update_target_source(target_id, target.picture_source_id)
|
|
||||||
if data.device_id is not None and isinstance(target, WledPictureTarget):
|
|
||||||
manager.update_target_device(target_id, target.device_id)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return _target_to_response(target)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update target: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/picture-targets/{target_id}", status_code=204, tags=["Targets"])
|
|
||||||
async def delete_target(
|
|
||||||
target_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
):
|
|
||||||
"""Delete a picture target. Stops processing first if active."""
|
|
||||||
try:
|
|
||||||
# Stop processing if running
|
|
||||||
try:
|
|
||||||
await manager.stop_processing(target_id)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Remove from manager
|
|
||||||
try:
|
|
||||||
manager.remove_target(target_id)
|
|
||||||
except (ValueError, RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Delete from store
|
|
||||||
target_store.delete_target(target_id)
|
|
||||||
|
|
||||||
logger.info(f"Deleted target {target_id}")
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete target: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
# ===== PROCESSING CONTROL ENDPOINTS =====
|
|
||||||
|
|
||||||
@router.post("/api/v1/picture-targets/{target_id}/start", tags=["Processing"])
|
|
||||||
async def start_processing(
|
|
||||||
target_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
):
|
|
||||||
"""Start processing for a picture 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:
|
|
||||||
raise HTTPException(status_code=409, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start processing: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/picture-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 picture 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/picture-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/picture-targets/{target_id}/settings", tags=["Settings"])
|
|
||||||
async def get_target_settings(
|
|
||||||
target_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
|
||||||
):
|
|
||||||
"""Get processing settings for a target."""
|
|
||||||
try:
|
|
||||||
target = target_store.get_target(target_id)
|
|
||||||
if isinstance(target, KeyColorsPictureTarget):
|
|
||||||
return _kc_settings_to_schema(target.settings)
|
|
||||||
if isinstance(target, WledPictureTarget):
|
|
||||||
return _settings_to_schema(target.settings)
|
|
||||||
return ProcessingSettingsSchema()
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/v1/picture-targets/{target_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
|
|
||||||
async def update_target_settings(
|
|
||||||
target_id: str,
|
|
||||||
settings: ProcessingSettingsSchema,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
):
|
|
||||||
"""Update processing settings for a target.
|
|
||||||
|
|
||||||
Merges with existing settings so callers can send partial updates.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
target = target_store.get_target(target_id)
|
|
||||||
if not isinstance(target, WledPictureTarget):
|
|
||||||
raise HTTPException(status_code=400, detail="Target does not support processing settings")
|
|
||||||
|
|
||||||
existing = target.settings
|
|
||||||
sent = settings.model_fields_set
|
|
||||||
|
|
||||||
# Merge: only override fields the client explicitly provided
|
|
||||||
new_settings = ProcessingSettings(
|
|
||||||
display_index=settings.display_index if 'display_index' in sent else existing.display_index,
|
|
||||||
fps=settings.fps if 'fps' in sent else existing.fps,
|
|
||||||
interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
|
|
||||||
brightness=settings.brightness if 'brightness' in sent else existing.brightness,
|
|
||||||
gamma=existing.gamma,
|
|
||||||
saturation=existing.saturation,
|
|
||||||
smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing,
|
|
||||||
standby_interval=settings.standby_interval if 'standby_interval' in sent else existing.standby_interval,
|
|
||||||
state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply color_correction fields if explicitly sent
|
|
||||||
if 'color_correction' in sent and settings.color_correction:
|
|
||||||
cc_sent = settings.color_correction.model_fields_set
|
|
||||||
if 'brightness' in cc_sent:
|
|
||||||
new_settings.brightness = settings.color_correction.brightness
|
|
||||||
if 'gamma' in cc_sent:
|
|
||||||
new_settings.gamma = settings.color_correction.gamma
|
|
||||||
if 'saturation' in cc_sent:
|
|
||||||
new_settings.saturation = settings.color_correction.saturation
|
|
||||||
|
|
||||||
# Update in store
|
|
||||||
target_store.update_target(target_id, settings=new_settings)
|
|
||||||
|
|
||||||
# Update in manager
|
|
||||||
try:
|
|
||||||
manager.update_target_settings(target_id, new_settings)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return _settings_to_schema(new_settings)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update target settings: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/picture-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/picture-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
|
|
||||||
return KeyColorsResponse(
|
|
||||||
target_id=target_id,
|
|
||||||
colors=colors,
|
|
||||||
timestamp=datetime.utcnow(),
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/picture-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"])
|
|
||||||
async def test_kc_target(
|
|
||||||
target_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_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, KeyColorsPictureTarget):
|
|
||||||
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 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
|
|
||||||
for fi in pp_template.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 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/picture-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>."""
|
|
||||||
# Authenticate
|
|
||||||
authenticated = False
|
|
||||||
cfg = get_config()
|
|
||||||
if token and cfg.auth.api_keys:
|
|
||||||
for _label, api_key in cfg.auth.api_keys.items():
|
|
||||||
if secrets.compare_digest(token, api_key):
|
|
||||||
authenticated = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not authenticated:
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ===== 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>."""
|
|
||||||
authenticated = False
|
|
||||||
cfg = get_config()
|
|
||||||
if token and cfg.auth.api_keys:
|
|
||||||
for _label, api_key in cfg.auth.api_keys.items():
|
|
||||||
if secrets.compare_digest(token, api_key):
|
|
||||||
authenticated = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not authenticated:
|
|
||||||
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/picture-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: PictureTargetStore = Depends(get_picture_target_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")
|
|
||||||
|
|
||||||
await manager.start_overlay(target_id, target.name)
|
|
||||||
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/picture-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/picture-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))
|
|
||||||
@@ -7,14 +7,13 @@ import time
|
|||||||
import httpx
|
import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
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_device_store,
|
fire_entity_event,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_pp_template_store,
|
get_pp_template_store,
|
||||||
get_processor_manager,
|
|
||||||
get_template_store,
|
get_template_store,
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.common import (
|
from wled_controller.api.schemas.common import (
|
||||||
@@ -32,13 +31,12 @@ from wled_controller.api.schemas.postprocessing import (
|
|||||||
)
|
)
|
||||||
from wled_controller.core.capture_engines import EngineRegistry
|
from wled_controller.core.capture_engines import EngineRegistry
|
||||||
from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool
|
from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
|
||||||
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
|
||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
|
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -54,6 +52,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
|||||||
created_at=t.created_at,
|
created_at=t.created_at,
|
||||||
updated_at=t.updated_at,
|
updated_at=t.updated_at,
|
||||||
description=t.description,
|
description=t.description,
|
||||||
|
tags=t.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -63,13 +62,9 @@ async def list_pp_templates(
|
|||||||
store: PostprocessingTemplateStore = Depends(get_pp_template_store),
|
store: PostprocessingTemplateStore = Depends(get_pp_template_store),
|
||||||
):
|
):
|
||||||
"""List all postprocessing templates."""
|
"""List all postprocessing templates."""
|
||||||
try:
|
templates = store.get_all_templates()
|
||||||
templates = store.get_all_templates()
|
responses = [_pp_template_to_response(t) for t in templates]
|
||||||
responses = [_pp_template_to_response(t) for t in templates]
|
return PostprocessingTemplateListResponse(templates=responses, count=len(responses))
|
||||||
return PostprocessingTemplateListResponse(templates=responses, count=len(responses))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list postprocessing templates: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201)
|
@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201)
|
||||||
@@ -85,8 +80,13 @@ async def create_pp_template(
|
|||||||
name=data.name,
|
name=data.name,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pp_template", "created", template.id)
|
||||||
return _pp_template_to_response(template)
|
return _pp_template_to_response(template)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -123,8 +123,13 @@ async def update_pp_template(
|
|||||||
name=data.name,
|
name=data.name,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pp_template", "updated", template_id)
|
||||||
return _pp_template_to_response(template)
|
return _pp_template_to_response(template)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -142,15 +147,21 @@ async def delete_pp_template(
|
|||||||
"""Delete a postprocessing template."""
|
"""Delete a postprocessing template."""
|
||||||
try:
|
try:
|
||||||
# Check if any picture source references this template
|
# Check if any picture source references this template
|
||||||
if store.is_referenced_by(template_id, stream_store):
|
source_names = store.get_sources_referencing(template_id, stream_store)
|
||||||
|
if source_names:
|
||||||
|
names = ", ".join(source_names)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail="Cannot delete postprocessing template: it is referenced by one or more picture sources. "
|
detail=f"Cannot delete postprocessing template: it is referenced by picture source(s): {names}. "
|
||||||
"Please reassign those streams before deleting.",
|
"Please reassign those streams before deleting.",
|
||||||
)
|
)
|
||||||
store.delete_template(template_id)
|
store.delete_template(template_id)
|
||||||
|
fire_entity_event("pp_template", "deleted", template_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -166,8 +177,6 @@ async def test_pp_template(
|
|||||||
pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store),
|
pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store),
|
||||||
stream_store: PictureSourceStore = Depends(get_picture_source_store),
|
stream_store: PictureSourceStore = Depends(get_picture_source_store),
|
||||||
template_store: TemplateStore = Depends(get_template_store),
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
processor_manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
device_store: DeviceStore = Depends(get_device_store),
|
|
||||||
):
|
):
|
||||||
"""Test a postprocessing template by capturing from a source stream and applying filters."""
|
"""Test a postprocessing template by capturing from a source stream and applying filters."""
|
||||||
stream = None
|
stream = None
|
||||||
@@ -181,6 +190,9 @@ async def test_pp_template(
|
|||||||
# Resolve source stream chain to get the raw stream
|
# Resolve source stream chain to get the raw stream
|
||||||
try:
|
try:
|
||||||
chain = stream_store.resolve_stream_chain(test_request.source_stream_id)
|
chain = stream_store.resolve_stream_chain(test_request.source_stream_id)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
@@ -225,19 +237,6 @@ async def test_pp_template(
|
|||||||
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
|
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(
|
stream = EngineRegistry.create_stream(
|
||||||
capture_template.engine_type, display_index, capture_template.engine_config
|
capture_template.engine_type, display_index, capture_template.engine_config
|
||||||
)
|
)
|
||||||
@@ -281,13 +280,14 @@ async def test_pp_template(
|
|||||||
thumbnail = pil_image.copy()
|
thumbnail = pil_image.copy()
|
||||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
# Apply postprocessing filters
|
# Apply postprocessing filters (expand filter_template references)
|
||||||
if pp_template.filters:
|
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
|
||||||
|
if flat_filters:
|
||||||
pool = ImagePool()
|
pool = ImagePool()
|
||||||
|
|
||||||
def apply_filters(img):
|
def apply_filters(img):
|
||||||
arr = np.array(img)
|
arr = np.array(img)
|
||||||
for fi in pp_template.filters:
|
for fi in flat_filters:
|
||||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
result = f.process_image(arr, pool)
|
result = f.process_image(arr, pool)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@@ -314,6 +314,7 @@ async def test_pp_template(
|
|||||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||||
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
|
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
|
||||||
width, height = pil_image.size
|
width, height = pil_image.size
|
||||||
|
thumb_w, thumb_h = thumbnail.size
|
||||||
|
|
||||||
return TemplateTestResponse(
|
return TemplateTestResponse(
|
||||||
full_capture=CaptureImage(
|
full_capture=CaptureImage(
|
||||||
@@ -321,8 +322,8 @@ async def test_pp_template(
|
|||||||
full_image=full_data_uri,
|
full_image=full_data_uri,
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
thumbnail_width=thumbnail_width,
|
thumbnail_width=thumb_w,
|
||||||
thumbnail_height=thumbnail_height,
|
thumbnail_height=thumb_h,
|
||||||
),
|
),
|
||||||
border_extraction=None,
|
border_extraction=None,
|
||||||
performance=PerformanceMetrics(
|
performance=PerformanceMetrics(
|
||||||
@@ -335,6 +336,9 @@ async def test_pp_template(
|
|||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -346,3 +350,104 @@ async def test_pp_template(
|
|||||||
stream.cleanup()
|
stream.cleanup()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ===== REAL-TIME PP TEMPLATE TEST WEBSOCKET =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/postprocessing-templates/{template_id}/test/ws")
|
||||||
|
async def test_pp_template_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
template_id: str,
|
||||||
|
token: str = Query(""),
|
||||||
|
duration: float = Query(5.0),
|
||||||
|
source_stream_id: str = Query(""),
|
||||||
|
preview_width: int = Query(0),
|
||||||
|
):
|
||||||
|
"""WebSocket for PP template test with intermediate frame previews."""
|
||||||
|
from wled_controller.api.routes._test_helpers import (
|
||||||
|
authenticate_ws_token,
|
||||||
|
stream_capture_test,
|
||||||
|
)
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
get_picture_source_store as _get_ps_store,
|
||||||
|
get_template_store as _get_t_store,
|
||||||
|
get_pp_template_store as _get_pp_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not authenticate_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not source_stream_id:
|
||||||
|
await websocket.close(code=4003, reason="source_stream_id is required")
|
||||||
|
return
|
||||||
|
|
||||||
|
pp_store = _get_pp_store()
|
||||||
|
stream_store = _get_ps_store()
|
||||||
|
template_store = _get_t_store()
|
||||||
|
|
||||||
|
# Get PP template
|
||||||
|
try:
|
||||||
|
pp_template = pp_store.get_template(template_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve source stream chain
|
||||||
|
try:
|
||||||
|
chain = stream_store.resolve_stream_chain(source_stream_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_stream = chain["raw_stream"]
|
||||||
|
|
||||||
|
if isinstance(raw_stream, StaticImagePictureSource):
|
||||||
|
await websocket.close(code=4003, reason="Static image streams don't support live test")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(raw_stream, ScreenCapturePictureSource):
|
||||||
|
await websocket.close(code=4003, reason="Unsupported stream type for live test")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create capture engine
|
||||||
|
try:
|
||||||
|
capture_template = template_store.get_template(raw_stream.capture_template_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
if capture_template.engine_type not in EngineRegistry.get_available_engines():
|
||||||
|
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve PP filters
|
||||||
|
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||||
|
|
||||||
|
# Engine factory — creates + initializes engine inside the capture thread
|
||||||
|
# to avoid thread-affinity issues (e.g. MSS uses thread-local state)
|
||||||
|
_engine_type = capture_template.engine_type
|
||||||
|
_display_index = raw_stream.display_index
|
||||||
|
_engine_config = capture_template.engine_config
|
||||||
|
|
||||||
|
def engine_factory():
|
||||||
|
s = EngineRegistry.create_stream(_engine_type, _display_index, _engine_config)
|
||||||
|
s.initialize()
|
||||||
|
return s
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"PP template test WS connected for {template_id} ({duration}s)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await stream_capture_test(
|
||||||
|
websocket, engine_factory, duration,
|
||||||
|
pp_filters=pp_filters,
|
||||||
|
preview_width=preview_width or None,
|
||||||
|
)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PP template test WS error for {template_id}: {e}")
|
||||||
|
finally:
|
||||||
|
logger.info(f"PP template test WS disconnected for {template_id}")
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
"""Profile management API routes."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
|
||||||
from wled_controller.api.dependencies import (
|
|
||||||
get_picture_target_store,
|
|
||||||
get_profile_engine,
|
|
||||||
get_profile_store,
|
|
||||||
)
|
|
||||||
from wled_controller.api.schemas.profiles import (
|
|
||||||
ConditionSchema,
|
|
||||||
ProfileCreate,
|
|
||||||
ProfileListResponse,
|
|
||||||
ProfileResponse,
|
|
||||||
ProfileUpdate,
|
|
||||||
)
|
|
||||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
|
||||||
from wled_controller.storage.profile import ApplicationCondition, Condition
|
|
||||||
from wled_controller.storage.profile_store import ProfileStore
|
|
||||||
from wled_controller.utils import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Helpers =====
|
|
||||||
|
|
||||||
def _condition_from_schema(s: ConditionSchema) -> Condition:
|
|
||||||
if s.condition_type == "application":
|
|
||||||
return ApplicationCondition(
|
|
||||||
apps=s.apps or [],
|
|
||||||
match_type=s.match_type or "running",
|
|
||||||
)
|
|
||||||
raise ValueError(f"Unknown condition type: {s.condition_type}")
|
|
||||||
|
|
||||||
|
|
||||||
def _condition_to_schema(c: Condition) -> ConditionSchema:
|
|
||||||
d = c.to_dict()
|
|
||||||
return ConditionSchema(**d)
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_to_response(profile, engine: ProfileEngine) -> ProfileResponse:
|
|
||||||
state = engine.get_profile_state(profile.id)
|
|
||||||
return ProfileResponse(
|
|
||||||
id=profile.id,
|
|
||||||
name=profile.name,
|
|
||||||
enabled=profile.enabled,
|
|
||||||
condition_logic=profile.condition_logic,
|
|
||||||
conditions=[_condition_to_schema(c) for c in profile.conditions],
|
|
||||||
target_ids=profile.target_ids,
|
|
||||||
is_active=state["is_active"],
|
|
||||||
active_target_ids=state["active_target_ids"],
|
|
||||||
last_activated_at=state.get("last_activated_at"),
|
|
||||||
last_deactivated_at=state.get("last_deactivated_at"),
|
|
||||||
created_at=profile.created_at,
|
|
||||||
updated_at=profile.updated_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_condition_logic(logic: str) -> None:
|
|
||||||
if logic not in ("or", "and"):
|
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_target_ids(target_ids: list, target_store: PictureTargetStore) -> None:
|
|
||||||
for tid in target_ids:
|
|
||||||
try:
|
|
||||||
target_store.get_target(tid)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Target not found: {tid}")
|
|
||||||
|
|
||||||
|
|
||||||
# ===== CRUD Endpoints =====
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/api/v1/profiles",
|
|
||||||
response_model=ProfileResponse,
|
|
||||||
tags=["Profiles"],
|
|
||||||
status_code=201,
|
|
||||||
)
|
|
||||||
async def create_profile(
|
|
||||||
data: ProfileCreate,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: ProfileStore = Depends(get_profile_store),
|
|
||||||
engine: ProfileEngine = Depends(get_profile_engine),
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
|
||||||
):
|
|
||||||
"""Create a new profile."""
|
|
||||||
_validate_condition_logic(data.condition_logic)
|
|
||||||
_validate_target_ids(data.target_ids, target_store)
|
|
||||||
|
|
||||||
try:
|
|
||||||
conditions = [_condition_from_schema(c) for c in data.conditions]
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
profile = store.create_profile(
|
|
||||||
name=data.name,
|
|
||||||
enabled=data.enabled,
|
|
||||||
condition_logic=data.condition_logic,
|
|
||||||
conditions=conditions,
|
|
||||||
target_ids=data.target_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
return _profile_to_response(profile, engine)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/api/v1/profiles",
|
|
||||||
response_model=ProfileListResponse,
|
|
||||||
tags=["Profiles"],
|
|
||||||
)
|
|
||||||
async def list_profiles(
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: ProfileStore = Depends(get_profile_store),
|
|
||||||
engine: ProfileEngine = Depends(get_profile_engine),
|
|
||||||
):
|
|
||||||
"""List all profiles."""
|
|
||||||
profiles = store.get_all_profiles()
|
|
||||||
return ProfileListResponse(
|
|
||||||
profiles=[_profile_to_response(p, engine) for p in profiles],
|
|
||||||
count=len(profiles),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/api/v1/profiles/{profile_id}",
|
|
||||||
response_model=ProfileResponse,
|
|
||||||
tags=["Profiles"],
|
|
||||||
)
|
|
||||||
async def get_profile(
|
|
||||||
profile_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: ProfileStore = Depends(get_profile_store),
|
|
||||||
engine: ProfileEngine = Depends(get_profile_engine),
|
|
||||||
):
|
|
||||||
"""Get a single profile."""
|
|
||||||
try:
|
|
||||||
profile = store.get_profile(profile_id)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
|
|
||||||
return _profile_to_response(profile, engine)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
|
||||||
"/api/v1/profiles/{profile_id}",
|
|
||||||
response_model=ProfileResponse,
|
|
||||||
tags=["Profiles"],
|
|
||||||
)
|
|
||||||
async def update_profile(
|
|
||||||
profile_id: str,
|
|
||||||
data: ProfileUpdate,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: ProfileStore = Depends(get_profile_store),
|
|
||||||
engine: ProfileEngine = Depends(get_profile_engine),
|
|
||||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
|
||||||
):
|
|
||||||
"""Update a profile."""
|
|
||||||
if data.condition_logic is not None:
|
|
||||||
_validate_condition_logic(data.condition_logic)
|
|
||||||
if data.target_ids is not None:
|
|
||||||
_validate_target_ids(data.target_ids, target_store)
|
|
||||||
|
|
||||||
conditions = None
|
|
||||||
if data.conditions is not None:
|
|
||||||
try:
|
|
||||||
conditions = [_condition_from_schema(c) for c in data.conditions]
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
try:
|
|
||||||
# If disabling, deactivate first
|
|
||||||
if data.enabled is False:
|
|
||||||
await engine.deactivate_if_active(profile_id)
|
|
||||||
|
|
||||||
profile = store.update_profile(
|
|
||||||
profile_id=profile_id,
|
|
||||||
name=data.name,
|
|
||||||
enabled=data.enabled,
|
|
||||||
condition_logic=data.condition_logic,
|
|
||||||
conditions=conditions,
|
|
||||||
target_ids=data.target_ids,
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
|
|
||||||
return _profile_to_response(profile, engine)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/api/v1/profiles/{profile_id}",
|
|
||||||
status_code=204,
|
|
||||||
tags=["Profiles"],
|
|
||||||
)
|
|
||||||
async def delete_profile(
|
|
||||||
profile_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: ProfileStore = Depends(get_profile_store),
|
|
||||||
engine: ProfileEngine = Depends(get_profile_engine),
|
|
||||||
):
|
|
||||||
"""Delete a profile."""
|
|
||||||
# Deactivate first (stop owned targets)
|
|
||||||
await engine.deactivate_if_active(profile_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
store.delete_profile(profile_id)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Enable/Disable =====
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/api/v1/profiles/{profile_id}/enable",
|
|
||||||
response_model=ProfileResponse,
|
|
||||||
tags=["Profiles"],
|
|
||||||
)
|
|
||||||
async def enable_profile(
|
|
||||||
profile_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: ProfileStore = Depends(get_profile_store),
|
|
||||||
engine: ProfileEngine = Depends(get_profile_engine),
|
|
||||||
):
|
|
||||||
"""Enable a profile."""
|
|
||||||
try:
|
|
||||||
profile = store.update_profile(profile_id=profile_id, enabled=True)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
|
|
||||||
return _profile_to_response(profile, engine)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/api/v1/profiles/{profile_id}/disable",
|
|
||||||
response_model=ProfileResponse,
|
|
||||||
tags=["Profiles"],
|
|
||||||
)
|
|
||||||
async def disable_profile(
|
|
||||||
profile_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: ProfileStore = Depends(get_profile_store),
|
|
||||||
engine: ProfileEngine = Depends(get_profile_engine),
|
|
||||||
):
|
|
||||||
"""Disable a profile and stop any targets it owns."""
|
|
||||||
await engine.deactivate_if_active(profile_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
profile = store.update_profile(profile_id=profile_id, enabled=False)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
|
|
||||||
return _profile_to_response(profile, engine)
|
|
||||||
271
server/src/wled_controller/api/routes/scene_presets.py
Normal file
271
server/src/wled_controller/api/routes/scene_presets.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"""Scene preset API routes — CRUD, capture, activate, recapture."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_output_target_store,
|
||||||
|
get_processor_manager,
|
||||||
|
get_scene_preset_store,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.scene_presets import (
|
||||||
|
ActivateResponse,
|
||||||
|
ScenePresetCreate,
|
||||||
|
ScenePresetListResponse,
|
||||||
|
ScenePresetResponse,
|
||||||
|
ScenePresetUpdate,
|
||||||
|
)
|
||||||
|
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||||
|
from wled_controller.core.scenes.scene_activator import (
|
||||||
|
apply_scene_state,
|
||||||
|
capture_current_snapshot,
|
||||||
|
)
|
||||||
|
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||||
|
from wled_controller.storage.scene_preset import ScenePreset
|
||||||
|
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
|
||||||
|
return ScenePresetResponse(
|
||||||
|
id=preset.id,
|
||||||
|
name=preset.name,
|
||||||
|
description=preset.description,
|
||||||
|
targets=[{
|
||||||
|
"target_id": t.target_id,
|
||||||
|
"running": t.running,
|
||||||
|
"color_strip_source_id": t.color_strip_source_id,
|
||||||
|
"brightness_value_source_id": t.brightness_value_source_id,
|
||||||
|
"fps": t.fps,
|
||||||
|
} for t in preset.targets],
|
||||||
|
order=preset.order,
|
||||||
|
tags=preset.tags,
|
||||||
|
created_at=preset.created_at,
|
||||||
|
updated_at=preset.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CRUD =====
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-presets",
|
||||||
|
response_model=ScenePresetResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_scene_preset(
|
||||||
|
data: ScenePresetCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Capture current state as a new scene preset."""
|
||||||
|
target_ids = set(data.target_ids) if data.target_ids is not None else None
|
||||||
|
targets = capture_current_snapshot(target_store, manager, target_ids)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
preset = ScenePreset(
|
||||||
|
id=f"scene_{uuid.uuid4().hex[:8]}",
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
targets=targets,
|
||||||
|
order=store.count(),
|
||||||
|
tags=data.tags if data.tags is not None else [],
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
preset = store.create_preset(preset)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_preset", "created", preset.id)
|
||||||
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-presets",
|
||||||
|
response_model=ScenePresetListResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def list_scene_presets(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
):
|
||||||
|
"""List all scene presets."""
|
||||||
|
presets = store.get_all_presets()
|
||||||
|
return ScenePresetListResponse(
|
||||||
|
presets=[_preset_to_response(p) for p in presets],
|
||||||
|
count=len(presets),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-presets/{preset_id}",
|
||||||
|
response_model=ScenePresetResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def get_scene_preset(
|
||||||
|
preset_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
):
|
||||||
|
"""Get a single scene preset."""
|
||||||
|
try:
|
||||||
|
preset = store.get_preset(preset_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/scene-presets/{preset_id}",
|
||||||
|
response_model=ScenePresetResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def update_scene_preset(
|
||||||
|
preset_id: str,
|
||||||
|
data: ScenePresetUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Update scene preset metadata and optionally change targets."""
|
||||||
|
# If target_ids changed, update the snapshot: keep state for existing targets,
|
||||||
|
# capture fresh state for newly added targets, drop removed ones.
|
||||||
|
new_targets = None
|
||||||
|
if data.target_ids is not None:
|
||||||
|
try:
|
||||||
|
existing = store.get_preset(preset_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
existing_map = {t.target_id: t for t in existing.targets}
|
||||||
|
new_target_ids = set(data.target_ids)
|
||||||
|
|
||||||
|
# Capture fresh state for newly added targets
|
||||||
|
added_ids = new_target_ids - set(existing_map.keys())
|
||||||
|
fresh = capture_current_snapshot(target_store, manager, added_ids) if added_ids else []
|
||||||
|
fresh_map = {t.target_id: t for t in fresh}
|
||||||
|
|
||||||
|
# Build new target list preserving order from target_ids
|
||||||
|
new_targets = []
|
||||||
|
for tid in data.target_ids:
|
||||||
|
if tid in existing_map:
|
||||||
|
new_targets.append(existing_map[tid])
|
||||||
|
elif tid in fresh_map:
|
||||||
|
new_targets.append(fresh_map[tid])
|
||||||
|
|
||||||
|
try:
|
||||||
|
preset = store.update_preset(
|
||||||
|
preset_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
order=data.order,
|
||||||
|
targets=new_targets,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
|
||||||
|
fire_entity_event("scene_preset", "updated", preset_id)
|
||||||
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/scene-presets/{preset_id}",
|
||||||
|
status_code=204,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def delete_scene_preset(
|
||||||
|
preset_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
):
|
||||||
|
"""Delete a scene preset."""
|
||||||
|
try:
|
||||||
|
store.delete_preset(preset_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_preset", "deleted", preset_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Recapture =====
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-presets/{preset_id}/recapture",
|
||||||
|
response_model=ScenePresetResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def recapture_scene_preset(
|
||||||
|
preset_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Re-capture current state into an existing preset (updates snapshot)."""
|
||||||
|
try:
|
||||||
|
existing = store.get_preset(preset_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
# Only recapture targets that are already in the preset
|
||||||
|
existing_ids = {t.target_id for t in existing.targets}
|
||||||
|
targets = capture_current_snapshot(target_store, manager, existing_ids)
|
||||||
|
|
||||||
|
new_snapshot = ScenePreset(
|
||||||
|
id=preset_id,
|
||||||
|
name="",
|
||||||
|
targets=targets,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
preset = store.recapture_preset(preset_id, new_snapshot)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Activate =====
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-presets/{preset_id}/activate",
|
||||||
|
response_model=ActivateResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def activate_scene_preset(
|
||||||
|
preset_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Activate a scene preset — restore the captured state."""
|
||||||
|
try:
|
||||||
|
preset = store.get_preset(preset_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
status, errors = await apply_scene_state(preset, target_store, manager)
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
||||||
|
|
||||||
|
fire_entity_event("scene_preset", "updated", preset_id)
|
||||||
|
return ActivateResponse(status=status, errors=errors)
|
||||||
204
server/src/wled_controller/api/routes/sync_clocks.py
Normal file
204
server/src/wled_controller/api/routes/sync_clocks.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Sync clock routes: CRUD + runtime control for synchronization clocks."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_color_strip_store,
|
||||||
|
get_sync_clock_manager,
|
||||||
|
get_sync_clock_store,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.sync_clocks import (
|
||||||
|
SyncClockCreate,
|
||||||
|
SyncClockListResponse,
|
||||||
|
SyncClockResponse,
|
||||||
|
SyncClockUpdate,
|
||||||
|
)
|
||||||
|
from wled_controller.storage.sync_clock import SyncClock
|
||||||
|
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||||
|
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||||
|
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockResponse:
|
||||||
|
"""Convert a SyncClock to a SyncClockResponse (with runtime state)."""
|
||||||
|
rt = manager.get_runtime(clock.id)
|
||||||
|
return SyncClockResponse(
|
||||||
|
id=clock.id,
|
||||||
|
name=clock.name,
|
||||||
|
speed=rt.speed if rt else clock.speed,
|
||||||
|
description=clock.description,
|
||||||
|
tags=clock.tags,
|
||||||
|
is_running=rt.is_running if rt else True,
|
||||||
|
elapsed_time=rt.get_time() if rt else 0.0,
|
||||||
|
created_at=clock.created_at,
|
||||||
|
updated_at=clock.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/sync-clocks", response_model=SyncClockListResponse, tags=["Sync Clocks"])
|
||||||
|
async def list_sync_clocks(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
||||||
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
|
):
|
||||||
|
"""List all synchronization clocks."""
|
||||||
|
clocks = store.get_all_clocks()
|
||||||
|
return SyncClockListResponse(
|
||||||
|
clocks=[_to_response(c, manager) for c in clocks],
|
||||||
|
count=len(clocks),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/sync-clocks", response_model=SyncClockResponse, status_code=201, tags=["Sync Clocks"])
|
||||||
|
async def create_sync_clock(
|
||||||
|
data: SyncClockCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
||||||
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
|
):
|
||||||
|
"""Create a new synchronization clock."""
|
||||||
|
try:
|
||||||
|
clock = store.create_clock(
|
||||||
|
name=data.name,
|
||||||
|
speed=data.speed,
|
||||||
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
fire_entity_event("sync_clock", "created", clock.id)
|
||||||
|
return _to_response(clock, manager)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"])
|
||||||
|
async def get_sync_clock(
|
||||||
|
clock_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
||||||
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
|
):
|
||||||
|
"""Get a synchronization clock by ID."""
|
||||||
|
try:
|
||||||
|
clock = store.get_clock(clock_id)
|
||||||
|
return _to_response(clock, manager)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"])
|
||||||
|
async def update_sync_clock(
|
||||||
|
clock_id: str,
|
||||||
|
data: SyncClockUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
||||||
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
|
):
|
||||||
|
"""Update a synchronization clock. Speed changes are hot-applied to running streams."""
|
||||||
|
try:
|
||||||
|
clock = store.update_clock(
|
||||||
|
clock_id=clock_id,
|
||||||
|
name=data.name,
|
||||||
|
speed=data.speed,
|
||||||
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
# Hot-update runtime speed
|
||||||
|
if data.speed is not None:
|
||||||
|
manager.update_speed(clock_id, clock.speed)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
|
return _to_response(clock, manager)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/sync-clocks/{clock_id}", status_code=204, tags=["Sync Clocks"])
|
||||||
|
async def delete_sync_clock(
|
||||||
|
clock_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
||||||
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
|
):
|
||||||
|
"""Delete a synchronization clock (fails if referenced by CSS sources)."""
|
||||||
|
try:
|
||||||
|
# Check references
|
||||||
|
for source in css_store.get_all_sources():
|
||||||
|
if getattr(source, "clock_id", None) == clock_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot delete: referenced by color strip source '{source.name}'"
|
||||||
|
)
|
||||||
|
manager.release_all_for(clock_id)
|
||||||
|
store.delete_clock(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "deleted", clock_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))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Runtime control ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/api/v1/sync-clocks/{clock_id}/pause", response_model=SyncClockResponse, tags=["Sync Clocks"])
|
||||||
|
async def pause_sync_clock(
|
||||||
|
clock_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
||||||
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
|
):
|
||||||
|
"""Pause a synchronization clock — all linked animations freeze."""
|
||||||
|
try:
|
||||||
|
clock = store.get_clock(clock_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
manager.pause(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
|
return _to_response(clock, manager)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/sync-clocks/{clock_id}/resume", response_model=SyncClockResponse, tags=["Sync Clocks"])
|
||||||
|
async def resume_sync_clock(
|
||||||
|
clock_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
||||||
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
|
):
|
||||||
|
"""Resume a paused synchronization clock."""
|
||||||
|
try:
|
||||||
|
clock = store.get_clock(clock_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
manager.resume(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
|
return _to_response(clock, manager)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/sync-clocks/{clock_id}/reset", response_model=SyncClockResponse, tags=["Sync Clocks"])
|
||||||
|
async def reset_sync_clock(
|
||||||
|
clock_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
||||||
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
|
):
|
||||||
|
"""Reset a synchronization clock to t=0 — all linked animations restart."""
|
||||||
|
try:
|
||||||
|
clock = store.get_clock(clock_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
manager.reset(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
|
return _to_response(clock, manager)
|
||||||
@@ -1,43 +1,108 @@
|
|||||||
"""System routes: health, version, displays, performance."""
|
"""System routes: health, version, displays, performance, backup/restore, ADB."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
|
||||||
|
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 (
|
||||||
|
get_auto_backup_engine,
|
||||||
|
get_audio_source_store,
|
||||||
|
get_audio_template_store,
|
||||||
|
get_automation_store,
|
||||||
|
get_color_strip_store,
|
||||||
|
get_device_store,
|
||||||
|
get_output_target_store,
|
||||||
|
get_pattern_template_store,
|
||||||
|
get_picture_source_store,
|
||||||
|
get_pp_template_store,
|
||||||
|
get_processor_manager,
|
||||||
|
get_scene_preset_store,
|
||||||
|
get_sync_clock_store,
|
||||||
|
get_template_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.core.capture.screen_capture import get_available_displays
|
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Prime psutil CPU counter (first call always returns 0.0)
|
# Prime psutil CPU counter (first call always returns 0.0)
|
||||||
psutil.cpu_percent(interval=None)
|
psutil.cpu_percent(interval=None)
|
||||||
|
|
||||||
# Try to initialize NVIDIA GPU monitoring
|
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
||||||
_nvml_available = False
|
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
|
||||||
try:
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
import pynvml as _pynvml_mod # nvidia-ml-py (the pynvml wrapper is deprecated)
|
|
||||||
|
|
||||||
_pynvml_mod.nvmlInit()
|
|
||||||
_nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0)
|
def _get_cpu_name() -> str | None:
|
||||||
_nvml_available = True
|
"""Get a human-friendly CPU model name (cached at module level)."""
|
||||||
_nvml = _pynvml_mod
|
try:
|
||||||
logger.info(f"NVIDIA GPU monitoring enabled: {_nvml.nvmlDeviceGetName(_nvml_handle)}")
|
if platform.system() == "Windows":
|
||||||
except Exception:
|
import winreg
|
||||||
_nvml = None
|
|
||||||
logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)")
|
key = winreg.OpenKey(
|
||||||
|
winreg.HKEY_LOCAL_MACHINE,
|
||||||
|
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||||
|
)
|
||||||
|
name, _ = winreg.QueryValueEx(key, "ProcessorNameString")
|
||||||
|
winreg.CloseKey(key)
|
||||||
|
return name.strip()
|
||||||
|
elif platform.system() == "Linux":
|
||||||
|
with open("/proc/cpuinfo") as f:
|
||||||
|
for line in f:
|
||||||
|
if "model name" in line:
|
||||||
|
return line.split(":")[1].strip()
|
||||||
|
elif platform.system() == "Darwin":
|
||||||
|
return (
|
||||||
|
subprocess.check_output(
|
||||||
|
["sysctl", "-n", "machdep.cpu.brand_string"]
|
||||||
|
)
|
||||||
|
.decode()
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CPU name detection failed: %s", e)
|
||||||
|
return platform.processor() or None
|
||||||
|
|
||||||
|
|
||||||
|
_cpu_name: str | None = _get_cpu_name()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -52,8 +117,9 @@ async def health_check():
|
|||||||
|
|
||||||
return HealthResponse(
|
return HealthResponse(
|
||||||
status="healthy",
|
status="healthy",
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.now(timezone.utc),
|
||||||
version=__version__,
|
version=__version__,
|
||||||
|
demo_mode=get_config().demo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,20 +135,65 @@ async def get_version():
|
|||||||
version=__version__,
|
version=__version__,
|
||||||
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||||||
api_version="v1",
|
api_version="v1",
|
||||||
|
demo_mode=get_config().demo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/tags", tags=["Tags"])
|
||||||
|
async def list_all_tags(_: AuthRequired):
|
||||||
|
"""Get all tags used across all entities."""
|
||||||
|
all_tags: set[str] = set()
|
||||||
|
store_getters = [
|
||||||
|
get_device_store, get_output_target_store, get_color_strip_store,
|
||||||
|
get_picture_source_store, get_audio_source_store, get_value_source_store,
|
||||||
|
get_sync_clock_store, get_automation_store, get_scene_preset_store,
|
||||||
|
get_template_store, get_audio_template_store, get_pp_template_store,
|
||||||
|
get_pattern_template_store,
|
||||||
|
]
|
||||||
|
for getter in store_getters:
|
||||||
|
try:
|
||||||
|
store = getter()
|
||||||
|
except RuntimeError:
|
||||||
|
continue
|
||||||
|
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
|
||||||
|
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
|
||||||
|
items = fn() if fn else None
|
||||||
|
if items:
|
||||||
|
for item in items:
|
||||||
|
all_tags.update(item.tags)
|
||||||
|
return {"tags": sorted(all_tags)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
|
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
|
||||||
async def get_displays(_: AuthRequired):
|
async def get_displays(
|
||||||
|
_: AuthRequired,
|
||||||
|
engine_type: Optional[str] = Query(None, description="Engine type to get displays for"),
|
||||||
|
):
|
||||||
"""Get list of available displays.
|
"""Get list of available displays.
|
||||||
|
|
||||||
Returns information about all available monitors/displays that can be captured.
|
Returns information about all available monitors/displays that can be captured.
|
||||||
|
When ``engine_type`` is provided, returns displays specific to that engine
|
||||||
|
(e.g. ``scrcpy`` returns connected Android devices instead of desktop monitors).
|
||||||
"""
|
"""
|
||||||
logger.info("Listing available displays")
|
logger.info(f"Listing available displays (engine_type={engine_type})")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get available displays with all metadata (name, refresh rate, etc.)
|
from wled_controller.core.capture_engines import EngineRegistry
|
||||||
display_dataclasses = get_available_displays()
|
|
||||||
|
if engine_type:
|
||||||
|
engine_cls = EngineRegistry.get_engine(engine_type)
|
||||||
|
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
|
||||||
|
elif is_demo_mode():
|
||||||
|
# In demo mode, use the best available engine (demo engine at priority 1000)
|
||||||
|
# instead of the mss-based real display detection
|
||||||
|
best = EngineRegistry.get_best_available_engine()
|
||||||
|
if best:
|
||||||
|
engine_cls = EngineRegistry.get_engine(best)
|
||||||
|
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
|
||||||
|
else:
|
||||||
|
display_dataclasses = await asyncio.to_thread(get_available_displays)
|
||||||
|
else:
|
||||||
|
display_dataclasses = await asyncio.to_thread(get_available_displays)
|
||||||
|
|
||||||
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
|
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
|
||||||
displays = [
|
displays = [
|
||||||
@@ -106,6 +217,12 @@ async def get_displays(_: AuthRequired):
|
|||||||
count=len(displays),
|
count=len(displays),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get displays: {e}")
|
logger.error(f"Failed to get displays: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -118,9 +235,9 @@ async def get_displays(_: AuthRequired):
|
|||||||
async def get_running_processes(_: AuthRequired):
|
async def get_running_processes(_: AuthRequired):
|
||||||
"""Get list of currently running process names.
|
"""Get list of currently running process names.
|
||||||
|
|
||||||
Returns a sorted list of unique process names for use in profile conditions.
|
Returns a sorted list of unique process names for use in automation conditions.
|
||||||
"""
|
"""
|
||||||
from wled_controller.core.profiles.platform_detector import PlatformDetector
|
from wled_controller.core.automations.platform_detector import PlatformDetector
|
||||||
|
|
||||||
try:
|
try:
|
||||||
detector = PlatformDetector()
|
detector = PlatformDetector()
|
||||||
@@ -140,8 +257,13 @@ async def get_running_processes(_: AuthRequired):
|
|||||||
response_model=PerformanceResponse,
|
response_model=PerformanceResponse,
|
||||||
tags=["Config"],
|
tags=["Config"],
|
||||||
)
|
)
|
||||||
async def get_system_performance(_: AuthRequired):
|
def get_system_performance(_: AuthRequired):
|
||||||
"""Get current system performance metrics (CPU, RAM, GPU)."""
|
"""Get current system performance metrics (CPU, RAM, GPU).
|
||||||
|
|
||||||
|
Uses sync ``def`` so FastAPI runs it in a thread pool — the psutil
|
||||||
|
and NVML calls are blocking and would stall the event loop if run
|
||||||
|
in an ``async def`` handler.
|
||||||
|
"""
|
||||||
mem = psutil.virtual_memory()
|
mem = psutil.virtual_memory()
|
||||||
|
|
||||||
gpu = None
|
gpu = None
|
||||||
@@ -159,14 +281,714 @@ async def get_system_performance(_: AuthRequired):
|
|||||||
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
|
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
|
||||||
temperature_c=float(temp),
|
temperature_c=float(temp),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug("NVML query failed: %s", e)
|
||||||
|
|
||||||
return PerformanceResponse(
|
return PerformanceResponse(
|
||||||
|
cpu_name=_cpu_name,
|
||||||
cpu_percent=psutil.cpu_percent(interval=None),
|
cpu_percent=psutil.cpu_percent(interval=None),
|
||||||
ram_used_mb=round(mem.used / 1024 / 1024, 1),
|
ram_used_mb=round(mem.used / 1024 / 1024, 1),
|
||||||
ram_total_mb=round(mem.total / 1024 / 1024, 1),
|
ram_total_mb=round(mem.total / 1024 / 1024, 1),
|
||||||
ram_percent=mem.percent,
|
ram_percent=mem.percent,
|
||||||
gpu=gpu,
|
gpu=gpu,
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/metrics-history", tags=["Config"])
|
||||||
|
async def get_metrics_history(
|
||||||
|
_: AuthRequired,
|
||||||
|
manager=Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Return the last ~2 minutes of system and per-target metrics.
|
||||||
|
|
||||||
|
Used by the dashboard to seed charts on page load so history
|
||||||
|
survives browser refreshes.
|
||||||
|
"""
|
||||||
|
return manager.metrics_history.get_history()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration backup / restore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Mapping: logical store name → StorageConfig attribute name
|
||||||
|
STORE_MAP = {
|
||||||
|
"devices": "devices_file",
|
||||||
|
"capture_templates": "templates_file",
|
||||||
|
"postprocessing_templates": "postprocessing_templates_file",
|
||||||
|
"picture_sources": "picture_sources_file",
|
||||||
|
"output_targets": "output_targets_file",
|
||||||
|
"pattern_templates": "pattern_templates_file",
|
||||||
|
"color_strip_sources": "color_strip_sources_file",
|
||||||
|
"audio_sources": "audio_sources_file",
|
||||||
|
"audio_templates": "audio_templates_file",
|
||||||
|
"value_sources": "value_sources_file",
|
||||||
|
"sync_clocks": "sync_clocks_file",
|
||||||
|
"color_strip_processing_templates": "color_strip_processing_templates_file",
|
||||||
|
"automations": "automations_file",
|
||||||
|
"scene_presets": "scene_presets_file",
|
||||||
|
}
|
||||||
|
|
||||||
|
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_restart() -> None:
|
||||||
|
"""Spawn a restart script after a short delay so the HTTP response completes."""
|
||||||
|
|
||||||
|
def _restart():
|
||||||
|
import time
|
||||||
|
time.sleep(1)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
subprocess.Popen(
|
||||||
|
["powershell", "-ExecutionPolicy", "Bypass", "-File",
|
||||||
|
str(_SERVER_DIR / "restart.ps1")],
|
||||||
|
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
subprocess.Popen(
|
||||||
|
["bash", str(_SERVER_DIR / "restart.sh")],
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
threading.Thread(target=_restart, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/api-keys", tags=["System"])
|
||||||
|
def list_api_keys(_: AuthRequired):
|
||||||
|
"""List API key labels (read-only; keys are defined in the YAML config file)."""
|
||||||
|
config = get_config()
|
||||||
|
keys = [
|
||||||
|
{"label": label, "masked": key[:4] + "****" + key[-4:] if len(key) >= 8 else "****"}
|
||||||
|
for label, key in config.auth.api_keys.items()
|
||||||
|
]
|
||||||
|
return {"keys": keys, "count": len(keys)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
|
||||||
|
def export_store(store_key: str, _: AuthRequired):
|
||||||
|
"""Download a single entity store as a JSON file."""
|
||||||
|
if store_key not in STORE_MAP:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
|
||||||
|
)
|
||||||
|
config = get_config()
|
||||||
|
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
|
||||||
|
if file_path.exists():
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
else:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
export = {
|
||||||
|
"meta": {
|
||||||
|
"format": "ledgrab-partial-export",
|
||||||
|
"format_version": 1,
|
||||||
|
"store_key": store_key,
|
||||||
|
"app_version": __version__,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
|
||||||
|
},
|
||||||
|
"store": data,
|
||||||
|
}
|
||||||
|
content = json.dumps(export, indent=2, ensure_ascii=False)
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||||
|
filename = f"ledgrab-{store_key}-{timestamp}.json"
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(content.encode("utf-8")),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
|
||||||
|
async def import_store(
|
||||||
|
store_key: str,
|
||||||
|
_: AuthRequired,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
|
||||||
|
):
|
||||||
|
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
|
||||||
|
if store_key not in STORE_MAP:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await file.read()
|
||||||
|
if len(raw) > 10 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
|
||||||
|
|
||||||
|
# Support both full-backup format and partial-export format
|
||||||
|
if "stores" in payload and isinstance(payload.get("meta"), dict):
|
||||||
|
# Full backup: extract the specific store
|
||||||
|
if payload["meta"].get("format") not in ("ledgrab-backup",):
|
||||||
|
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
|
||||||
|
stores = payload.get("stores", {})
|
||||||
|
if store_key not in stores:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
|
||||||
|
incoming = stores[store_key]
|
||||||
|
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
|
||||||
|
# Partial export format
|
||||||
|
if payload["meta"].get("store_key") != store_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
|
||||||
|
)
|
||||||
|
incoming = payload.get("store", {})
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
|
||||||
|
|
||||||
|
if not isinstance(incoming, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
|
||||||
|
|
||||||
|
def _write():
|
||||||
|
if merge and file_path.exists():
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
existing = json.load(f)
|
||||||
|
if isinstance(existing, dict):
|
||||||
|
existing.update(incoming)
|
||||||
|
atomic_write_json(file_path, existing)
|
||||||
|
return len(existing)
|
||||||
|
atomic_write_json(file_path, incoming)
|
||||||
|
return len(incoming)
|
||||||
|
|
||||||
|
count = await asyncio.to_thread(_write)
|
||||||
|
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
|
||||||
|
_schedule_restart()
|
||||||
|
return {
|
||||||
|
"status": "imported",
|
||||||
|
"store_key": store_key,
|
||||||
|
"entries": count,
|
||||||
|
"merge": merge,
|
||||||
|
"restart_scheduled": True,
|
||||||
|
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/backup", tags=["System"])
|
||||||
|
def backup_config(_: AuthRequired):
|
||||||
|
"""Download all configuration as a single JSON backup file."""
|
||||||
|
config = get_config()
|
||||||
|
stores = {}
|
||||||
|
for store_key, config_attr in STORE_MAP.items():
|
||||||
|
file_path = Path(getattr(config.storage, config_attr))
|
||||||
|
if file_path.exists():
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
stores[store_key] = json.load(f)
|
||||||
|
else:
|
||||||
|
stores[store_key] = {}
|
||||||
|
|
||||||
|
backup = {
|
||||||
|
"meta": {
|
||||||
|
"format": "ledgrab-backup",
|
||||||
|
"format_version": 1,
|
||||||
|
"app_version": __version__,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
|
||||||
|
"store_count": len(stores),
|
||||||
|
},
|
||||||
|
"stores": stores,
|
||||||
|
}
|
||||||
|
|
||||||
|
content = json.dumps(backup, indent=2, ensure_ascii=False)
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||||
|
filename = f"ledgrab-backup-{timestamp}.json"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(content.encode("utf-8")),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/system/restart", tags=["System"])
|
||||||
|
def restart_server(_: AuthRequired):
|
||||||
|
"""Schedule a server restart and return immediately."""
|
||||||
|
_schedule_restart()
|
||||||
|
return {"status": "restarting"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
|
||||||
|
async def restore_config(
|
||||||
|
_: AuthRequired,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
):
|
||||||
|
"""Upload a backup file to restore all configuration. Triggers server restart."""
|
||||||
|
# Read and parse
|
||||||
|
try:
|
||||||
|
raw = await file.read()
|
||||||
|
if len(raw) > 10 * 1024 * 1024: # 10 MB limit
|
||||||
|
raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)")
|
||||||
|
backup = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
|
||||||
|
|
||||||
|
# Validate envelope
|
||||||
|
meta = backup.get("meta")
|
||||||
|
if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup":
|
||||||
|
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file")
|
||||||
|
|
||||||
|
fmt_version = meta.get("format_version", 0)
|
||||||
|
if fmt_version > 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Backup format version {fmt_version} is not supported by this server version",
|
||||||
|
)
|
||||||
|
|
||||||
|
stores = backup.get("stores")
|
||||||
|
if not isinstance(stores, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Backup file missing 'stores' section")
|
||||||
|
|
||||||
|
known_keys = set(STORE_MAP.keys())
|
||||||
|
present_keys = known_keys & set(stores.keys())
|
||||||
|
if not present_keys:
|
||||||
|
raise HTTPException(status_code=400, detail="Backup contains no recognized store data")
|
||||||
|
|
||||||
|
for key in present_keys:
|
||||||
|
if not isinstance(stores[key], dict):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
|
||||||
|
|
||||||
|
# Write store files atomically (in thread to avoid blocking event loop)
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
def _write_stores():
|
||||||
|
count = 0
|
||||||
|
for store_key, config_attr in STORE_MAP.items():
|
||||||
|
if store_key in stores:
|
||||||
|
file_path = Path(getattr(config.storage, config_attr))
|
||||||
|
atomic_write_json(file_path, stores[store_key])
|
||||||
|
count += 1
|
||||||
|
logger.info(f"Restored store: {store_key} -> {file_path}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
written = await asyncio.to_thread(_write_stores)
|
||||||
|
|
||||||
|
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
|
||||||
|
_schedule_restart()
|
||||||
|
|
||||||
|
missing = known_keys - present_keys
|
||||||
|
return RestoreResponse(
|
||||||
|
status="restored",
|
||||||
|
stores_written=written,
|
||||||
|
stores_total=len(STORE_MAP),
|
||||||
|
missing_stores=sorted(missing) if missing else [],
|
||||||
|
restart_scheduled=True,
|
||||||
|
message=f"Restored {written} stores. Server restarting...",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auto-backup settings & saved backups
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/system/auto-backup/settings",
|
||||||
|
response_model=AutoBackupStatusResponse,
|
||||||
|
tags=["System"],
|
||||||
|
)
|
||||||
|
async def get_auto_backup_settings(
|
||||||
|
_: AuthRequired,
|
||||||
|
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||||
|
):
|
||||||
|
"""Get auto-backup settings and status."""
|
||||||
|
return engine.get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/system/auto-backup/settings",
|
||||||
|
response_model=AutoBackupStatusResponse,
|
||||||
|
tags=["System"],
|
||||||
|
)
|
||||||
|
async def update_auto_backup_settings(
|
||||||
|
_: AuthRequired,
|
||||||
|
body: AutoBackupSettings,
|
||||||
|
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||||
|
):
|
||||||
|
"""Update auto-backup settings (enable/disable, interval, max backups)."""
|
||||||
|
return await engine.update_settings(
|
||||||
|
enabled=body.enabled,
|
||||||
|
interval_hours=body.interval_hours,
|
||||||
|
max_backups=body.max_backups,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
|
||||||
|
async def trigger_backup(
|
||||||
|
_: AuthRequired,
|
||||||
|
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||||
|
):
|
||||||
|
"""Manually trigger a backup now."""
|
||||||
|
backup = await engine.trigger_backup()
|
||||||
|
return {"status": "ok", "backup": backup}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/system/backups",
|
||||||
|
response_model=BackupListResponse,
|
||||||
|
tags=["System"],
|
||||||
|
)
|
||||||
|
async def list_backups(
|
||||||
|
_: AuthRequired,
|
||||||
|
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||||
|
):
|
||||||
|
"""List all saved backup files."""
|
||||||
|
backups = engine.list_backups()
|
||||||
|
return BackupListResponse(
|
||||||
|
backups=[BackupFileInfo(**b) for b in backups],
|
||||||
|
count=len(backups),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/backups/{filename}", tags=["System"])
|
||||||
|
def download_saved_backup(
|
||||||
|
filename: str,
|
||||||
|
_: AuthRequired,
|
||||||
|
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||||
|
):
|
||||||
|
"""Download a specific saved backup file."""
|
||||||
|
try:
|
||||||
|
path = engine.get_backup_path(filename)
|
||||||
|
except (ValueError, FileNotFoundError) as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
content = path.read_bytes()
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(content),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/system/backups/{filename}", tags=["System"])
|
||||||
|
async def delete_saved_backup(
|
||||||
|
filename: str,
|
||||||
|
_: AuthRequired,
|
||||||
|
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||||
|
):
|
||||||
|
"""Delete a specific saved backup file."""
|
||||||
|
try:
|
||||||
|
engine.delete_backup(filename)
|
||||||
|
except (ValueError, FileNotFoundError) as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
return {"status": "deleted", "filename": filename}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MQTT settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MQTT_SETTINGS_FILE: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mqtt_settings_path() -> Path:
|
||||||
|
global _MQTT_SETTINGS_FILE
|
||||||
|
if _MQTT_SETTINGS_FILE is None:
|
||||||
|
cfg = get_config()
|
||||||
|
# Derive the data directory from any known storage file path
|
||||||
|
data_dir = Path(cfg.storage.devices_file).parent
|
||||||
|
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
|
||||||
|
return _MQTT_SETTINGS_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def _load_mqtt_settings() -> dict:
|
||||||
|
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
|
||||||
|
cfg = get_config()
|
||||||
|
defaults = {
|
||||||
|
"enabled": cfg.mqtt.enabled,
|
||||||
|
"broker_host": cfg.mqtt.broker_host,
|
||||||
|
"broker_port": cfg.mqtt.broker_port,
|
||||||
|
"username": cfg.mqtt.username,
|
||||||
|
"password": cfg.mqtt.password,
|
||||||
|
"client_id": cfg.mqtt.client_id,
|
||||||
|
"base_topic": cfg.mqtt.base_topic,
|
||||||
|
}
|
||||||
|
path = _get_mqtt_settings_path()
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
overrides = json.load(f)
|
||||||
|
defaults.update(overrides)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load MQTT settings override file: {e}")
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def _save_mqtt_settings(settings: dict) -> None:
|
||||||
|
"""Persist MQTT settings to the JSON override file."""
|
||||||
|
from wled_controller.utils import atomic_write_json
|
||||||
|
atomic_write_json(_get_mqtt_settings_path(), settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/system/mqtt/settings",
|
||||||
|
response_model=MQTTSettingsResponse,
|
||||||
|
tags=["System"],
|
||||||
|
)
|
||||||
|
async def get_mqtt_settings(_: AuthRequired):
|
||||||
|
"""Get current MQTT broker settings. Password is masked."""
|
||||||
|
s = _load_mqtt_settings()
|
||||||
|
return MQTTSettingsResponse(
|
||||||
|
enabled=s["enabled"],
|
||||||
|
broker_host=s["broker_host"],
|
||||||
|
broker_port=s["broker_port"],
|
||||||
|
username=s["username"],
|
||||||
|
password_set=bool(s.get("password")),
|
||||||
|
client_id=s["client_id"],
|
||||||
|
base_topic=s["base_topic"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/system/mqtt/settings",
|
||||||
|
response_model=MQTTSettingsResponse,
|
||||||
|
tags=["System"],
|
||||||
|
)
|
||||||
|
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
|
||||||
|
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
|
||||||
|
current = _load_mqtt_settings()
|
||||||
|
|
||||||
|
# If caller sends an empty password, keep the existing one
|
||||||
|
password = body.password if body.password else current.get("password", "")
|
||||||
|
|
||||||
|
new_settings = {
|
||||||
|
"enabled": body.enabled,
|
||||||
|
"broker_host": body.broker_host,
|
||||||
|
"broker_port": body.broker_port,
|
||||||
|
"username": body.username,
|
||||||
|
"password": password,
|
||||||
|
"client_id": body.client_id,
|
||||||
|
"base_topic": body.base_topic,
|
||||||
|
}
|
||||||
|
_save_mqtt_settings(new_settings)
|
||||||
|
logger.info("MQTT settings updated")
|
||||||
|
|
||||||
|
return MQTTSettingsResponse(
|
||||||
|
enabled=new_settings["enabled"],
|
||||||
|
broker_host=new_settings["broker_host"],
|
||||||
|
broker_port=new_settings["broker_port"],
|
||||||
|
username=new_settings["username"],
|
||||||
|
password_set=bool(new_settings["password"]),
|
||||||
|
client_id=new_settings["client_id"],
|
||||||
|
base_topic=new_settings["base_topic"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# External URL setting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_EXTERNAL_URL_FILE: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_external_url_path() -> Path:
|
||||||
|
global _EXTERNAL_URL_FILE
|
||||||
|
if _EXTERNAL_URL_FILE is None:
|
||||||
|
cfg = get_config()
|
||||||
|
data_dir = Path(cfg.storage.devices_file).parent
|
||||||
|
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
|
||||||
|
return _EXTERNAL_URL_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def load_external_url() -> str:
|
||||||
|
"""Load the external URL setting. Returns empty string if not set."""
|
||||||
|
path = _get_external_url_path()
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get("external_url", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _save_external_url(url: str) -> None:
|
||||||
|
from wled_controller.utils import atomic_write_json
|
||||||
|
atomic_write_json(_get_external_url_path(), {"external_url": url})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/system/external-url",
|
||||||
|
response_model=ExternalUrlResponse,
|
||||||
|
tags=["System"],
|
||||||
|
)
|
||||||
|
async def get_external_url(_: AuthRequired):
|
||||||
|
"""Get the configured external base URL."""
|
||||||
|
return ExternalUrlResponse(external_url=load_external_url())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/system/external-url",
|
||||||
|
response_model=ExternalUrlResponse,
|
||||||
|
tags=["System"],
|
||||||
|
)
|
||||||
|
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
|
||||||
|
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
|
||||||
|
url = body.external_url.strip().rstrip("/")
|
||||||
|
_save_external_url(url)
|
||||||
|
logger.info("External URL updated: %s", url or "(cleared)")
|
||||||
|
return ExternalUrlResponse(external_url=url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Live log viewer WebSocket
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/system/logs/ws")
|
||||||
|
async def logs_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
token: str = Query(""),
|
||||||
|
):
|
||||||
|
"""WebSocket that streams server log lines in real time.
|
||||||
|
|
||||||
|
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
|
||||||
|
lines as individual text messages, then pushes new lines as they appear.
|
||||||
|
"""
|
||||||
|
from wled_controller.api.auth import verify_ws_token
|
||||||
|
from wled_controller.utils import log_broadcaster
|
||||||
|
|
||||||
|
if not verify_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Ensure the broadcaster knows the event loop (may be first connection)
|
||||||
|
log_broadcaster.ensure_loop()
|
||||||
|
|
||||||
|
# Subscribe *before* reading the backlog so no lines slip through
|
||||||
|
queue = log_broadcaster.subscribe()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send backlog first
|
||||||
|
for line in log_broadcaster.get_backlog():
|
||||||
|
await websocket.send_text(line)
|
||||||
|
|
||||||
|
# Stream new lines
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||||
|
await websocket.send_text(line)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Send a keepalive ping so the connection stays alive
|
||||||
|
try:
|
||||||
|
await websocket.send_text("")
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
log_broadcaster.unsubscribe(queue)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ADB helpers (for Android / scrcpy engine)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AdbConnectRequest(BaseModel):
|
||||||
|
address: str
|
||||||
|
|
||||||
|
|
||||||
|
def _get_adb_path() -> str:
|
||||||
|
"""Get the adb binary path from the scrcpy engine's resolver."""
|
||||||
|
from wled_controller.core.capture_engines.scrcpy_engine import _get_adb
|
||||||
|
return _get_adb()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/adb/connect", tags=["ADB"])
|
||||||
|
async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
|
||||||
|
"""Connect to a WiFi ADB device by IP address.
|
||||||
|
|
||||||
|
Appends ``:5555`` if no port is specified.
|
||||||
|
"""
|
||||||
|
address = request.address.strip()
|
||||||
|
if not address:
|
||||||
|
raise HTTPException(status_code=400, detail="Address is required")
|
||||||
|
if ":" not in address:
|
||||||
|
address = f"{address}:5555"
|
||||||
|
|
||||||
|
adb = _get_adb_path()
|
||||||
|
logger.info(f"Connecting ADB device: {address}")
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
adb, "connect", address,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||||
|
output = (stdout.decode() + stderr.decode()).strip()
|
||||||
|
if "connected" in output.lower():
|
||||||
|
return {"status": "connected", "address": address, "message": output}
|
||||||
|
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise HTTPException(status_code=504, detail="ADB connect timed out")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/adb/disconnect", tags=["ADB"])
|
||||||
|
async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
||||||
|
"""Disconnect a WiFi ADB device."""
|
||||||
|
address = request.address.strip()
|
||||||
|
if not address:
|
||||||
|
raise HTTPException(status_code=400, detail="Address is required")
|
||||||
|
|
||||||
|
adb = _get_adb_path()
|
||||||
|
logger.info(f"Disconnecting ADB device: {address}")
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
adb, "disconnect", address,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||||
|
return {"status": "disconnected", "message": stdout.decode().strip()}
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Log level ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
|
||||||
|
async def get_log_level(_: AuthRequired):
|
||||||
|
"""Get the current root logger log level."""
|
||||||
|
level_int = logging.getLogger().getEffectiveLevel()
|
||||||
|
return LogLevelResponse(level=logging.getLevelName(level_int))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
|
||||||
|
async def set_log_level(_: AuthRequired, body: LogLevelRequest):
|
||||||
|
"""Change the root logger log level at runtime (no server restart required)."""
|
||||||
|
level_name = body.level.upper()
|
||||||
|
if level_name not in _VALID_LOG_LEVELS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid log level '{body.level}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}",
|
||||||
|
)
|
||||||
|
level_int = getattr(logging, level_name)
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level_int)
|
||||||
|
# Also update all handlers so they actually emit at the new level
|
||||||
|
for handler in root.handlers:
|
||||||
|
handler.setLevel(level_int)
|
||||||
|
logger.info("Log level changed to %s", level_name)
|
||||||
|
return LogLevelResponse(level=level_name)
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import time
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
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_device_store,
|
fire_entity_event,
|
||||||
|
get_cspt_store,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_processor_manager,
|
get_pp_template_store,
|
||||||
get_template_store,
|
get_template_store,
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.common import (
|
from wled_controller.api.schemas.common import (
|
||||||
@@ -36,12 +37,11 @@ from wled_controller.api.schemas.filters import (
|
|||||||
)
|
)
|
||||||
from wled_controller.core.capture_engines import EngineRegistry
|
from wled_controller.core.capture_engines import EngineRegistry
|
||||||
from wled_controller.core.filters import FilterRegistry
|
from wled_controller.core.filters import FilterRegistry
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
|
||||||
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.picture_source_store import PictureSourceStore
|
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource
|
from wled_controller.storage.picture_source import ScreenCapturePictureSource
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ async def list_templates(
|
|||||||
name=t.name,
|
name=t.name,
|
||||||
engine_type=t.engine_type,
|
engine_type=t.engine_type,
|
||||||
engine_config=t.engine_config,
|
engine_config=t.engine_config,
|
||||||
|
tags=t.tags,
|
||||||
created_at=t.created_at,
|
created_at=t.created_at,
|
||||||
updated_at=t.updated_at,
|
updated_at=t.updated_at,
|
||||||
description=t.description,
|
description=t.description,
|
||||||
@@ -96,19 +96,25 @@ async def create_template(
|
|||||||
engine_type=template_data.engine_type,
|
engine_type=template_data.engine_type,
|
||||||
engine_config=template_data.engine_config,
|
engine_config=template_data.engine_config,
|
||||||
description=template_data.description,
|
description=template_data.description,
|
||||||
|
tags=template_data.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fire_entity_event("capture_template", "created", template.id)
|
||||||
return TemplateResponse(
|
return TemplateResponse(
|
||||||
id=template.id,
|
id=template.id,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
engine_type=template.engine_type,
|
engine_type=template.engine_type,
|
||||||
engine_config=template.engine_config,
|
engine_config=template.engine_config,
|
||||||
|
tags=template.tags,
|
||||||
created_at=template.created_at,
|
created_at=template.created_at,
|
||||||
updated_at=template.updated_at,
|
updated_at=template.updated_at,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -133,6 +139,7 @@ async def get_template(
|
|||||||
name=template.name,
|
name=template.name,
|
||||||
engine_type=template.engine_type,
|
engine_type=template.engine_type,
|
||||||
engine_config=template.engine_config,
|
engine_config=template.engine_config,
|
||||||
|
tags=template.tags,
|
||||||
created_at=template.created_at,
|
created_at=template.created_at,
|
||||||
updated_at=template.updated_at,
|
updated_at=template.updated_at,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
@@ -154,19 +161,25 @@ async def update_template(
|
|||||||
engine_type=update_data.engine_type,
|
engine_type=update_data.engine_type,
|
||||||
engine_config=update_data.engine_config,
|
engine_config=update_data.engine_config,
|
||||||
description=update_data.description,
|
description=update_data.description,
|
||||||
|
tags=update_data.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fire_entity_event("capture_template", "updated", template_id)
|
||||||
return TemplateResponse(
|
return TemplateResponse(
|
||||||
id=template.id,
|
id=template.id,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
engine_type=template.engine_type,
|
engine_type=template.engine_type,
|
||||||
engine_config=template.engine_config,
|
engine_config=template.engine_config,
|
||||||
|
tags=template.tags,
|
||||||
created_at=template.created_at,
|
created_at=template.created_at,
|
||||||
updated_at=template.updated_at,
|
updated_at=template.updated_at,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -202,9 +215,13 @@ async def delete_template(
|
|||||||
|
|
||||||
# Proceed with deletion
|
# Proceed with deletion
|
||||||
template_store.delete_template(template_id)
|
template_store.delete_template(template_id)
|
||||||
|
fire_entity_event("capture_template", "deleted", template_id)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise # Re-raise HTTP exceptions as-is
|
raise # Re-raise HTTP exceptions as-is
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -214,22 +231,24 @@ async def delete_template(
|
|||||||
|
|
||||||
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
|
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
|
||||||
async def list_engines(_auth: AuthRequired):
|
async def list_engines(_auth: AuthRequired):
|
||||||
"""List available capture engines on this system.
|
"""List all registered capture engines.
|
||||||
|
|
||||||
Returns all registered engines that are available on the current platform.
|
Returns every registered engine with an ``available`` flag showing
|
||||||
|
whether it can be used on the current system.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
available_engine_types = EngineRegistry.get_available_engines()
|
available_set = set(EngineRegistry.get_available_engines())
|
||||||
|
all_engines = EngineRegistry.get_all_engines()
|
||||||
|
|
||||||
engines = []
|
engines = []
|
||||||
for engine_type in available_engine_types:
|
for engine_type, engine_class in all_engines.items():
|
||||||
engine_class = EngineRegistry.get_engine(engine_type)
|
|
||||||
engines.append(
|
engines.append(
|
||||||
EngineInfo(
|
EngineInfo(
|
||||||
type=engine_type,
|
type=engine_type,
|
||||||
name=engine_type.upper(),
|
name=engine_type.upper(),
|
||||||
default_config=engine_class.get_default_config(),
|
default_config=engine_class.get_default_config(),
|
||||||
available=True,
|
available=(engine_type in available_set),
|
||||||
|
has_own_displays=getattr(engine_class, 'HAS_OWN_DISPLAYS', False),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -241,14 +260,16 @@ async def list_engines(_auth: AuthRequired):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
|
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
|
||||||
async def test_template(
|
def test_template(
|
||||||
test_request: TemplateTestRequest,
|
test_request: TemplateTestRequest,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
processor_manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
device_store: DeviceStore = Depends(get_device_store),
|
|
||||||
):
|
):
|
||||||
"""Test a capture template configuration.
|
"""Test a capture template configuration.
|
||||||
|
|
||||||
|
Uses sync ``def`` so FastAPI runs it in a thread pool — the engine
|
||||||
|
initialisation and capture loop are blocking and would stall the
|
||||||
|
event loop if run in an ``async def`` handler.
|
||||||
|
|
||||||
Temporarily instantiates an engine with the provided configuration,
|
Temporarily instantiates an engine with the provided configuration,
|
||||||
captures frames for the specified duration, and returns actual FPS metrics.
|
captures frames for the specified duration, and returns actual FPS metrics.
|
||||||
"""
|
"""
|
||||||
@@ -261,24 +282,6 @@ async def test_template(
|
|||||||
detail=f"Engine '{test_request.engine_type}' is not available on this system"
|
detail=f"Engine '{test_request.engine_type}' is not available on this system"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if display is already being captured
|
|
||||||
locked_device_id = processor_manager.get_display_lock_info(test_request.display_index)
|
|
||||||
if locked_device_id:
|
|
||||||
# Get device info for better error message
|
|
||||||
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 {test_request.display_index} is currently being captured by device "
|
|
||||||
f"'{device_name}'. Please stop the device processing before testing this template."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create and initialize capture stream
|
# Create and initialize capture stream
|
||||||
stream = EngineRegistry.create_stream(
|
stream = EngineRegistry.create_stream(
|
||||||
test_request.engine_type, test_request.display_index, test_request.engine_config
|
test_request.engine_type, test_request.display_index, test_request.engine_config
|
||||||
@@ -300,8 +303,9 @@ async def test_template(
|
|||||||
screen_capture = stream.capture_frame()
|
screen_capture = stream.capture_frame()
|
||||||
capture_elapsed = time.perf_counter() - capture_start
|
capture_elapsed = time.perf_counter() - capture_start
|
||||||
|
|
||||||
# Skip if no new frame (screen unchanged)
|
# Skip if no new frame (screen unchanged); yield CPU
|
||||||
if screen_capture is None:
|
if screen_capture is None:
|
||||||
|
time.sleep(0.005)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
total_capture_time += capture_elapsed
|
total_capture_time += capture_elapsed
|
||||||
@@ -367,6 +371,10 @@ async def test_template(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
@@ -382,29 +390,154 @@ async def test_template(
|
|||||||
logger.error(f"Error cleaning up test stream: {e}")
|
logger.error(f"Error cleaning up test stream: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== REAL-TIME CAPTURE TEMPLATE TEST WEBSOCKET =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/capture-templates/test/ws")
|
||||||
|
async def test_template_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
token: str = Query(""),
|
||||||
|
):
|
||||||
|
"""WebSocket for capture template test with intermediate frame previews.
|
||||||
|
|
||||||
|
Config is sent as the first client message (JSON with engine_type,
|
||||||
|
engine_config, display_index, capture_duration).
|
||||||
|
"""
|
||||||
|
from wled_controller.api.routes._test_helpers import (
|
||||||
|
authenticate_ws_token,
|
||||||
|
stream_capture_test,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not authenticate_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Read config from first client message
|
||||||
|
try:
|
||||||
|
config = await websocket.receive_json()
|
||||||
|
except Exception as e:
|
||||||
|
await websocket.send_json({"type": "error", "detail": f"Expected JSON config: {e}"})
|
||||||
|
await websocket.close(code=4003)
|
||||||
|
return
|
||||||
|
|
||||||
|
engine_type = config.get("engine_type", "")
|
||||||
|
engine_config = config.get("engine_config", {})
|
||||||
|
display_index = config.get("display_index", 0)
|
||||||
|
duration = float(config.get("capture_duration", 5.0))
|
||||||
|
pw = int(config.get("preview_width", 0)) or None
|
||||||
|
|
||||||
|
if engine_type not in EngineRegistry.get_available_engines():
|
||||||
|
await websocket.send_json({"type": "error", "detail": f"Engine '{engine_type}' not available"})
|
||||||
|
await websocket.close(code=4003)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Engine factory — creates + initializes engine inside the capture thread
|
||||||
|
# to avoid thread-affinity issues (e.g. MSS uses thread-local state)
|
||||||
|
def engine_factory():
|
||||||
|
s = EngineRegistry.create_stream(engine_type, display_index, engine_config)
|
||||||
|
s.initialize()
|
||||||
|
return s
|
||||||
|
|
||||||
|
logger.info(f"Capture template test WS connected ({engine_type}, display {display_index}, {duration}s)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Capture template test WS error: {e}")
|
||||||
|
finally:
|
||||||
|
logger.info("Capture template test WS disconnected")
|
||||||
|
|
||||||
|
|
||||||
# ===== FILTER TYPE ENDPOINTS =====
|
# ===== FILTER TYPE ENDPOINTS =====
|
||||||
|
|
||||||
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
|
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
|
||||||
async def list_filter_types(_auth: AuthRequired):
|
async def list_filter_types(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
pp_store=Depends(get_pp_template_store),
|
||||||
|
):
|
||||||
"""List all available postprocessing filter types and their options schemas."""
|
"""List all available postprocessing filter types and their options schemas."""
|
||||||
all_filters = FilterRegistry.get_all()
|
all_filters = FilterRegistry.get_all()
|
||||||
|
|
||||||
|
# Pre-build template choices for the filter_template filter
|
||||||
|
template_choices = None
|
||||||
|
if pp_store:
|
||||||
|
try:
|
||||||
|
templates = pp_store.get_all_templates()
|
||||||
|
template_choices = [{"value": t.id, "label": t.name} for t in templates]
|
||||||
|
except Exception:
|
||||||
|
template_choices = []
|
||||||
|
|
||||||
responses = []
|
responses = []
|
||||||
for filter_id, filter_cls in all_filters.items():
|
for filter_id, filter_cls in all_filters.items():
|
||||||
schema = filter_cls.get_options_schema()
|
schema = filter_cls.get_options_schema()
|
||||||
|
opt_schemas = []
|
||||||
|
for opt in schema:
|
||||||
|
choices = opt.choices
|
||||||
|
# Enrich filter_template choices with current template list
|
||||||
|
if filter_id == "filter_template" and opt.key == "template_id" and template_choices is not None:
|
||||||
|
choices = template_choices
|
||||||
|
opt_schemas.append(FilterOptionDefSchema(
|
||||||
|
key=opt.key,
|
||||||
|
label=opt.label,
|
||||||
|
type=opt.option_type,
|
||||||
|
default=opt.default,
|
||||||
|
min_value=opt.min_value,
|
||||||
|
max_value=opt.max_value,
|
||||||
|
step=opt.step,
|
||||||
|
choices=choices,
|
||||||
|
))
|
||||||
responses.append(FilterTypeResponse(
|
responses.append(FilterTypeResponse(
|
||||||
filter_id=filter_cls.filter_id,
|
filter_id=filter_cls.filter_id,
|
||||||
filter_name=filter_cls.filter_name,
|
filter_name=filter_cls.filter_name,
|
||||||
options_schema=[
|
options_schema=opt_schemas,
|
||||||
FilterOptionDefSchema(
|
))
|
||||||
key=opt.key,
|
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||||
label=opt.label,
|
|
||||||
type=opt.option_type,
|
|
||||||
default=opt.default,
|
@router.get("/api/v1/strip-filters", response_model=FilterTypeListResponse, tags=["Filters"])
|
||||||
min_value=opt.min_value,
|
async def list_strip_filter_types(
|
||||||
max_value=opt.max_value,
|
_auth: AuthRequired,
|
||||||
step=opt.step,
|
cspt_store=Depends(get_cspt_store),
|
||||||
)
|
):
|
||||||
for opt in schema
|
"""List filter types that support 1D LED strip processing."""
|
||||||
],
|
all_filters = FilterRegistry.get_all()
|
||||||
|
|
||||||
|
# Pre-build template choices for the css_filter_template filter
|
||||||
|
cspt_choices = None
|
||||||
|
if cspt_store:
|
||||||
|
try:
|
||||||
|
templates = cspt_store.get_all_templates()
|
||||||
|
cspt_choices = [{"value": t.id, "label": t.name} for t in templates]
|
||||||
|
except Exception:
|
||||||
|
cspt_choices = []
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
for filter_id, filter_cls in all_filters.items():
|
||||||
|
if not getattr(filter_cls, "supports_strip", True):
|
||||||
|
continue
|
||||||
|
schema = filter_cls.get_options_schema()
|
||||||
|
opt_schemas = []
|
||||||
|
for opt in schema:
|
||||||
|
choices = opt.choices
|
||||||
|
if filter_id == "css_filter_template" and opt.key == "template_id" and cspt_choices is not None:
|
||||||
|
choices = cspt_choices
|
||||||
|
opt_schemas.append(FilterOptionDefSchema(
|
||||||
|
key=opt.key,
|
||||||
|
label=opt.label,
|
||||||
|
type=opt.option_type,
|
||||||
|
default=opt.default,
|
||||||
|
min_value=opt.min_value,
|
||||||
|
max_value=opt.max_value,
|
||||||
|
step=opt.step,
|
||||||
|
choices=choices,
|
||||||
|
))
|
||||||
|
responses.append(FilterTypeResponse(
|
||||||
|
filter_id=filter_cls.filter_id,
|
||||||
|
filter_name=filter_cls.filter_name,
|
||||||
|
options_schema=opt_schemas,
|
||||||
))
|
))
|
||||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||||
|
|||||||
253
server/src/wled_controller/api/routes/value_sources.py
Normal file
253
server/src/wled_controller/api/routes/value_sources.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""Value source routes: CRUD for value sources."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_output_target_store,
|
||||||
|
get_processor_manager,
|
||||||
|
get_value_source_store,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.value_sources import (
|
||||||
|
ValueSourceCreate,
|
||||||
|
ValueSourceListResponse,
|
||||||
|
ValueSourceResponse,
|
||||||
|
ValueSourceUpdate,
|
||||||
|
)
|
||||||
|
from wled_controller.storage.value_source import ValueSource
|
||||||
|
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||||
|
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||||
|
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(source: ValueSource) -> ValueSourceResponse:
|
||||||
|
"""Convert a ValueSource to a ValueSourceResponse."""
|
||||||
|
d = source.to_dict()
|
||||||
|
return ValueSourceResponse(
|
||||||
|
id=d["id"],
|
||||||
|
name=d["name"],
|
||||||
|
source_type=d["source_type"],
|
||||||
|
value=d.get("value"),
|
||||||
|
waveform=d.get("waveform"),
|
||||||
|
speed=d.get("speed"),
|
||||||
|
min_value=d.get("min_value"),
|
||||||
|
max_value=d.get("max_value"),
|
||||||
|
audio_source_id=d.get("audio_source_id"),
|
||||||
|
mode=d.get("mode"),
|
||||||
|
sensitivity=d.get("sensitivity"),
|
||||||
|
smoothing=d.get("smoothing"),
|
||||||
|
auto_gain=d.get("auto_gain"),
|
||||||
|
schedule=d.get("schedule"),
|
||||||
|
picture_source_id=d.get("picture_source_id"),
|
||||||
|
scene_behavior=d.get("scene_behavior"),
|
||||||
|
use_real_time=d.get("use_real_time"),
|
||||||
|
latitude=d.get("latitude"),
|
||||||
|
description=d.get("description"),
|
||||||
|
tags=d.get("tags", []),
|
||||||
|
created_at=source.created_at,
|
||||||
|
updated_at=source.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
|
||||||
|
async def list_value_sources(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
source_type: Optional[str] = Query(None, description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene"),
|
||||||
|
store: ValueSourceStore = Depends(get_value_source_store),
|
||||||
|
):
|
||||||
|
"""List all value sources, optionally filtered by type."""
|
||||||
|
sources = store.get_all_sources()
|
||||||
|
if source_type:
|
||||||
|
sources = [s for s in sources if s.source_type == source_type]
|
||||||
|
return ValueSourceListResponse(
|
||||||
|
sources=[_to_response(s) for s in sources],
|
||||||
|
count=len(sources),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/value-sources", response_model=ValueSourceResponse, status_code=201, tags=["Value Sources"])
|
||||||
|
async def create_value_source(
|
||||||
|
data: ValueSourceCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ValueSourceStore = Depends(get_value_source_store),
|
||||||
|
):
|
||||||
|
"""Create a new value source."""
|
||||||
|
try:
|
||||||
|
source = store.create_source(
|
||||||
|
name=data.name,
|
||||||
|
source_type=data.source_type,
|
||||||
|
value=data.value,
|
||||||
|
waveform=data.waveform,
|
||||||
|
speed=data.speed,
|
||||||
|
min_value=data.min_value,
|
||||||
|
max_value=data.max_value,
|
||||||
|
audio_source_id=data.audio_source_id,
|
||||||
|
mode=data.mode,
|
||||||
|
sensitivity=data.sensitivity,
|
||||||
|
smoothing=data.smoothing,
|
||||||
|
description=data.description,
|
||||||
|
schedule=data.schedule,
|
||||||
|
picture_source_id=data.picture_source_id,
|
||||||
|
scene_behavior=data.scene_behavior,
|
||||||
|
auto_gain=data.auto_gain,
|
||||||
|
use_real_time=data.use_real_time,
|
||||||
|
latitude=data.latitude,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
fire_entity_event("value_source", "created", source.id)
|
||||||
|
return _to_response(source)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
|
||||||
|
async def get_value_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ValueSourceStore = Depends(get_value_source_store),
|
||||||
|
):
|
||||||
|
"""Get a value source by ID."""
|
||||||
|
try:
|
||||||
|
source = store.get_source(source_id)
|
||||||
|
return _to_response(source)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
|
||||||
|
async def update_value_source(
|
||||||
|
source_id: str,
|
||||||
|
data: ValueSourceUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ValueSourceStore = Depends(get_value_source_store),
|
||||||
|
pm: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Update an existing value source."""
|
||||||
|
try:
|
||||||
|
source = store.update_source(
|
||||||
|
source_id=source_id,
|
||||||
|
name=data.name,
|
||||||
|
value=data.value,
|
||||||
|
waveform=data.waveform,
|
||||||
|
speed=data.speed,
|
||||||
|
min_value=data.min_value,
|
||||||
|
max_value=data.max_value,
|
||||||
|
audio_source_id=data.audio_source_id,
|
||||||
|
mode=data.mode,
|
||||||
|
sensitivity=data.sensitivity,
|
||||||
|
smoothing=data.smoothing,
|
||||||
|
description=data.description,
|
||||||
|
schedule=data.schedule,
|
||||||
|
picture_source_id=data.picture_source_id,
|
||||||
|
scene_behavior=data.scene_behavior,
|
||||||
|
auto_gain=data.auto_gain,
|
||||||
|
use_real_time=data.use_real_time,
|
||||||
|
latitude=data.latitude,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
# Hot-reload running value streams
|
||||||
|
pm.update_value_source(source_id)
|
||||||
|
fire_entity_event("value_source", "updated", source_id)
|
||||||
|
return _to_response(source)
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/value-sources/{source_id}", status_code=204, tags=["Value Sources"])
|
||||||
|
async def delete_value_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ValueSourceStore = Depends(get_value_source_store),
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
|
):
|
||||||
|
"""Delete a value source."""
|
||||||
|
try:
|
||||||
|
# Check if any targets reference this value source
|
||||||
|
from wled_controller.storage.wled_output_target import WledOutputTarget
|
||||||
|
for target in target_store.get_all_targets():
|
||||||
|
if isinstance(target, WledOutputTarget):
|
||||||
|
if getattr(target, "brightness_value_source_id", "") == source_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot delete: referenced by target '{target.name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
store.delete_source(source_id)
|
||||||
|
fire_entity_event("value_source", "deleted", 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))
|
||||||
|
|
||||||
|
|
||||||
|
# ===== REAL-TIME VALUE SOURCE TEST WEBSOCKET =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/value-sources/{source_id}/test/ws")
|
||||||
|
async def test_value_source_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
source_id: str,
|
||||||
|
token: str = Query(""),
|
||||||
|
):
|
||||||
|
"""WebSocket for real-time value source output. Auth via ?token=<api_key>.
|
||||||
|
|
||||||
|
Acquires a ValueStream for the given source, polls get_value() at ~20 Hz,
|
||||||
|
and streams {value: float} JSON to the client.
|
||||||
|
"""
|
||||||
|
from wled_controller.api.auth import verify_ws_token
|
||||||
|
if not verify_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate source exists
|
||||||
|
store = get_value_source_store()
|
||||||
|
try:
|
||||||
|
store.get_source(source_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Acquire a value stream
|
||||||
|
manager = get_processor_manager()
|
||||||
|
vsm = manager.value_stream_manager
|
||||||
|
if vsm is None:
|
||||||
|
await websocket.close(code=4003, reason="Value stream manager not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
stream = vsm.acquire(source_id)
|
||||||
|
except Exception as e:
|
||||||
|
await websocket.close(code=4003, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"Value source test WebSocket connected for {source_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
value = stream.get_value()
|
||||||
|
await websocket.send_json({"value": round(value, 4)})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Value source test WebSocket error for {source_id}: {e}")
|
||||||
|
finally:
|
||||||
|
vsm.release(source_id)
|
||||||
|
logger.info(f"Value source test WebSocket disconnected for {source_id}")
|
||||||
57
server/src/wled_controller/api/routes/webhooks.py
Normal file
57
server/src/wled_controller/api/routes/webhooks.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Webhook endpoint for automation triggers.
|
||||||
|
|
||||||
|
External services call POST /api/v1/webhooks/{token} with a JSON body
|
||||||
|
containing {"action": "activate"} or {"action": "deactivate"} to control
|
||||||
|
automations that have a webhook condition. No API-key auth is required —
|
||||||
|
the secret token itself authenticates the caller.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from wled_controller.api.dependencies import get_automation_engine, get_automation_store
|
||||||
|
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||||
|
from wled_controller.storage.automation import WebhookCondition
|
||||||
|
from wled_controller.storage.automation_store import AutomationStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookPayload(BaseModel):
|
||||||
|
action: str = Field(description="'activate' or 'deactivate'")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/webhooks/{token}",
|
||||||
|
tags=["Webhooks"],
|
||||||
|
)
|
||||||
|
async def handle_webhook(
|
||||||
|
token: str,
|
||||||
|
body: WebhookPayload,
|
||||||
|
store: AutomationStore = Depends(get_automation_store),
|
||||||
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
|
):
|
||||||
|
"""Receive a webhook call and set the corresponding condition state."""
|
||||||
|
if body.action not in ("activate", "deactivate"):
|
||||||
|
raise HTTPException(status_code=400, detail="action must be 'activate' or 'deactivate'")
|
||||||
|
|
||||||
|
# Find the automation that owns this token
|
||||||
|
for automation in store.get_all_automations():
|
||||||
|
for condition in automation.conditions:
|
||||||
|
if isinstance(condition, WebhookCondition) and condition.token == token:
|
||||||
|
active = body.action == "activate"
|
||||||
|
await engine.set_webhook_state(token, active)
|
||||||
|
logger.info(
|
||||||
|
"Webhook %s: automation '%s' (%s) → %s",
|
||||||
|
token[:8], automation.name, automation.id, body.action,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"automation_id": automation.id,
|
||||||
|
"automation_name": automation.name,
|
||||||
|
"action": body.action,
|
||||||
|
}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="Webhook token not found")
|
||||||
@@ -23,13 +23,18 @@ from .devices import (
|
|||||||
DeviceStateResponse,
|
DeviceStateResponse,
|
||||||
DeviceUpdate,
|
DeviceUpdate,
|
||||||
)
|
)
|
||||||
from .picture_targets import (
|
from .color_strip_sources import (
|
||||||
ColorCorrection,
|
ColorStripSourceCreate,
|
||||||
PictureTargetCreate,
|
ColorStripSourceListResponse,
|
||||||
PictureTargetListResponse,
|
ColorStripSourceResponse,
|
||||||
PictureTargetResponse,
|
ColorStripSourceUpdate,
|
||||||
PictureTargetUpdate,
|
CSSCalibrationTestRequest,
|
||||||
ProcessingSettings,
|
)
|
||||||
|
from .output_targets import (
|
||||||
|
OutputTargetCreate,
|
||||||
|
OutputTargetListResponse,
|
||||||
|
OutputTargetResponse,
|
||||||
|
OutputTargetUpdate,
|
||||||
TargetMetricsResponse,
|
TargetMetricsResponse,
|
||||||
TargetProcessingState,
|
TargetProcessingState,
|
||||||
)
|
)
|
||||||
@@ -90,12 +95,15 @@ __all__ = [
|
|||||||
"DeviceResponse",
|
"DeviceResponse",
|
||||||
"DeviceStateResponse",
|
"DeviceStateResponse",
|
||||||
"DeviceUpdate",
|
"DeviceUpdate",
|
||||||
"ColorCorrection",
|
"ColorStripSourceCreate",
|
||||||
"PictureTargetCreate",
|
"ColorStripSourceListResponse",
|
||||||
"PictureTargetListResponse",
|
"ColorStripSourceResponse",
|
||||||
"PictureTargetResponse",
|
"ColorStripSourceUpdate",
|
||||||
"PictureTargetUpdate",
|
"CSSCalibrationTestRequest",
|
||||||
"ProcessingSettings",
|
"OutputTargetCreate",
|
||||||
|
"OutputTargetListResponse",
|
||||||
|
"OutputTargetResponse",
|
||||||
|
"OutputTargetUpdate",
|
||||||
"TargetMetricsResponse",
|
"TargetMetricsResponse",
|
||||||
"TargetProcessingState",
|
"TargetProcessingState",
|
||||||
"EngineInfo",
|
"EngineInfo",
|
||||||
|
|||||||
59
server/src/wled_controller/api/schemas/audio_sources.py
Normal file
59
server/src/wled_controller/api/schemas/audio_sources.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Audio source schemas (CRUD)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSourceCreate(BaseModel):
|
||||||
|
"""Request to create an audio source."""
|
||||||
|
|
||||||
|
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||||
|
source_type: Literal["multichannel", "mono"] = Field(description="Source type")
|
||||||
|
# multichannel fields
|
||||||
|
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
|
||||||
|
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
|
||||||
|
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||||
|
# mono fields
|
||||||
|
audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID")
|
||||||
|
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSourceUpdate(BaseModel):
|
||||||
|
"""Request to update an audio source."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||||
|
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
|
||||||
|
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
|
||||||
|
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||||
|
audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID")
|
||||||
|
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSourceResponse(BaseModel):
|
||||||
|
"""Audio source response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Source ID")
|
||||||
|
name: str = Field(description="Source name")
|
||||||
|
source_type: str = Field(description="Source type: multichannel or mono")
|
||||||
|
device_index: Optional[int] = Field(None, description="Audio device index")
|
||||||
|
is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
|
||||||
|
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||||
|
audio_source_id: Optional[str] = Field(None, description="Parent multichannel source ID")
|
||||||
|
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||||
|
description: Optional[str] = Field(None, description="Description")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSourceListResponse(BaseModel):
|
||||||
|
"""List of audio sources."""
|
||||||
|
|
||||||
|
sources: List[AudioSourceResponse] = Field(description="List of audio sources")
|
||||||
|
count: int = Field(description="Number of sources")
|
||||||
62
server/src/wled_controller/api/schemas/audio_templates.py
Normal file
62
server/src/wled_controller/api/schemas/audio_templates.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Audio capture template and engine schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AudioTemplateCreate(BaseModel):
|
||||||
|
"""Request to create an audio capture template."""
|
||||||
|
|
||||||
|
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||||
|
engine_type: str = Field(description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1)
|
||||||
|
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
|
||||||
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
|
class AudioTemplateUpdate(BaseModel):
|
||||||
|
"""Request to update an audio template."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||||
|
engine_type: Optional[str] = Field(None, description="Audio engine type")
|
||||||
|
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
|
||||||
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AudioTemplateResponse(BaseModel):
|
||||||
|
"""Audio template information response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Template ID")
|
||||||
|
name: str = Field(description="Template name")
|
||||||
|
engine_type: str = Field(description="Engine type identifier")
|
||||||
|
engine_config: Dict = Field(description="Engine-specific configuration")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
description: Optional[str] = Field(None, description="Template description")
|
||||||
|
|
||||||
|
|
||||||
|
class AudioTemplateListResponse(BaseModel):
|
||||||
|
"""List of audio templates response."""
|
||||||
|
|
||||||
|
templates: List[AudioTemplateResponse] = Field(description="List of audio templates")
|
||||||
|
count: int = Field(description="Number of templates")
|
||||||
|
|
||||||
|
|
||||||
|
class AudioEngineInfo(BaseModel):
|
||||||
|
"""Audio capture engine information."""
|
||||||
|
|
||||||
|
type: str = Field(description="Engine type identifier (e.g., 'wasapi', 'sounddevice')")
|
||||||
|
name: str = Field(description="Human-readable engine name")
|
||||||
|
default_config: Dict = Field(description="Default configuration for this engine")
|
||||||
|
available: bool = Field(description="Whether engine is available on this system")
|
||||||
|
|
||||||
|
|
||||||
|
class AudioEngineListResponse(BaseModel):
|
||||||
|
"""List of audio engines response."""
|
||||||
|
|
||||||
|
engines: List[AudioEngineInfo] = Field(description="Available audio engines")
|
||||||
|
count: int = Field(description="Number of engines")
|
||||||
82
server/src/wled_controller/api/schemas/automations.py
Normal file
82
server/src/wled_controller/api/schemas/automations.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Automation-related schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ConditionSchema(BaseModel):
|
||||||
|
"""A single condition within an automation."""
|
||||||
|
|
||||||
|
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
||||||
|
# Application condition fields
|
||||||
|
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
|
||||||
|
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
|
||||||
|
# Time-of-day condition fields
|
||||||
|
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day condition)")
|
||||||
|
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
|
||||||
|
# System idle condition fields
|
||||||
|
idle_minutes: Optional[int] = Field(None, description="Idle timeout in minutes (for system_idle condition)")
|
||||||
|
when_idle: Optional[bool] = Field(None, description="True=active when idle (for system_idle condition)")
|
||||||
|
# Display state condition fields
|
||||||
|
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
|
||||||
|
# MQTT condition fields
|
||||||
|
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
|
||||||
|
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
|
||||||
|
match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)")
|
||||||
|
# Webhook condition fields
|
||||||
|
token: Optional[str] = Field(None, description="Secret token for webhook URL (for webhook condition)")
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationCreate(BaseModel):
|
||||||
|
"""Request to create an automation."""
|
||||||
|
|
||||||
|
name: str = Field(description="Automation name", min_length=1, max_length=100)
|
||||||
|
enabled: bool = Field(default=True, description="Whether the automation is enabled")
|
||||||
|
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
|
||||||
|
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
|
||||||
|
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||||
|
deactivation_mode: str = Field(default="none", description="'none', 'revert', or 'fallback_scene'")
|
||||||
|
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationUpdate(BaseModel):
|
||||||
|
"""Request to update an automation."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
|
||||||
|
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
|
||||||
|
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
|
||||||
|
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
|
||||||
|
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||||
|
deactivation_mode: Optional[str] = Field(None, description="'none', 'revert', or 'fallback_scene'")
|
||||||
|
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationResponse(BaseModel):
|
||||||
|
"""Automation information response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Automation ID")
|
||||||
|
name: str = Field(description="Automation name")
|
||||||
|
enabled: bool = Field(description="Whether the automation is enabled")
|
||||||
|
condition_logic: str = Field(description="Condition combination logic")
|
||||||
|
conditions: List[ConditionSchema] = Field(description="List of conditions")
|
||||||
|
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||||
|
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
||||||
|
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
webhook_url: Optional[str] = Field(None, description="Webhook URL for the first webhook condition (if any)")
|
||||||
|
is_active: bool = Field(default=False, description="Whether the automation is currently active")
|
||||||
|
last_activated_at: Optional[datetime] = Field(None, description="Last time this automation was activated")
|
||||||
|
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this automation was deactivated")
|
||||||
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationListResponse(BaseModel):
|
||||||
|
"""List of automations response."""
|
||||||
|
|
||||||
|
automations: List[AutomationResponse] = Field(description="List of automations")
|
||||||
|
count: int = Field(description="Number of automations")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Color strip processing template schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .filters import FilterInstanceSchema
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripProcessingTemplateCreate(BaseModel):
|
||||||
|
"""Request to create a color strip processing template."""
|
||||||
|
|
||||||
|
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||||
|
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
|
||||||
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripProcessingTemplateUpdate(BaseModel):
|
||||||
|
"""Request to update a color strip processing template."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||||
|
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
|
||||||
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripProcessingTemplateResponse(BaseModel):
|
||||||
|
"""Color strip processing template information response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Template ID")
|
||||||
|
name: str = Field(description="Template name")
|
||||||
|
filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
description: Optional[str] = Field(None, description="Template description")
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripProcessingTemplateListResponse(BaseModel):
|
||||||
|
"""List of color strip processing templates response."""
|
||||||
|
|
||||||
|
templates: List[ColorStripProcessingTemplateResponse] = Field(description="List of templates")
|
||||||
|
count: int = Field(description="Number of templates")
|
||||||
307
server/src/wled_controller/api/schemas/color_strip_sources.py
Normal file
307
server/src/wled_controller/api/schemas/color_strip_sources.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""Color strip source schemas (CRUD)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from wled_controller.api.schemas.devices import Calibration
|
||||||
|
|
||||||
|
|
||||||
|
class AnimationConfig(BaseModel):
|
||||||
|
"""Procedural animation configuration for static/gradient color strip sources."""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
type: str = "breathing" # breathing | color_cycle | gradient_shift | wave
|
||||||
|
speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1–10.0)")
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStop(BaseModel):
|
||||||
|
"""A single color stop in a gradient."""
|
||||||
|
|
||||||
|
position: float = Field(description="Relative position along the strip (0.0–1.0)", ge=0.0, le=1.0)
|
||||||
|
color: List[int] = Field(description="Primary RGB color [R, G, B] (0–255 each)")
|
||||||
|
color_right: Optional[List[int]] = Field(
|
||||||
|
None,
|
||||||
|
description="Optional right-side RGB color for a hard edge (bidirectional stop)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CompositeLayer(BaseModel):
|
||||||
|
"""A single layer in a composite color strip source."""
|
||||||
|
|
||||||
|
source_id: str = Field(description="ID of the layer's color strip source")
|
||||||
|
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen|override")
|
||||||
|
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
|
||||||
|
enabled: bool = Field(default=True, description="Whether this layer is active")
|
||||||
|
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
|
||||||
|
processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID")
|
||||||
|
|
||||||
|
|
||||||
|
class MappedZone(BaseModel):
|
||||||
|
"""A single zone in a mapped color strip source."""
|
||||||
|
|
||||||
|
source_id: str = Field(description="ID of the zone's color strip source")
|
||||||
|
start: int = Field(default=0, ge=0, description="First LED index (0-based)")
|
||||||
|
end: int = Field(default=0, ge=0, description="Last LED index (exclusive); 0 = auto-fill")
|
||||||
|
reverse: bool = Field(default=False, description="Reverse zone output")
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripSourceCreate(BaseModel):
|
||||||
|
"""Request to create a color strip source."""
|
||||||
|
|
||||||
|
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||||
|
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed"] = Field(default="picture", description="Source type")
|
||||||
|
# picture-type fields
|
||||||
|
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
||||||
|
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
|
||||||
|
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
|
||||||
|
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
|
||||||
|
# static-type fields
|
||||||
|
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
|
||||||
|
# gradient-type fields
|
||||||
|
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
|
||||||
|
# color_cycle-type fields
|
||||||
|
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
|
||||||
|
# effect-type fields
|
||||||
|
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
|
||||||
|
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice)")
|
||||||
|
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
|
||||||
|
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
|
||||||
|
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor)")
|
||||||
|
# composite-type fields
|
||||||
|
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
|
||||||
|
# mapped-type fields
|
||||||
|
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
|
||||||
|
# audio-type fields
|
||||||
|
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
|
||||||
|
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
|
||||||
|
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
|
||||||
|
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
|
||||||
|
# shared
|
||||||
|
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
|
||||||
|
# api_input-type fields
|
||||||
|
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
|
||||||
|
timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0)
|
||||||
|
# notification-type fields
|
||||||
|
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
|
||||||
|
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
|
||||||
|
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB) for notifications")
|
||||||
|
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color (#RRGGBB)")
|
||||||
|
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
||||||
|
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
||||||
|
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||||
|
# daylight-type fields
|
||||||
|
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
||||||
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||||
|
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||||
|
# candlelight-type fields
|
||||||
|
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||||
|
# processed-type fields
|
||||||
|
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
|
||||||
|
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
|
||||||
|
# sync clock
|
||||||
|
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripSourceUpdate(BaseModel):
|
||||||
|
"""Request to update a color strip source."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||||
|
# picture-type fields
|
||||||
|
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||||
|
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)")
|
||||||
|
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||||
|
# static-type fields
|
||||||
|
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
|
||||||
|
# gradient-type fields
|
||||||
|
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
|
||||||
|
# color_cycle-type fields
|
||||||
|
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
|
||||||
|
# effect-type fields
|
||||||
|
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
|
||||||
|
palette: Optional[str] = Field(None, description="Named palette")
|
||||||
|
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
|
||||||
|
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
|
||||||
|
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
|
||||||
|
# composite-type fields
|
||||||
|
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
|
||||||
|
# mapped-type fields
|
||||||
|
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
|
||||||
|
# audio-type fields
|
||||||
|
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
|
||||||
|
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
|
||||||
|
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
|
||||||
|
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
|
||||||
|
# shared
|
||||||
|
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
|
||||||
|
# api_input-type fields
|
||||||
|
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
||||||
|
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0)
|
||||||
|
# notification-type fields
|
||||||
|
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
|
||||||
|
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
|
||||||
|
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)")
|
||||||
|
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color")
|
||||||
|
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
||||||
|
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
||||||
|
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||||
|
# daylight-type fields
|
||||||
|
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
||||||
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||||
|
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||||
|
# candlelight-type fields
|
||||||
|
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||||
|
# processed-type fields
|
||||||
|
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
|
||||||
|
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
|
||||||
|
# sync clock
|
||||||
|
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripSourceResponse(BaseModel):
|
||||||
|
"""Color strip source response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Source ID")
|
||||||
|
name: str = Field(description="Source name")
|
||||||
|
source_type: str = Field(description="Source type")
|
||||||
|
# picture-type fields
|
||||||
|
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||||
|
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
|
||||||
|
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
|
||||||
|
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||||
|
# static-type fields
|
||||||
|
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B]")
|
||||||
|
# gradient-type fields
|
||||||
|
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
|
||||||
|
# color_cycle-type fields
|
||||||
|
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
|
||||||
|
# effect-type fields
|
||||||
|
effect_type: Optional[str] = Field(None, description="Effect algorithm")
|
||||||
|
palette: Optional[str] = Field(None, description="Named palette")
|
||||||
|
intensity: Optional[float] = Field(None, description="Effect intensity")
|
||||||
|
scale: Optional[float] = Field(None, description="Spatial scale")
|
||||||
|
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
|
||||||
|
# composite-type fields
|
||||||
|
layers: Optional[List[dict]] = Field(None, description="Layers for composite type")
|
||||||
|
# mapped-type fields
|
||||||
|
zones: Optional[List[dict]] = Field(None, description="Zones for mapped type")
|
||||||
|
# audio-type fields
|
||||||
|
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
|
||||||
|
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||||
|
sensitivity: Optional[float] = Field(None, description="Audio sensitivity")
|
||||||
|
color_peak: Optional[List[int]] = Field(None, description="Peak color [R,G,B]")
|
||||||
|
# shared
|
||||||
|
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
||||||
|
description: Optional[str] = Field(None, description="Description")
|
||||||
|
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
|
||||||
|
# api_input-type fields
|
||||||
|
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
||||||
|
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)")
|
||||||
|
# notification-type fields
|
||||||
|
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
|
||||||
|
duration_ms: Optional[int] = Field(None, description="Effect duration in milliseconds")
|
||||||
|
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)")
|
||||||
|
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color")
|
||||||
|
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
||||||
|
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
||||||
|
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||||
|
# daylight-type fields
|
||||||
|
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
|
||||||
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||||
|
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
|
||||||
|
# candlelight-type fields
|
||||||
|
num_candles: Optional[int] = Field(None, description="Number of independent candle sources")
|
||||||
|
# processed-type fields
|
||||||
|
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
|
||||||
|
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID")
|
||||||
|
# sync clock
|
||||||
|
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
|
||||||
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripSourceListResponse(BaseModel):
|
||||||
|
"""List of color strip sources."""
|
||||||
|
|
||||||
|
sources: List[ColorStripSourceResponse] = Field(description="List of color strip sources")
|
||||||
|
count: int = Field(description="Number of sources")
|
||||||
|
|
||||||
|
|
||||||
|
class SegmentPayload(BaseModel):
|
||||||
|
"""A single segment for segment-based LED color updates."""
|
||||||
|
|
||||||
|
start: int = Field(ge=0, description="Starting LED index")
|
||||||
|
length: int = Field(ge=1, description="Number of LEDs in segment")
|
||||||
|
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
|
||||||
|
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
|
||||||
|
colors: Optional[List[List[int]]] = Field(None, description="Colors for per_pixel/gradient [[R,G,B],...]")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_mode_fields(self) -> "SegmentPayload":
|
||||||
|
if self.mode == "solid":
|
||||||
|
if self.color is None or len(self.color) != 3:
|
||||||
|
raise ValueError("solid mode requires 'color' as a list of 3 ints [R,G,B]")
|
||||||
|
if not all(0 <= c <= 255 for c in self.color):
|
||||||
|
raise ValueError("solid color values must be 0-255")
|
||||||
|
elif self.mode == "per_pixel":
|
||||||
|
if not self.colors:
|
||||||
|
raise ValueError("per_pixel mode requires non-empty 'colors' list")
|
||||||
|
for c in self.colors:
|
||||||
|
if len(c) != 3:
|
||||||
|
raise ValueError("each color in per_pixel must be [R,G,B]")
|
||||||
|
elif self.mode == "gradient":
|
||||||
|
if not self.colors or len(self.colors) < 2:
|
||||||
|
raise ValueError("gradient mode requires 'colors' with at least 2 stops")
|
||||||
|
for c in self.colors:
|
||||||
|
if len(c) != 3:
|
||||||
|
raise ValueError("each color stop in gradient must be [R,G,B]")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ColorPushRequest(BaseModel):
|
||||||
|
"""Request to push raw LED colors to an api_input source.
|
||||||
|
|
||||||
|
Accepts either 'colors' (legacy flat array) or 'segments' (new segment-based).
|
||||||
|
At least one must be provided.
|
||||||
|
"""
|
||||||
|
|
||||||
|
colors: Optional[List[List[int]]] = Field(None, description="LED color array [[R,G,B], ...] (0-255 each)")
|
||||||
|
segments: Optional[List[SegmentPayload]] = Field(None, description="Segment-based color updates")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _require_colors_or_segments(self) -> "ColorPushRequest":
|
||||||
|
if self.colors is None and self.segments is None:
|
||||||
|
raise ValueError("Either 'colors' or 'segments' must be provided")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyRequest(BaseModel):
|
||||||
|
"""Request to trigger a notification on a notification color strip source."""
|
||||||
|
|
||||||
|
app: Optional[str] = Field(None, description="App name for color lookup")
|
||||||
|
color: Optional[str] = Field(None, description="Hex color override (#RRGGBB)")
|
||||||
|
|
||||||
|
|
||||||
|
class CSSCalibrationTestRequest(BaseModel):
|
||||||
|
"""Request to run a calibration test for a color strip source on a specific device."""
|
||||||
|
|
||||||
|
device_id: str = Field(description="Device ID to send test pixels to")
|
||||||
|
edges: dict = Field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"top": [255, 0, 0],
|
||||||
|
"right": [0, 255, 0],
|
||||||
|
"bottom": [0, 100, 255],
|
||||||
|
"left": [255, 255, 0],
|
||||||
|
},
|
||||||
|
description="Map of edge names to RGB colors. Empty dict = exit test mode.",
|
||||||
|
)
|
||||||
@@ -15,6 +15,29 @@ class DeviceCreate(BaseModel):
|
|||||||
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)")
|
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)")
|
||||||
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
|
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
|
||||||
auto_shutdown: Optional[bool] = Field(default=None, description="Turn off device when server stops (defaults to true for adalight)")
|
auto_shutdown: Optional[bool] = Field(default=None, description="Turn off device when server stops (defaults to true for adalight)")
|
||||||
|
send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)")
|
||||||
|
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
|
||||||
|
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
# DMX (Art-Net / sACN) fields
|
||||||
|
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
|
||||||
|
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
|
||||||
|
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
|
||||||
|
# ESP-NOW fields
|
||||||
|
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)")
|
||||||
|
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)")
|
||||||
|
# Philips Hue fields
|
||||||
|
hue_username: Optional[str] = Field(None, description="Hue bridge username (from pairing)")
|
||||||
|
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key (hex)")
|
||||||
|
hue_entertainment_group_id: Optional[str] = Field(None, description="Hue entertainment group/zone ID")
|
||||||
|
# SPI Direct fields
|
||||||
|
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed in Hz")
|
||||||
|
spi_led_type: Optional[str] = Field(None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW")
|
||||||
|
# Razer Chroma fields
|
||||||
|
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad")
|
||||||
|
# SteelSeries GameSense fields
|
||||||
|
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator")
|
||||||
|
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
|
||||||
|
|
||||||
|
|
||||||
class DeviceUpdate(BaseModel):
|
class DeviceUpdate(BaseModel):
|
||||||
@@ -26,20 +49,50 @@ class DeviceUpdate(BaseModel):
|
|||||||
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (for devices with manual_led_count capability)")
|
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (for devices with manual_led_count capability)")
|
||||||
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
|
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
|
||||||
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops")
|
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops")
|
||||||
|
send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)")
|
||||||
|
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
|
||||||
|
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
|
||||||
|
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
|
||||||
|
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
|
||||||
|
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address")
|
||||||
|
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
|
||||||
|
hue_username: Optional[str] = Field(None, description="Hue bridge username")
|
||||||
|
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key")
|
||||||
|
hue_entertainment_group_id: Optional[str] = Field(None, description="Hue entertainment group ID")
|
||||||
|
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
|
||||||
|
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
|
||||||
|
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
|
||||||
|
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
|
||||||
|
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
|
||||||
|
|
||||||
|
|
||||||
class StaticColorUpdate(BaseModel):
|
class CalibrationLineSchema(BaseModel):
|
||||||
"""Request to set or clear the static idle color."""
|
"""One LED line in advanced calibration."""
|
||||||
|
|
||||||
color: Optional[List[int]] = Field(
|
picture_source_id: str = Field(description="Picture source (monitor) to sample from")
|
||||||
None,
|
edge: Literal["top", "right", "bottom", "left"] = Field(description="Screen edge to sample")
|
||||||
description="RGB color [R, G, B] with values 0-255, or null to clear",
|
led_count: int = Field(ge=1, description="Number of LEDs in this line")
|
||||||
)
|
span_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start fraction along edge")
|
||||||
|
span_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End fraction along edge")
|
||||||
|
reverse: bool = Field(default=False, description="Reverse LED direction")
|
||||||
|
border_width: int = Field(default=10, ge=1, le=100, description="Sampling depth in pixels")
|
||||||
|
|
||||||
|
|
||||||
class Calibration(BaseModel):
|
class Calibration(BaseModel):
|
||||||
"""Calibration configuration for pixel-to-LED mapping."""
|
"""Calibration configuration for pixel-to-LED mapping."""
|
||||||
|
|
||||||
|
mode: Literal["simple", "advanced"] = Field(
|
||||||
|
default="simple",
|
||||||
|
description="Calibration mode: simple (4-edge) or advanced (multi-source lines)"
|
||||||
|
)
|
||||||
|
# Advanced mode: ordered list of lines
|
||||||
|
lines: Optional[List[CalibrationLineSchema]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Line list for advanced mode (ignored in simple mode)"
|
||||||
|
)
|
||||||
|
# Simple mode fields
|
||||||
layout: Literal["clockwise", "counterclockwise"] = Field(
|
layout: Literal["clockwise", "counterclockwise"] = Field(
|
||||||
default="clockwise",
|
default="clockwise",
|
||||||
description="LED strip layout direction"
|
description="LED strip layout direction"
|
||||||
@@ -102,9 +155,24 @@ class DeviceResponse(BaseModel):
|
|||||||
enabled: bool = Field(description="Whether device is enabled")
|
enabled: bool = Field(description="Whether device is enabled")
|
||||||
baud_rate: Optional[int] = Field(None, description="Serial baud rate")
|
baud_rate: Optional[int] = Field(None, description="Serial baud rate")
|
||||||
auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop")
|
auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop")
|
||||||
static_color: Optional[List[int]] = Field(None, description="Static idle color [R, G, B]")
|
send_latency_ms: int = Field(default=0, description="Simulated send latency in ms (mock devices)")
|
||||||
|
rgbw: bool = Field(default=False, description="RGBW mode (mock devices)")
|
||||||
|
zone_mode: str = Field(default="combined", description="OpenRGB zone mode: combined or separate")
|
||||||
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
||||||
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
|
||||||
|
dmx_start_universe: int = Field(default=0, description="DMX start universe")
|
||||||
|
dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)")
|
||||||
|
espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address")
|
||||||
|
espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel")
|
||||||
|
hue_username: str = Field(default="", description="Hue bridge username")
|
||||||
|
hue_client_key: str = Field(default="", description="Hue entertainment client key")
|
||||||
|
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
|
||||||
|
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
|
||||||
|
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||||
|
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||||
|
gamesense_device_type: str = Field(default="keyboard", description="GameSense device type")
|
||||||
|
default_css_processing_template_id: str = Field(default="", description="Default color strip processing template ID")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
@@ -154,3 +222,18 @@ class DiscoverDevicesResponse(BaseModel):
|
|||||||
devices: List[DiscoveredDeviceResponse] = Field(description="Discovered devices")
|
devices: List[DiscoveredDeviceResponse] = Field(description="Discovered devices")
|
||||||
count: int = Field(description="Total devices found")
|
count: int = Field(description="Total devices found")
|
||||||
scan_duration_ms: float = Field(description="How long the scan took in milliseconds")
|
scan_duration_ms: float = Field(description="How long the scan took in milliseconds")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRGBZoneResponse(BaseModel):
|
||||||
|
"""A single zone on an OpenRGB device."""
|
||||||
|
|
||||||
|
name: str = Field(description="Zone name (e.g. JRAINBOW2)")
|
||||||
|
led_count: int = Field(description="Number of LEDs in this zone")
|
||||||
|
zone_type: str = Field(description="Zone type (linear, single, matrix)")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRGBZonesResponse(BaseModel):
|
||||||
|
"""Response from OpenRGB zone listing."""
|
||||||
|
|
||||||
|
device_name: str = Field(description="OpenRGB device name")
|
||||||
|
zones: List[OpenRGBZoneResponse] = Field(description="Available zones")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Filter-related schemas."""
|
"""Filter-related schemas."""
|
||||||
|
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -17,11 +17,13 @@ class FilterOptionDefSchema(BaseModel):
|
|||||||
|
|
||||||
key: str = Field(description="Option key")
|
key: str = Field(description="Option key")
|
||||||
label: str = Field(description="Display label")
|
label: str = Field(description="Display label")
|
||||||
type: str = Field(description="Option type (float or int)")
|
type: str = Field(description="Option type (float, int, bool, select, or string)")
|
||||||
default: Any = Field(description="Default value")
|
default: Any = Field(description="Default value")
|
||||||
min_value: Any = Field(description="Minimum value")
|
min_value: Any = Field(description="Minimum value")
|
||||||
max_value: Any = Field(description="Maximum value")
|
max_value: Any = Field(description="Maximum value")
|
||||||
step: Any = Field(description="Step increment")
|
step: Any = Field(description="Step increment")
|
||||||
|
choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type")
|
||||||
|
max_length: Optional[int] = Field(default=None, description="Maximum string length for string type")
|
||||||
|
|
||||||
|
|
||||||
class FilterTypeResponse(BaseModel):
|
class FilterTypeResponse(BaseModel):
|
||||||
|
|||||||
@@ -1,38 +1,11 @@
|
|||||||
"""Picture target schemas (CRUD, processing state, settings, metrics)."""
|
"""Output target schemas (CRUD, processing state, metrics)."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Literal, Optional
|
from typing import Dict, Optional, List
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
|
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
||||||
|
|
||||||
|
|
||||||
class ColorCorrection(BaseModel):
|
|
||||||
"""Color correction settings."""
|
|
||||||
|
|
||||||
gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0)
|
|
||||||
saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0)
|
|
||||||
brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0)
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessingSettings(BaseModel):
|
|
||||||
"""Processing settings for a picture target."""
|
|
||||||
|
|
||||||
display_index: int = Field(default=0, description="Display to capture", ge=0)
|
|
||||||
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
|
|
||||||
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
|
|
||||||
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
|
|
||||||
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
|
|
||||||
standby_interval: float = Field(default=1.0, description="Seconds between keepalive sends when screen is static (0.5-5.0)", ge=0.5, le=5.0)
|
|
||||||
state_check_interval: int = Field(
|
|
||||||
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
|
|
||||||
description="Seconds between WLED health checks"
|
|
||||||
)
|
|
||||||
color_correction: Optional[ColorCorrection] = Field(
|
|
||||||
default_factory=ColorCorrection,
|
|
||||||
description="Color correction settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class KeyColorRectangleSchema(BaseModel):
|
class KeyColorRectangleSchema(BaseModel):
|
||||||
@@ -53,6 +26,7 @@ class KeyColorsSettingsSchema(BaseModel):
|
|||||||
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
pattern_template_id: str = Field(default="", description="Pattern template ID for rectangle layout")
|
pattern_template_id: str = Field(default="", description="Pattern template ID for rectangle layout")
|
||||||
brightness: float = Field(default=1.0, description="Output brightness (0.0-1.0)", ge=0.0, le=1.0)
|
brightness: float = Field(default=1.0, description="Output brightness (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||||
|
|
||||||
|
|
||||||
class ExtractedColorResponse(BaseModel):
|
class ExtractedColorResponse(BaseModel):
|
||||||
@@ -72,71 +46,105 @@ class KeyColorsResponse(BaseModel):
|
|||||||
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
|
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
|
||||||
|
|
||||||
|
|
||||||
class PictureTargetCreate(BaseModel):
|
class OutputTargetCreate(BaseModel):
|
||||||
"""Request to create a picture target."""
|
"""Request to create an output target."""
|
||||||
|
|
||||||
name: str = Field(description="Target name", min_length=1, max_length=100)
|
name: str = Field(description="Target name", min_length=1, max_length=100)
|
||||||
target_type: str = Field(default="led", description="Target type (led, key_colors)")
|
target_type: str = Field(default="led", description="Target type (led, key_colors)")
|
||||||
|
# LED target fields
|
||||||
device_id: str = Field(default="", description="LED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
|
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||||
|
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
||||||
|
keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
|
||||||
|
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
|
||||||
|
min_brightness_threshold: int = Field(default=0, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
|
||||||
|
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
|
||||||
|
protocol: str = Field(default="ddp", pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)")
|
||||||
|
# KC target fields
|
||||||
|
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
class PictureTargetUpdate(BaseModel):
|
class OutputTargetUpdate(BaseModel):
|
||||||
"""Request to update a picture target."""
|
"""Request to update an output target."""
|
||||||
|
|
||||||
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
|
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
|
||||||
device_id: Optional[str] = Field(None, description="WLED device ID")
|
# LED target fields
|
||||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||||
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
|
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||||
|
brightness_value_source_id: Optional[str] = Field(None, description="Brightness value source ID")
|
||||||
|
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
||||||
|
keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
|
||||||
|
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
|
||||||
|
min_brightness_threshold: Optional[int] = Field(None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
|
||||||
|
adaptive_fps: Optional[bool] = Field(None, description="Auto-reduce FPS when device is unresponsive")
|
||||||
|
protocol: Optional[str] = Field(None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)")
|
||||||
|
# KC target fields
|
||||||
|
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class PictureTargetResponse(BaseModel):
|
class OutputTargetResponse(BaseModel):
|
||||||
"""Picture target response."""
|
"""Output target response."""
|
||||||
|
|
||||||
id: str = Field(description="Target ID")
|
id: str = Field(description="Target ID")
|
||||||
name: str = Field(description="Target name")
|
name: str = Field(description="Target name")
|
||||||
target_type: str = Field(description="Target type")
|
target_type: str = Field(description="Target type")
|
||||||
device_id: str = Field(default="", description="WLED device ID")
|
# LED target fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (wled)")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (key_colors)")
|
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||||
|
fps: Optional[int] = Field(None, description="Target send FPS")
|
||||||
|
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
||||||
|
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
|
||||||
|
min_brightness_threshold: int = Field(default=0, description="Min brightness threshold (0=disabled)")
|
||||||
|
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
|
||||||
|
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
|
||||||
|
# KC target fields
|
||||||
|
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
||||||
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
|
||||||
description: Optional[str] = Field(None, description="Description")
|
description: Optional[str] = Field(None, description="Description")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|
||||||
class PictureTargetListResponse(BaseModel):
|
class OutputTargetListResponse(BaseModel):
|
||||||
"""List of picture targets response."""
|
"""List of output targets response."""
|
||||||
|
|
||||||
targets: List[PictureTargetResponse] = Field(description="List of picture targets")
|
targets: List[OutputTargetResponse] = Field(description="List of output targets")
|
||||||
count: int = Field(description="Number of targets")
|
count: int = Field(description="Number of targets")
|
||||||
|
|
||||||
|
|
||||||
class TargetProcessingState(BaseModel):
|
class TargetProcessingState(BaseModel):
|
||||||
"""Processing state for a picture target."""
|
"""Processing state for an output target."""
|
||||||
|
|
||||||
target_id: str = Field(description="Target ID")
|
target_id: str = Field(description="Target ID")
|
||||||
device_id: Optional[str] = Field(None, description="Device ID")
|
device_id: Optional[str] = Field(None, description="Device ID")
|
||||||
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
processing: bool = Field(description="Whether processing is active")
|
processing: bool = Field(description="Whether processing is active")
|
||||||
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
|
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
|
||||||
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
|
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
|
||||||
fps_target: int = Field(default=0, description="Target FPS")
|
fps_target: Optional[int] = Field(None, description="Target FPS")
|
||||||
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
||||||
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
|
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
|
||||||
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
|
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
|
||||||
timing_extract_ms: Optional[float] = Field(None, description="Border extraction time (ms)")
|
|
||||||
timing_map_leds_ms: Optional[float] = Field(None, description="LED mapping time (ms)")
|
|
||||||
timing_smooth_ms: Optional[float] = Field(None, description="Smoothing time (ms)")
|
|
||||||
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
||||||
timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)")
|
timing_extract_ms: Optional[float] = Field(None, description="Border pixel extraction time (ms)")
|
||||||
|
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
|
||||||
|
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
|
||||||
|
timing_total_ms: Optional[float] = Field(None, description="Total processing time per frame (ms)")
|
||||||
|
timing_audio_read_ms: Optional[float] = Field(None, description="Audio device read time (ms)")
|
||||||
|
timing_audio_fft_ms: Optional[float] = Field(None, description="Audio FFT analysis time (ms)")
|
||||||
|
timing_audio_render_ms: Optional[float] = Field(None, description="Audio visualization render time (ms)")
|
||||||
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
|
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
|
||||||
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
|
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
|
||||||
display_index: int = Field(default=0, description="Current display index")
|
display_index: Optional[int] = Field(None, description="Current display index")
|
||||||
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
|
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
|
||||||
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
||||||
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
||||||
@@ -150,6 +158,8 @@ class TargetProcessingState(BaseModel):
|
|||||||
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
|
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
|
||||||
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
||||||
device_error: Optional[str] = Field(None, description="Last health check error")
|
device_error: Optional[str] = Field(None, description="Last health check error")
|
||||||
|
device_streaming_reachable: Optional[bool] = Field(None, description="Device reachable during streaming (HTTP probe)")
|
||||||
|
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
|
||||||
|
|
||||||
|
|
||||||
class TargetMetricsResponse(BaseModel):
|
class TargetMetricsResponse(BaseModel):
|
||||||
@@ -159,7 +169,7 @@ class TargetMetricsResponse(BaseModel):
|
|||||||
device_id: Optional[str] = Field(None, description="Device ID")
|
device_id: Optional[str] = Field(None, description="Device ID")
|
||||||
processing: bool = Field(description="Whether processing is active")
|
processing: bool = Field(description="Whether processing is active")
|
||||||
fps_actual: Optional[float] = Field(None, description="Actual FPS")
|
fps_actual: Optional[float] = Field(None, description="Actual FPS")
|
||||||
fps_target: int = Field(description="Target FPS")
|
fps_target: Optional[int] = Field(None, description="Target FPS")
|
||||||
uptime_seconds: float = Field(description="Processing uptime in seconds")
|
uptime_seconds: float = Field(description="Processing uptime in seconds")
|
||||||
frames_processed: int = Field(description="Total frames processed")
|
frames_processed: int = Field(description="Total frames processed")
|
||||||
errors_count: int = Field(description="Total error count")
|
errors_count: int = Field(description="Total error count")
|
||||||
@@ -167,6 +177,20 @@ class TargetMetricsResponse(BaseModel):
|
|||||||
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
|
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class BulkTargetRequest(BaseModel):
|
||||||
|
"""Request body for bulk start/stop operations."""
|
||||||
|
|
||||||
|
ids: List[str] = Field(description="List of target IDs to operate on")
|
||||||
|
|
||||||
|
|
||||||
|
class BulkTargetResponse(BaseModel):
|
||||||
|
"""Response for bulk start/stop operations."""
|
||||||
|
|
||||||
|
started: List[str] = Field(default_factory=list, description="IDs that were successfully started")
|
||||||
|
stopped: List[str] = Field(default_factory=list, description="IDs that were successfully stopped")
|
||||||
|
errors: Dict[str, str] = Field(default_factory=dict, description="Map of target ID to error message for failures")
|
||||||
|
|
||||||
|
|
||||||
class KCTestRectangleResponse(BaseModel):
|
class KCTestRectangleResponse(BaseModel):
|
||||||
"""A rectangle with its extracted color from a KC test."""
|
"""A rectangle with its extracted color from a KC test."""
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .picture_targets import KeyColorRectangleSchema
|
from .output_targets import KeyColorRectangleSchema
|
||||||
|
|
||||||
|
|
||||||
class PatternTemplateCreate(BaseModel):
|
class PatternTemplateCreate(BaseModel):
|
||||||
@@ -14,6 +14,7 @@ class PatternTemplateCreate(BaseModel):
|
|||||||
name: str = Field(description="Template name", min_length=1, max_length=100)
|
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||||
rectangles: List[KeyColorRectangleSchema] = Field(default_factory=list, description="List of named rectangles")
|
rectangles: List[KeyColorRectangleSchema] = Field(default_factory=list, description="List of named rectangles")
|
||||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
class PatternTemplateUpdate(BaseModel):
|
class PatternTemplateUpdate(BaseModel):
|
||||||
@@ -22,6 +23,7 @@ class PatternTemplateUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||||
rectangles: Optional[List[KeyColorRectangleSchema]] = Field(None, description="List of named rectangles")
|
rectangles: Optional[List[KeyColorRectangleSchema]] = Field(None, description="List of named rectangles")
|
||||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class PatternTemplateResponse(BaseModel):
|
class PatternTemplateResponse(BaseModel):
|
||||||
@@ -30,6 +32,7 @@ class PatternTemplateResponse(BaseModel):
|
|||||||
id: str = Field(description="Template ID")
|
id: str = Field(description="Template ID")
|
||||||
name: str = Field(description="Template name")
|
name: str = Field(description="Template name")
|
||||||
rectangles: List[KeyColorRectangleSchema] = Field(description="List of named rectangles")
|
rectangles: List[KeyColorRectangleSchema] = Field(description="List of named rectangles")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
description: Optional[str] = Field(None, description="Template description")
|
description: Optional[str] = Field(None, description="Template description")
|
||||||
|
|||||||
@@ -10,14 +10,23 @@ class PictureSourceCreate(BaseModel):
|
|||||||
"""Request to create a picture source."""
|
"""Request to create a picture source."""
|
||||||
|
|
||||||
name: str = Field(description="Stream name", min_length=1, max_length=100)
|
name: str = Field(description="Stream name", min_length=1, max_length=100)
|
||||||
stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type")
|
stream_type: Literal["raw", "processed", "static_image", "video"] = Field(description="Stream type")
|
||||||
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
||||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
||||||
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90)
|
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
||||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
||||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
# Video fields
|
||||||
|
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL")
|
||||||
|
loop: bool = Field(True, description="Loop video playback")
|
||||||
|
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
|
||||||
|
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
|
||||||
|
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
|
||||||
|
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
|
||||||
|
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
|
||||||
|
|
||||||
|
|
||||||
class PictureSourceUpdate(BaseModel):
|
class PictureSourceUpdate(BaseModel):
|
||||||
@@ -26,11 +35,20 @@ class PictureSourceUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
|
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
|
||||||
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
||||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
||||||
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90)
|
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
||||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
||||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
# Video fields
|
||||||
|
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL")
|
||||||
|
loop: Optional[bool] = Field(None, description="Loop video playback")
|
||||||
|
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0)
|
||||||
|
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
|
||||||
|
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
|
||||||
|
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
|
||||||
|
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
|
||||||
|
|
||||||
|
|
||||||
class PictureSourceResponse(BaseModel):
|
class PictureSourceResponse(BaseModel):
|
||||||
@@ -38,16 +56,25 @@ class PictureSourceResponse(BaseModel):
|
|||||||
|
|
||||||
id: str = Field(description="Stream ID")
|
id: str = Field(description="Stream ID")
|
||||||
name: str = Field(description="Stream name")
|
name: str = Field(description="Stream name")
|
||||||
stream_type: str = Field(description="Stream type (raw, processed, or static_image)")
|
stream_type: str = Field(description="Stream type (raw, processed, static_image, or video)")
|
||||||
display_index: Optional[int] = Field(None, description="Display index")
|
display_index: Optional[int] = Field(None, description="Display index")
|
||||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
|
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
|
||||||
target_fps: Optional[int] = Field(None, description="Target FPS")
|
target_fps: Optional[int] = Field(None, description="Target FPS")
|
||||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
|
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
|
||||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID")
|
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID")
|
||||||
image_source: Optional[str] = Field(None, description="Image URL or file path")
|
image_source: Optional[str] = Field(None, description="Image URL or file path")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
description: Optional[str] = Field(None, description="Stream description")
|
description: Optional[str] = Field(None, description="Stream description")
|
||||||
|
# Video fields
|
||||||
|
url: Optional[str] = Field(None, description="Video URL")
|
||||||
|
loop: Optional[bool] = Field(None, description="Loop video playback")
|
||||||
|
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
|
||||||
|
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
|
||||||
|
end_time: Optional[float] = Field(None, description="Trim end time in seconds")
|
||||||
|
resolution_limit: Optional[int] = Field(None, description="Max width for decode")
|
||||||
|
clock_id: Optional[str] = Field(None, description="Sync clock ID")
|
||||||
|
|
||||||
|
|
||||||
class PictureSourceListResponse(BaseModel):
|
class PictureSourceListResponse(BaseModel):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class PostprocessingTemplateCreate(BaseModel):
|
|||||||
name: str = Field(description="Template name", min_length=1, max_length=100)
|
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||||
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
|
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
|
||||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
class PostprocessingTemplateUpdate(BaseModel):
|
class PostprocessingTemplateUpdate(BaseModel):
|
||||||
@@ -22,6 +23,7 @@ class PostprocessingTemplateUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||||
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
|
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
|
||||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class PostprocessingTemplateResponse(BaseModel):
|
class PostprocessingTemplateResponse(BaseModel):
|
||||||
@@ -30,6 +32,7 @@ class PostprocessingTemplateResponse(BaseModel):
|
|||||||
id: str = Field(description="Template ID")
|
id: str = Field(description="Template ID")
|
||||||
name: str = Field(description="Template name")
|
name: str = Field(description="Template name")
|
||||||
filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
|
filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
description: Optional[str] = Field(None, description="Template description")
|
description: Optional[str] = Field(None, description="Template description")
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Profile-related schemas."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ConditionSchema(BaseModel):
|
|
||||||
"""A single condition within a profile."""
|
|
||||||
|
|
||||||
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
|
||||||
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
|
|
||||||
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileCreate(BaseModel):
|
|
||||||
"""Request to create a profile."""
|
|
||||||
|
|
||||||
name: str = Field(description="Profile name", min_length=1, max_length=100)
|
|
||||||
enabled: bool = Field(default=True, description="Whether the profile is enabled")
|
|
||||||
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
|
|
||||||
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
|
|
||||||
target_ids: List[str] = Field(default_factory=list, description="Target IDs to activate")
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileUpdate(BaseModel):
|
|
||||||
"""Request to update a profile."""
|
|
||||||
|
|
||||||
name: Optional[str] = Field(None, description="Profile name", min_length=1, max_length=100)
|
|
||||||
enabled: Optional[bool] = Field(None, description="Whether the profile is enabled")
|
|
||||||
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
|
|
||||||
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
|
|
||||||
target_ids: Optional[List[str]] = Field(None, description="Target IDs to activate")
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileResponse(BaseModel):
|
|
||||||
"""Profile information response."""
|
|
||||||
|
|
||||||
id: str = Field(description="Profile ID")
|
|
||||||
name: str = Field(description="Profile name")
|
|
||||||
enabled: bool = Field(description="Whether the profile is enabled")
|
|
||||||
condition_logic: str = Field(description="Condition combination logic")
|
|
||||||
conditions: List[ConditionSchema] = Field(description="List of conditions")
|
|
||||||
target_ids: List[str] = Field(description="Target IDs to activate")
|
|
||||||
is_active: bool = Field(default=False, description="Whether the profile is currently active")
|
|
||||||
active_target_ids: List[str] = Field(default_factory=list, description="Targets currently owned by this profile")
|
|
||||||
last_activated_at: Optional[datetime] = Field(None, description="Last time this profile was activated")
|
|
||||||
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this profile was deactivated")
|
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileListResponse(BaseModel):
|
|
||||||
"""List of profiles response."""
|
|
||||||
|
|
||||||
profiles: List[ProfileResponse] = Field(description="List of profiles")
|
|
||||||
count: int = Field(description="Number of profiles")
|
|
||||||
56
server/src/wled_controller/api/schemas/scene_presets.py
Normal file
56
server/src/wled_controller/api/schemas/scene_presets.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Scene preset API schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TargetSnapshotSchema(BaseModel):
|
||||||
|
target_id: str
|
||||||
|
running: bool = False
|
||||||
|
color_strip_source_id: str = ""
|
||||||
|
brightness_value_source_id: str = ""
|
||||||
|
fps: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePresetCreate(BaseModel):
|
||||||
|
"""Create a scene preset by capturing current state."""
|
||||||
|
|
||||||
|
name: str = Field(description="Preset name", min_length=1, max_length=100)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
target_ids: Optional[List[str]] = Field(None, description="Target IDs to capture (all if omitted)")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePresetUpdate(BaseModel):
|
||||||
|
"""Update scene preset metadata and optionally change which targets are included."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
|
order: Optional[int] = None
|
||||||
|
target_ids: Optional[List[str]] = Field(None, description="Update target list: keep state for existing, capture fresh for new, drop removed")
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePresetResponse(BaseModel):
|
||||||
|
"""Scene preset with full snapshot data."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
targets: List[TargetSnapshotSchema]
|
||||||
|
order: int
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePresetListResponse(BaseModel):
|
||||||
|
presets: List[ScenePresetResponse]
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ActivateResponse(BaseModel):
|
||||||
|
status: str = Field(description="'activated' or 'partial'")
|
||||||
|
errors: List[str] = Field(default_factory=list)
|
||||||
45
server/src/wled_controller/api/schemas/sync_clocks.py
Normal file
45
server/src/wled_controller/api/schemas/sync_clocks.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Sync clock schemas (CRUD + control)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SyncClockCreate(BaseModel):
|
||||||
|
"""Request to create a synchronization clock."""
|
||||||
|
|
||||||
|
name: str = Field(description="Clock name", min_length=1, max_length=100)
|
||||||
|
speed: float = Field(default=1.0, description="Speed multiplier (0.1–10.0)", ge=0.1, le=10.0)
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
|
class SyncClockUpdate(BaseModel):
|
||||||
|
"""Request to update a synchronization clock."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Clock name", min_length=1, max_length=100)
|
||||||
|
speed: Optional[float] = Field(None, description="Speed multiplier (0.1–10.0)", ge=0.1, le=10.0)
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SyncClockResponse(BaseModel):
|
||||||
|
"""Synchronization clock response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Clock ID")
|
||||||
|
name: str = Field(description="Clock name")
|
||||||
|
speed: float = Field(description="Speed multiplier")
|
||||||
|
description: Optional[str] = Field(None, description="Description")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
is_running: bool = Field(True, description="Whether clock is currently running")
|
||||||
|
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
|
||||||
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class SyncClockListResponse(BaseModel):
|
||||||
|
"""List of synchronization clocks."""
|
||||||
|
|
||||||
|
clocks: List[SyncClockResponse] = Field(description="List of sync clocks")
|
||||||
|
count: int = Field(description="Number of clocks")
|
||||||
@@ -12,6 +12,7 @@ class HealthResponse(BaseModel):
|
|||||||
status: Literal["healthy", "unhealthy"] = Field(description="Service health status")
|
status: Literal["healthy", "unhealthy"] = Field(description="Service health status")
|
||||||
timestamp: datetime = Field(description="Current server time")
|
timestamp: datetime = Field(description="Current server time")
|
||||||
version: str = Field(description="Application version")
|
version: str = Field(description="Application version")
|
||||||
|
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
|
||||||
|
|
||||||
|
|
||||||
class VersionResponse(BaseModel):
|
class VersionResponse(BaseModel):
|
||||||
@@ -20,6 +21,7 @@ class VersionResponse(BaseModel):
|
|||||||
version: str = Field(description="Application version")
|
version: str = Field(description="Application version")
|
||||||
python_version: str = Field(description="Python version")
|
python_version: str = Field(description="Python version")
|
||||||
api_version: str = Field(description="API version")
|
api_version: str = Field(description="API version")
|
||||||
|
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
|
||||||
|
|
||||||
|
|
||||||
class DisplayInfo(BaseModel):
|
class DisplayInfo(BaseModel):
|
||||||
@@ -62,9 +64,110 @@ class GpuInfo(BaseModel):
|
|||||||
class PerformanceResponse(BaseModel):
|
class PerformanceResponse(BaseModel):
|
||||||
"""System performance metrics."""
|
"""System performance metrics."""
|
||||||
|
|
||||||
|
cpu_name: str | None = Field(default=None, description="CPU model name")
|
||||||
cpu_percent: float = Field(description="System-wide CPU usage percent")
|
cpu_percent: float = Field(description="System-wide CPU usage percent")
|
||||||
ram_used_mb: float = Field(description="RAM used in MB")
|
ram_used_mb: float = Field(description="RAM used in MB")
|
||||||
ram_total_mb: float = Field(description="RAM total in MB")
|
ram_total_mb: float = Field(description="RAM total in MB")
|
||||||
ram_percent: float = Field(description="RAM usage percent")
|
ram_percent: float = Field(description="RAM usage percent")
|
||||||
gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
|
gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
|
||||||
timestamp: datetime = Field(description="Measurement timestamp")
|
timestamp: datetime = Field(description="Measurement timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreResponse(BaseModel):
|
||||||
|
"""Response after restoring configuration backup."""
|
||||||
|
|
||||||
|
status: str = Field(description="Status of restore operation")
|
||||||
|
stores_written: int = Field(description="Number of stores successfully written")
|
||||||
|
stores_total: int = Field(description="Total number of known stores")
|
||||||
|
missing_stores: List[str] = Field(default_factory=list, description="Store keys not found in backup")
|
||||||
|
restart_scheduled: bool = Field(description="Whether server restart was scheduled")
|
||||||
|
message: str = Field(description="Human-readable status message")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Auto-backup schemas ──────────────────────────────────────
|
||||||
|
|
||||||
|
class AutoBackupSettings(BaseModel):
|
||||||
|
"""Settings for automatic backup."""
|
||||||
|
|
||||||
|
enabled: bool = Field(description="Whether auto-backup is enabled")
|
||||||
|
interval_hours: float = Field(ge=0.5, le=168, description="Backup interval in hours")
|
||||||
|
max_backups: int = Field(ge=1, le=100, description="Maximum number of backup files to keep")
|
||||||
|
|
||||||
|
|
||||||
|
class AutoBackupStatusResponse(BaseModel):
|
||||||
|
"""Auto-backup settings plus runtime status."""
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
interval_hours: float
|
||||||
|
max_backups: int
|
||||||
|
last_backup_time: str | None = None
|
||||||
|
next_backup_time: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BackupFileInfo(BaseModel):
|
||||||
|
"""Information about a saved backup file."""
|
||||||
|
|
||||||
|
filename: str
|
||||||
|
size_bytes: int
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class BackupListResponse(BaseModel):
|
||||||
|
"""List of saved backup files."""
|
||||||
|
|
||||||
|
backups: List[BackupFileInfo]
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
# ─── MQTT schemas ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MQTTSettingsResponse(BaseModel):
|
||||||
|
"""MQTT broker settings response (password is masked)."""
|
||||||
|
|
||||||
|
enabled: bool = Field(description="Whether MQTT is enabled")
|
||||||
|
broker_host: str = Field(description="MQTT broker hostname or IP")
|
||||||
|
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
||||||
|
username: str = Field(description="MQTT username (empty = anonymous)")
|
||||||
|
password_set: bool = Field(description="Whether a password is configured")
|
||||||
|
client_id: str = Field(description="MQTT client ID")
|
||||||
|
base_topic: str = Field(description="Base topic prefix")
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTSettingsRequest(BaseModel):
|
||||||
|
"""MQTT broker settings update request."""
|
||||||
|
|
||||||
|
enabled: bool = Field(description="Whether MQTT is enabled")
|
||||||
|
broker_host: str = Field(description="MQTT broker hostname or IP")
|
||||||
|
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
||||||
|
username: str = Field(default="", description="MQTT username (empty = anonymous)")
|
||||||
|
password: str = Field(default="", description="MQTT password (empty = keep existing if omitted)")
|
||||||
|
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
||||||
|
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── External URL schema ───────────────────────────────────────
|
||||||
|
|
||||||
|
class ExternalUrlResponse(BaseModel):
|
||||||
|
"""External URL setting response."""
|
||||||
|
|
||||||
|
external_url: str = Field(description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL.")
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalUrlRequest(BaseModel):
|
||||||
|
"""External URL setting update request."""
|
||||||
|
|
||||||
|
external_url: str = Field(default="", description="External base URL. Empty string to clear.")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Log level schemas ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class LogLevelResponse(BaseModel):
|
||||||
|
"""Current log level response."""
|
||||||
|
|
||||||
|
level: str = Field(description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)")
|
||||||
|
|
||||||
|
|
||||||
|
class LogLevelRequest(BaseModel):
|
||||||
|
"""Request to change the log level."""
|
||||||
|
|
||||||
|
level: str = Field(description="New log level name (DEBUG, INFO, WARNING, ERROR, CRITICAL)")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class TemplateCreate(BaseModel):
|
|||||||
engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1)
|
engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1)
|
||||||
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
|
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
|
||||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
class TemplateUpdate(BaseModel):
|
class TemplateUpdate(BaseModel):
|
||||||
@@ -22,6 +23,7 @@ class TemplateUpdate(BaseModel):
|
|||||||
engine_type: Optional[str] = Field(None, description="Capture engine type (mss, dxcam, wgc)")
|
engine_type: Optional[str] = Field(None, description="Capture engine type (mss, dxcam, wgc)")
|
||||||
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
|
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
|
||||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class TemplateResponse(BaseModel):
|
class TemplateResponse(BaseModel):
|
||||||
@@ -31,6 +33,7 @@ class TemplateResponse(BaseModel):
|
|||||||
name: str = Field(description="Template name")
|
name: str = Field(description="Template name")
|
||||||
engine_type: str = Field(description="Engine type identifier")
|
engine_type: str = Field(description="Engine type identifier")
|
||||||
engine_config: Dict = Field(description="Engine-specific configuration")
|
engine_config: Dict = Field(description="Engine-specific configuration")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
description: Optional[str] = Field(None, description="Template description")
|
description: Optional[str] = Field(None, description="Template description")
|
||||||
@@ -50,6 +53,7 @@ class EngineInfo(BaseModel):
|
|||||||
name: str = Field(description="Human-readable engine name")
|
name: str = Field(description="Human-readable engine name")
|
||||||
default_config: Dict = Field(description="Default configuration for this engine")
|
default_config: Dict = Field(description="Default configuration for this engine")
|
||||||
available: bool = Field(description="Whether engine is available on this system")
|
available: bool = Field(description="Whether engine is available on this system")
|
||||||
|
has_own_displays: bool = Field(default=False, description="Engine has its own device list (not desktop monitors)")
|
||||||
|
|
||||||
|
|
||||||
class EngineListResponse(BaseModel):
|
class EngineListResponse(BaseModel):
|
||||||
|
|||||||
97
server/src/wled_controller/api/schemas/value_sources.py
Normal file
97
server/src/wled_controller/api/schemas/value_sources.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Value source schemas (CRUD)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ValueSourceCreate(BaseModel):
|
||||||
|
"""Request to create a value source."""
|
||||||
|
|
||||||
|
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||||
|
source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"] = Field(description="Source type")
|
||||||
|
# static fields
|
||||||
|
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
# animated fields
|
||||||
|
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
|
||||||
|
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
|
||||||
|
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
# audio fields
|
||||||
|
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||||
|
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
|
||||||
|
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
|
||||||
|
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
|
||||||
|
# adaptive fields
|
||||||
|
schedule: Optional[list] = Field(None, description="Time-of-day schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
|
||||||
|
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
|
||||||
|
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
|
||||||
|
# daylight fields
|
||||||
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
|
||||||
|
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
|
class ValueSourceUpdate(BaseModel):
|
||||||
|
"""Request to update a value source."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||||
|
# static fields
|
||||||
|
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
# animated fields
|
||||||
|
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
|
||||||
|
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
|
||||||
|
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
# audio fields
|
||||||
|
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||||
|
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
|
||||||
|
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
|
||||||
|
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
|
||||||
|
# adaptive fields
|
||||||
|
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
|
||||||
|
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
|
||||||
|
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
|
||||||
|
# daylight fields
|
||||||
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
|
||||||
|
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ValueSourceResponse(BaseModel):
|
||||||
|
"""Value source response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Source ID")
|
||||||
|
name: str = Field(description="Source name")
|
||||||
|
source_type: str = Field(description="Source type: static, animated, audio, adaptive_time, or adaptive_scene")
|
||||||
|
value: Optional[float] = Field(None, description="Static value")
|
||||||
|
waveform: Optional[str] = Field(None, description="Waveform type")
|
||||||
|
speed: Optional[float] = Field(None, description="Cycles per minute")
|
||||||
|
min_value: Optional[float] = Field(None, description="Minimum output")
|
||||||
|
max_value: Optional[float] = Field(None, description="Maximum output")
|
||||||
|
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||||
|
mode: Optional[str] = Field(None, description="Audio mode")
|
||||||
|
sensitivity: Optional[float] = Field(None, description="Gain multiplier")
|
||||||
|
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
|
||||||
|
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
|
||||||
|
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
|
||||||
|
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||||
|
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
|
||||||
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
|
||||||
|
latitude: Optional[float] = Field(None, description="Geographic latitude")
|
||||||
|
description: Optional[str] = Field(None, description="Description")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class ValueSourceListResponse(BaseModel):
|
||||||
|
"""List of value sources."""
|
||||||
|
|
||||||
|
sources: List[ValueSourceResponse] = Field(description="List of value sources")
|
||||||
|
count: int = Field(description="Number of sources")
|
||||||
@@ -31,9 +31,28 @@ class StorageConfig(BaseSettings):
|
|||||||
templates_file: str = "data/capture_templates.json"
|
templates_file: str = "data/capture_templates.json"
|
||||||
postprocessing_templates_file: str = "data/postprocessing_templates.json"
|
postprocessing_templates_file: str = "data/postprocessing_templates.json"
|
||||||
picture_sources_file: str = "data/picture_sources.json"
|
picture_sources_file: str = "data/picture_sources.json"
|
||||||
picture_targets_file: str = "data/picture_targets.json"
|
output_targets_file: str = "data/output_targets.json"
|
||||||
pattern_templates_file: str = "data/pattern_templates.json"
|
pattern_templates_file: str = "data/pattern_templates.json"
|
||||||
profiles_file: str = "data/profiles.json"
|
color_strip_sources_file: str = "data/color_strip_sources.json"
|
||||||
|
audio_sources_file: str = "data/audio_sources.json"
|
||||||
|
audio_templates_file: str = "data/audio_templates.json"
|
||||||
|
value_sources_file: str = "data/value_sources.json"
|
||||||
|
automations_file: str = "data/automations.json"
|
||||||
|
scene_presets_file: str = "data/scene_presets.json"
|
||||||
|
color_strip_processing_templates_file: str = "data/color_strip_processing_templates.json"
|
||||||
|
sync_clocks_file: str = "data/sync_clocks.json"
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTConfig(BaseSettings):
|
||||||
|
"""MQTT broker configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
broker_host: str = "localhost"
|
||||||
|
broker_port: int = 1883
|
||||||
|
username: str = ""
|
||||||
|
password: str = ""
|
||||||
|
client_id: str = "ledgrab"
|
||||||
|
base_topic: str = "ledgrab"
|
||||||
|
|
||||||
|
|
||||||
class LoggingConfig(BaseSettings):
|
class LoggingConfig(BaseSettings):
|
||||||
@@ -54,11 +73,22 @@ class Config(BaseSettings):
|
|||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
demo: bool = False
|
||||||
|
|
||||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
auth: AuthConfig = Field(default_factory=AuthConfig)
|
||||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||||
|
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
|
||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||||
|
|
||||||
|
def model_post_init(self, __context: object) -> None:
|
||||||
|
"""Override storage paths when demo mode is active."""
|
||||||
|
if self.demo:
|
||||||
|
for field_name in self.storage.model_fields:
|
||||||
|
value = getattr(self.storage, field_name)
|
||||||
|
if isinstance(value, str) and value.startswith("data/"):
|
||||||
|
setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_yaml(cls, config_path: str | Path) -> "Config":
|
def from_yaml(cls, config_path: str | Path) -> "Config":
|
||||||
"""Load configuration from YAML file.
|
"""Load configuration from YAML file.
|
||||||
@@ -73,7 +103,7 @@ class Config(BaseSettings):
|
|||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
||||||
|
|
||||||
with open(config_path, "r") as f:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
config_data = yaml.safe_load(f)
|
config_data = yaml.safe_load(f)
|
||||||
|
|
||||||
return cls(**config_data)
|
return cls(**config_data)
|
||||||
@@ -84,8 +114,9 @@ class Config(BaseSettings):
|
|||||||
|
|
||||||
Tries to load from:
|
Tries to load from:
|
||||||
1. Environment variable WLED_CONFIG_PATH
|
1. Environment variable WLED_CONFIG_PATH
|
||||||
2. ./config/default_config.yaml
|
2. WLED_DEMO=true → ./config/demo_config.yaml (if it exists)
|
||||||
3. Default values
|
3. ./config/default_config.yaml
|
||||||
|
4. Default values
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Config instance
|
Config instance
|
||||||
@@ -95,6 +126,12 @@ class Config(BaseSettings):
|
|||||||
if config_path:
|
if config_path:
|
||||||
return cls.from_yaml(config_path)
|
return cls.from_yaml(config_path)
|
||||||
|
|
||||||
|
# Demo mode: try dedicated demo config first
|
||||||
|
if os.getenv("WLED_DEMO", "").lower() in ("true", "1", "yes"):
|
||||||
|
demo_path = Path("config/demo_config.yaml")
|
||||||
|
if demo_path.exists():
|
||||||
|
return cls.from_yaml(demo_path)
|
||||||
|
|
||||||
# Try default location
|
# Try default location
|
||||||
default_path = Path("config/default_config.yaml")
|
default_path = Path("config/default_config.yaml")
|
||||||
if default_path.exists():
|
if default_path.exists():
|
||||||
@@ -129,3 +166,8 @@ def reload_config() -> Config:
|
|||||||
global config
|
global config
|
||||||
config = Config.load()
|
config = Config.load()
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def is_demo_mode() -> bool:
|
||||||
|
"""Check whether the application is running in demo mode."""
|
||||||
|
return get_config().demo
|
||||||
|
|||||||
41
server/src/wled_controller/core/audio/__init__.py
Normal file
41
server/src/wled_controller/core/audio/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Audio capture engine abstraction layer."""
|
||||||
|
|
||||||
|
from wled_controller.core.audio.base import (
|
||||||
|
AudioCaptureEngine,
|
||||||
|
AudioCaptureStreamBase,
|
||||||
|
AudioDeviceInfo,
|
||||||
|
)
|
||||||
|
from wled_controller.core.audio.factory import AudioEngineRegistry
|
||||||
|
from wled_controller.core.audio.analysis import (
|
||||||
|
AudioAnalysis,
|
||||||
|
AudioAnalyzer,
|
||||||
|
NUM_BANDS,
|
||||||
|
DEFAULT_SAMPLE_RATE,
|
||||||
|
DEFAULT_CHUNK_SIZE,
|
||||||
|
)
|
||||||
|
from wled_controller.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
|
||||||
|
from wled_controller.core.audio.sounddevice_engine import SounddeviceEngine, SounddeviceCaptureStream
|
||||||
|
from wled_controller.core.audio.demo_engine import DemoAudioEngine, DemoAudioCaptureStream
|
||||||
|
|
||||||
|
# Auto-register available engines
|
||||||
|
AudioEngineRegistry.register(WasapiEngine)
|
||||||
|
AudioEngineRegistry.register(SounddeviceEngine)
|
||||||
|
AudioEngineRegistry.register(DemoAudioEngine)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AudioCaptureEngine",
|
||||||
|
"AudioCaptureStreamBase",
|
||||||
|
"AudioDeviceInfo",
|
||||||
|
"AudioEngineRegistry",
|
||||||
|
"AudioAnalysis",
|
||||||
|
"AudioAnalyzer",
|
||||||
|
"NUM_BANDS",
|
||||||
|
"DEFAULT_SAMPLE_RATE",
|
||||||
|
"DEFAULT_CHUNK_SIZE",
|
||||||
|
"WasapiEngine",
|
||||||
|
"WasapiCaptureStream",
|
||||||
|
"SounddeviceEngine",
|
||||||
|
"SounddeviceCaptureStream",
|
||||||
|
"DemoAudioEngine",
|
||||||
|
"DemoAudioCaptureStream",
|
||||||
|
]
|
||||||
252
server/src/wled_controller/core/audio/analysis.py
Normal file
252
server/src/wled_controller/core/audio/analysis.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"""Shared audio analysis — FFT spectrum, RMS, beat detection.
|
||||||
|
|
||||||
|
Engines provide raw audio chunks; AudioAnalyzer processes them into
|
||||||
|
AudioAnalysis snapshots consumed by visualization streams.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Number of logarithmic frequency bands for spectrum analysis
|
||||||
|
NUM_BANDS = 64
|
||||||
|
|
||||||
|
# Audio defaults
|
||||||
|
DEFAULT_SAMPLE_RATE = 44100
|
||||||
|
DEFAULT_CHUNK_SIZE = 2048 # ~46 ms at 44100 Hz
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioAnalysis:
|
||||||
|
"""Snapshot of audio analysis results.
|
||||||
|
|
||||||
|
Written by the capture thread, read by visualization streams.
|
||||||
|
Mono fields contain the mixed-down signal (all channels averaged).
|
||||||
|
Per-channel fields (left/right) are populated when the source is stereo+.
|
||||||
|
For mono sources, left/right are copies of the mono data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
timestamp: float = 0.0
|
||||||
|
# Mono (mixed) — backward-compatible fields
|
||||||
|
rms: float = 0.0
|
||||||
|
peak: float = 0.0
|
||||||
|
spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32))
|
||||||
|
beat: bool = False
|
||||||
|
beat_intensity: float = 0.0
|
||||||
|
# Per-channel
|
||||||
|
left_rms: float = 0.0
|
||||||
|
left_spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32))
|
||||||
|
right_rms: float = 0.0
|
||||||
|
right_spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_log_bands(num_bands: int, fft_size: int, sample_rate: int) -> List[Tuple[int, int]]:
|
||||||
|
"""Build logarithmically-spaced frequency band boundaries for FFT bins.
|
||||||
|
|
||||||
|
Returns list of (start_bin, end_bin) pairs.
|
||||||
|
"""
|
||||||
|
nyquist = sample_rate / 2
|
||||||
|
min_freq = 20.0
|
||||||
|
max_freq = min(nyquist, 20000.0)
|
||||||
|
log_min = math.log10(min_freq)
|
||||||
|
log_max = math.log10(max_freq)
|
||||||
|
|
||||||
|
freqs = np.logspace(log_min, log_max, num_bands + 1)
|
||||||
|
bin_width = sample_rate / fft_size
|
||||||
|
|
||||||
|
bands = []
|
||||||
|
for i in range(num_bands):
|
||||||
|
start_bin = max(1, int(freqs[i] / bin_width))
|
||||||
|
end_bin = max(start_bin + 1, int(freqs[i + 1] / bin_width))
|
||||||
|
end_bin = min(end_bin, fft_size // 2)
|
||||||
|
bands.append((start_bin, end_bin))
|
||||||
|
return bands
|
||||||
|
|
||||||
|
|
||||||
|
class AudioAnalyzer:
|
||||||
|
"""Stateful audio analyzer — call analyze() per raw chunk.
|
||||||
|
|
||||||
|
Maintains smoothing buffers, energy history for beat detection,
|
||||||
|
and pre-allocated FFT scratch buffers. Thread-safe only if a single
|
||||||
|
thread calls analyze() (the capture thread).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sample_rate: int = DEFAULT_SAMPLE_RATE, chunk_size: int = DEFAULT_CHUNK_SIZE):
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
self._chunk_size = chunk_size
|
||||||
|
|
||||||
|
# FFT helpers
|
||||||
|
self._window = np.hanning(chunk_size).astype(np.float32)
|
||||||
|
self._bands = _build_log_bands(NUM_BANDS, chunk_size, sample_rate)
|
||||||
|
|
||||||
|
# Beat detection state
|
||||||
|
self._energy_history: np.ndarray = np.zeros(43, dtype=np.float64) # ~1s at 44100/2048
|
||||||
|
self._energy_idx = 0
|
||||||
|
|
||||||
|
# Smoothed spectrum (exponential decay)
|
||||||
|
self._smooth_spectrum = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||||
|
self._smooth_spectrum_left = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||||
|
self._smooth_spectrum_right = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||||
|
self._smoothing_alpha = 0.3
|
||||||
|
|
||||||
|
# Pre-allocated scratch buffers
|
||||||
|
self._fft_windowed = np.empty(chunk_size, dtype=np.float32)
|
||||||
|
self._spectrum_buf = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||||
|
self._spectrum_buf_left = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||||
|
self._spectrum_buf_right = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||||
|
self._sq_buf = np.empty(chunk_size, dtype=np.float32)
|
||||||
|
|
||||||
|
# Double-buffered output spectra — avoids allocating new arrays each
|
||||||
|
# analyze() call. Consumers hold a reference to the "old" buffer while
|
||||||
|
# the analyzer writes into the alternate one.
|
||||||
|
self._out_spectrum = [np.zeros(NUM_BANDS, dtype=np.float32),
|
||||||
|
np.zeros(NUM_BANDS, dtype=np.float32)]
|
||||||
|
self._out_spectrum_left = [np.zeros(NUM_BANDS, dtype=np.float32),
|
||||||
|
np.zeros(NUM_BANDS, dtype=np.float32)]
|
||||||
|
self._out_spectrum_right = [np.zeros(NUM_BANDS, dtype=np.float32),
|
||||||
|
np.zeros(NUM_BANDS, dtype=np.float32)]
|
||||||
|
self._out_idx = 0 # toggles 0/1 each analyze() call
|
||||||
|
|
||||||
|
# Pre-compute band start/end arrays and widths for vectorized binning
|
||||||
|
self._band_starts = np.array([s for s, _ in self._bands], dtype=np.intp)
|
||||||
|
self._band_ends = np.array([e for _, e in self._bands], dtype=np.intp)
|
||||||
|
self._band_widths = (self._band_ends - self._band_starts).astype(np.float32)
|
||||||
|
self._band_widths[self._band_widths == 0] = 1.0 # avoid divide-by-zero
|
||||||
|
|
||||||
|
# Pre-allocated channel buffers for stereo
|
||||||
|
self._left_buf = np.empty(chunk_size, dtype=np.float32)
|
||||||
|
self._right_buf = np.empty(chunk_size, dtype=np.float32)
|
||||||
|
self._mono_buf = np.empty(chunk_size, dtype=np.float32)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sample_rate(self) -> int:
|
||||||
|
return self._sample_rate
|
||||||
|
|
||||||
|
@sample_rate.setter
|
||||||
|
def sample_rate(self, value: int):
|
||||||
|
if value != self._sample_rate:
|
||||||
|
self._sample_rate = value
|
||||||
|
self._bands = _build_log_bands(NUM_BANDS, self._chunk_size, value)
|
||||||
|
|
||||||
|
def analyze(self, raw_data: np.ndarray, channels: int) -> AudioAnalysis:
|
||||||
|
"""Analyze a raw audio chunk and return an AudioAnalysis snapshot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_data: 1-D float32 array of interleaved samples (length = chunk_size * channels)
|
||||||
|
channels: Number of audio channels
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudioAnalysis with spectrum, RMS, beat, etc.
|
||||||
|
"""
|
||||||
|
chunk_size = self._chunk_size
|
||||||
|
alpha = self._smoothing_alpha
|
||||||
|
one_minus_alpha = 1.0 - alpha
|
||||||
|
|
||||||
|
# Split channels and mix to mono
|
||||||
|
if channels > 1:
|
||||||
|
data = raw_data.reshape(-1, channels)
|
||||||
|
np.copyto(self._left_buf[:len(data)], data[:, 0])
|
||||||
|
right_col = data[:, 1] if channels >= 2 else data[:, 0]
|
||||||
|
np.copyto(self._right_buf[:len(data)], right_col)
|
||||||
|
np.add(data[:, 0], right_col, out=self._mono_buf[:len(data)])
|
||||||
|
self._mono_buf[:len(data)] *= 0.5
|
||||||
|
samples = self._mono_buf[:len(data)]
|
||||||
|
left_samples = self._left_buf[:len(data)]
|
||||||
|
right_samples = self._right_buf[:len(data)]
|
||||||
|
else:
|
||||||
|
samples = raw_data
|
||||||
|
left_samples = samples
|
||||||
|
right_samples = samples
|
||||||
|
|
||||||
|
# RMS and peak
|
||||||
|
n = len(samples)
|
||||||
|
np.multiply(samples, samples, out=self._sq_buf[:n])
|
||||||
|
rms = float(np.sqrt(np.mean(self._sq_buf[:n])))
|
||||||
|
peak = float(np.max(np.abs(samples)))
|
||||||
|
|
||||||
|
if channels > 1:
|
||||||
|
np.multiply(left_samples, left_samples, out=self._sq_buf[:n])
|
||||||
|
left_rms = float(np.sqrt(np.mean(self._sq_buf[:n])))
|
||||||
|
np.multiply(right_samples, right_samples, out=self._sq_buf[:n])
|
||||||
|
right_rms = float(np.sqrt(np.mean(self._sq_buf[:n])))
|
||||||
|
else:
|
||||||
|
left_rms = rms
|
||||||
|
right_rms = rms
|
||||||
|
|
||||||
|
# FFT for mono, left, right
|
||||||
|
self._fft_bands(samples, self._spectrum_buf, self._smooth_spectrum,
|
||||||
|
alpha, one_minus_alpha)
|
||||||
|
if channels > 1:
|
||||||
|
self._fft_bands(left_samples, self._spectrum_buf_left, self._smooth_spectrum_left,
|
||||||
|
alpha, one_minus_alpha)
|
||||||
|
self._fft_bands(right_samples, self._spectrum_buf_right, self._smooth_spectrum_right,
|
||||||
|
alpha, one_minus_alpha)
|
||||||
|
else:
|
||||||
|
np.copyto(self._smooth_spectrum_left, self._smooth_spectrum)
|
||||||
|
np.copyto(self._smooth_spectrum_right, self._smooth_spectrum)
|
||||||
|
|
||||||
|
# Beat detection — compare current energy to rolling average (mono)
|
||||||
|
np.multiply(samples, samples, out=self._sq_buf[:n])
|
||||||
|
energy = float(np.sum(self._sq_buf[:n]))
|
||||||
|
self._energy_history[self._energy_idx] = energy
|
||||||
|
self._energy_idx = (self._energy_idx + 1) % len(self._energy_history)
|
||||||
|
avg_energy = float(np.mean(self._energy_history))
|
||||||
|
|
||||||
|
beat = False
|
||||||
|
beat_intensity = 0.0
|
||||||
|
if avg_energy > 1e-8:
|
||||||
|
ratio = energy / avg_energy
|
||||||
|
if ratio > 1.5:
|
||||||
|
beat = True
|
||||||
|
beat_intensity = min(1.0, (ratio - 1.0) / 2.0)
|
||||||
|
|
||||||
|
# Snapshot spectra into double-buffered output arrays (no allocation)
|
||||||
|
idx = self._out_idx
|
||||||
|
self._out_idx = 1 - idx
|
||||||
|
out_spec = self._out_spectrum[idx]
|
||||||
|
out_left = self._out_spectrum_left[idx]
|
||||||
|
out_right = self._out_spectrum_right[idx]
|
||||||
|
np.copyto(out_spec, self._smooth_spectrum)
|
||||||
|
np.copyto(out_left, self._smooth_spectrum_left)
|
||||||
|
np.copyto(out_right, self._smooth_spectrum_right)
|
||||||
|
|
||||||
|
return AudioAnalysis(
|
||||||
|
timestamp=time.perf_counter(),
|
||||||
|
rms=rms,
|
||||||
|
peak=peak,
|
||||||
|
spectrum=out_spec,
|
||||||
|
beat=beat,
|
||||||
|
beat_intensity=beat_intensity,
|
||||||
|
left_rms=left_rms,
|
||||||
|
left_spectrum=out_left,
|
||||||
|
right_rms=right_rms,
|
||||||
|
right_spectrum=out_right,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fft_bands(self, samps, buf, smooth_buf, alpha, one_minus_alpha):
|
||||||
|
"""Compute FFT, bin into bands, normalize, and smooth."""
|
||||||
|
chunk_size = self._chunk_size
|
||||||
|
chunk = samps[:chunk_size]
|
||||||
|
if len(chunk) < chunk_size:
|
||||||
|
chunk = np.pad(chunk, (0, chunk_size - len(chunk)))
|
||||||
|
np.multiply(chunk, self._window, out=self._fft_windowed)
|
||||||
|
fft_mag = np.abs(np.fft.rfft(self._fft_windowed))
|
||||||
|
fft_mag *= (1.0 / chunk_size)
|
||||||
|
fft_len = len(fft_mag)
|
||||||
|
# Vectorized band binning using cumulative sum
|
||||||
|
valid = (self._band_starts < fft_len) & (self._band_ends <= fft_len) & (self._band_ends > 0)
|
||||||
|
buf[:] = 0.0
|
||||||
|
if valid.any():
|
||||||
|
cumsum = np.cumsum(fft_mag)
|
||||||
|
band_sums = cumsum[self._band_ends[valid] - 1] - np.where(
|
||||||
|
self._band_starts[valid] > 0, cumsum[self._band_starts[valid] - 1], 0.0
|
||||||
|
)
|
||||||
|
buf[valid] = band_sums / self._band_widths[valid]
|
||||||
|
spec_max = float(np.max(buf))
|
||||||
|
if spec_max > 1e-6:
|
||||||
|
buf *= (1.0 / spec_max)
|
||||||
|
smooth_buf *= one_minus_alpha
|
||||||
|
smooth_buf += alpha * buf
|
||||||
322
server/src/wled_controller/core/audio/audio_capture.py
Normal file
322
server/src/wled_controller/core/audio/audio_capture.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""Audio capture service — shared audio analysis with ref counting.
|
||||||
|
|
||||||
|
Provides real-time FFT spectrum, RMS level, and beat detection from
|
||||||
|
system audio or microphone/line-in. Multiple AudioColorStripStreams
|
||||||
|
sharing the same device reuse a single capture thread via
|
||||||
|
AudioCaptureManager.
|
||||||
|
|
||||||
|
Engine-agnostic: uses AudioEngineRegistry to create the underlying
|
||||||
|
capture stream (WASAPI, sounddevice, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from wled_controller.core.audio.analysis import (
|
||||||
|
AudioAnalysis,
|
||||||
|
AudioAnalyzer,
|
||||||
|
DEFAULT_CHUNK_SIZE,
|
||||||
|
DEFAULT_SAMPLE_RATE,
|
||||||
|
)
|
||||||
|
from wled_controller.core.audio.base import AudioCaptureStreamBase
|
||||||
|
from wled_controller.core.audio.factory import AudioEngineRegistry
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Re-export for backward compatibility
|
||||||
|
__all__ = [
|
||||||
|
"AudioAnalysis",
|
||||||
|
"ManagedAudioStream",
|
||||||
|
"AudioCaptureManager",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ManagedAudioStream — wraps engine stream + analyzer in background thread
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ManagedAudioStream:
|
||||||
|
"""Wraps an AudioCaptureStreamBase + AudioAnalyzer in a background thread.
|
||||||
|
|
||||||
|
Public API is the same as the old AudioCaptureStream:
|
||||||
|
start(), stop(), get_latest_analysis(), get_last_timing().
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
engine_type: str,
|
||||||
|
device_index: int,
|
||||||
|
is_loopback: bool,
|
||||||
|
engine_config: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
|
self._engine_type = engine_type
|
||||||
|
self._device_index = device_index
|
||||||
|
self._is_loopback = is_loopback
|
||||||
|
self._engine_config = engine_config or {}
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._latest: Optional[AudioAnalysis] = None
|
||||||
|
self._last_timing: dict = {}
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._capture_loop, daemon=True,
|
||||||
|
name=f"AudioCapture-{self._engine_type}-{self._device_index}-"
|
||||||
|
f"{'lb' if self._is_loopback else 'in'}",
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info(
|
||||||
|
f"ManagedAudioStream started: engine={self._engine_type} "
|
||||||
|
f"device={self._device_index} loopback={self._is_loopback}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=5.0)
|
||||||
|
self._thread = None
|
||||||
|
with self._lock:
|
||||||
|
self._latest = None
|
||||||
|
logger.info(
|
||||||
|
f"ManagedAudioStream stopped: engine={self._engine_type} "
|
||||||
|
f"device={self._device_index}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_latest_analysis(self) -> Optional[AudioAnalysis]:
|
||||||
|
with self._lock:
|
||||||
|
return self._latest
|
||||||
|
|
||||||
|
def get_last_timing(self) -> dict:
|
||||||
|
return dict(self._last_timing)
|
||||||
|
|
||||||
|
def _capture_loop(self) -> None:
|
||||||
|
stream: Optional[AudioCaptureStreamBase] = None
|
||||||
|
try:
|
||||||
|
stream = AudioEngineRegistry.create_stream(
|
||||||
|
self._engine_type, self._device_index,
|
||||||
|
self._is_loopback, self._engine_config,
|
||||||
|
)
|
||||||
|
stream.initialize()
|
||||||
|
|
||||||
|
sample_rate = stream.sample_rate
|
||||||
|
chunk_size = stream.chunk_size
|
||||||
|
channels = stream.channels
|
||||||
|
|
||||||
|
analyzer = AudioAnalyzer(sample_rate=sample_rate, chunk_size=chunk_size)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Audio stream opened: engine={self._engine_type} "
|
||||||
|
f"device={self._device_index} loopback={self._is_loopback} "
|
||||||
|
f"channels={channels} sr={sample_rate}"
|
||||||
|
)
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
t_read_start = time.perf_counter()
|
||||||
|
raw_data = stream.read_chunk()
|
||||||
|
if raw_data is None:
|
||||||
|
time.sleep(0.05)
|
||||||
|
continue
|
||||||
|
t_read_end = time.perf_counter()
|
||||||
|
|
||||||
|
analysis = analyzer.analyze(raw_data, channels)
|
||||||
|
|
||||||
|
t_fft_end = time.perf_counter()
|
||||||
|
self._last_timing = {
|
||||||
|
"read_ms": (t_read_end - t_read_start) * 1000,
|
||||||
|
"fft_ms": (t_fft_end - t_read_end) * 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._latest = analysis
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ManagedAudioStream fatal error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
if stream is not None:
|
||||||
|
try:
|
||||||
|
stream.cleanup()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._running = False
|
||||||
|
logger.info(
|
||||||
|
f"ManagedAudioStream loop ended: engine={self._engine_type} "
|
||||||
|
f"device={self._device_index}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AudioCaptureManager — ref-counted shared capture streams
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AudioCaptureManager:
|
||||||
|
"""Manages shared ManagedAudioStream instances with reference counting.
|
||||||
|
|
||||||
|
Multiple AudioColorStripStreams using the same audio device share a
|
||||||
|
single capture thread. Key: (engine_type, device_index, is_loopback).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._streams: Dict[
|
||||||
|
Tuple[str, int, bool],
|
||||||
|
Tuple[ManagedAudioStream, int],
|
||||||
|
] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def acquire(
|
||||||
|
self,
|
||||||
|
device_index: int,
|
||||||
|
is_loopback: bool,
|
||||||
|
engine_type: Optional[str] = None,
|
||||||
|
engine_config: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> ManagedAudioStream:
|
||||||
|
"""Get or create a ManagedAudioStream for the given device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_index: Audio device index
|
||||||
|
is_loopback: Whether to capture loopback audio
|
||||||
|
engine_type: Engine type (falls back to best available if None)
|
||||||
|
engine_config: Engine-specific configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Shared ManagedAudioStream instance.
|
||||||
|
"""
|
||||||
|
if engine_type is None:
|
||||||
|
engine_type = AudioEngineRegistry.get_best_available_engine()
|
||||||
|
if engine_type is None:
|
||||||
|
raise RuntimeError("No audio capture engines available")
|
||||||
|
|
||||||
|
key = (engine_type, device_index, is_loopback)
|
||||||
|
with self._lock:
|
||||||
|
if key in self._streams:
|
||||||
|
stream, ref_count = self._streams[key]
|
||||||
|
self._streams[key] = (stream, ref_count + 1)
|
||||||
|
logger.info(f"Reusing audio capture {key} (ref_count={ref_count + 1})")
|
||||||
|
return stream
|
||||||
|
|
||||||
|
stream = ManagedAudioStream(
|
||||||
|
engine_type, device_index, is_loopback, engine_config,
|
||||||
|
)
|
||||||
|
stream.start()
|
||||||
|
self._streams[key] = (stream, 1)
|
||||||
|
logger.info(f"Created audio capture {key}")
|
||||||
|
return stream
|
||||||
|
|
||||||
|
def release(
|
||||||
|
self,
|
||||||
|
device_index: int,
|
||||||
|
is_loopback: bool,
|
||||||
|
engine_type: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Release a reference to a ManagedAudioStream."""
|
||||||
|
if engine_type is None:
|
||||||
|
engine_type = AudioEngineRegistry.get_best_available_engine()
|
||||||
|
if engine_type is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
key = (engine_type, device_index, is_loopback)
|
||||||
|
stream_to_stop = None
|
||||||
|
with self._lock:
|
||||||
|
if key not in self._streams:
|
||||||
|
logger.warning(f"Attempted to release unknown audio capture: {key}")
|
||||||
|
return
|
||||||
|
|
||||||
|
stream, ref_count = self._streams[key]
|
||||||
|
ref_count -= 1
|
||||||
|
if ref_count <= 0:
|
||||||
|
stream_to_stop = stream
|
||||||
|
del self._streams[key]
|
||||||
|
logger.info(f"Removed audio capture {key}")
|
||||||
|
else:
|
||||||
|
self._streams[key] = (stream, ref_count)
|
||||||
|
logger.debug(f"Released audio capture {key} (ref_count={ref_count})")
|
||||||
|
# Stop outside the lock — stream.stop() joins a thread (up to 5s)
|
||||||
|
if stream_to_stop is not None:
|
||||||
|
stream_to_stop.stop()
|
||||||
|
|
||||||
|
def release_all(self) -> None:
|
||||||
|
"""Stop and remove all capture streams. Called on shutdown."""
|
||||||
|
with self._lock:
|
||||||
|
streams_to_stop = list(self._streams.items())
|
||||||
|
self._streams.clear()
|
||||||
|
# Stop outside the lock — each stop() joins a thread
|
||||||
|
for key, (stream, _) in streams_to_stop:
|
||||||
|
try:
|
||||||
|
stream.stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping audio capture {key}: {e}")
|
||||||
|
logger.info("Released all audio capture streams")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def enumerate_devices() -> List[dict]:
|
||||||
|
"""List available audio devices from all registered engines.
|
||||||
|
|
||||||
|
Returns list of dicts with device info, each tagged with engine_type.
|
||||||
|
Deduplicates by (name, is_loopback), keeping the entry from the
|
||||||
|
highest-priority engine.
|
||||||
|
"""
|
||||||
|
# Collect from all engines, sorted by descending priority
|
||||||
|
engines = [
|
||||||
|
(engine_class.ENGINE_PRIORITY, engine_type, engine_class)
|
||||||
|
for engine_type, engine_class in AudioEngineRegistry.get_all_engines().items()
|
||||||
|
]
|
||||||
|
engines.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
seen: set = set()
|
||||||
|
result = []
|
||||||
|
for _priority, engine_type, engine_class in engines:
|
||||||
|
try:
|
||||||
|
if not engine_class.is_available():
|
||||||
|
continue
|
||||||
|
for dev in engine_class.enumerate_devices():
|
||||||
|
key = (dev.name, dev.is_loopback)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append({
|
||||||
|
"index": dev.index,
|
||||||
|
"name": dev.name,
|
||||||
|
"is_input": dev.is_input,
|
||||||
|
"is_loopback": dev.is_loopback,
|
||||||
|
"channels": dev.channels,
|
||||||
|
"default_samplerate": dev.default_samplerate,
|
||||||
|
"engine_type": engine_type,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error enumerating devices for engine '{engine_type}': {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def enumerate_devices_by_engine() -> Dict[str, List[dict]]:
|
||||||
|
"""List available audio devices grouped by engine type.
|
||||||
|
|
||||||
|
Unlike enumerate_devices(), does NOT deduplicate across engines.
|
||||||
|
Each engine's devices are returned with their engine-specific indices.
|
||||||
|
"""
|
||||||
|
result: Dict[str, List[dict]] = {}
|
||||||
|
for engine_type, engine_class in AudioEngineRegistry.get_all_engines().items():
|
||||||
|
try:
|
||||||
|
if not engine_class.is_available():
|
||||||
|
continue
|
||||||
|
devices = []
|
||||||
|
for dev in engine_class.enumerate_devices():
|
||||||
|
devices.append({
|
||||||
|
"index": dev.index,
|
||||||
|
"name": dev.name,
|
||||||
|
"is_input": dev.is_input,
|
||||||
|
"is_loopback": dev.is_loopback,
|
||||||
|
"channels": dev.channels,
|
||||||
|
"default_samplerate": dev.default_samplerate,
|
||||||
|
"engine_type": engine_type,
|
||||||
|
})
|
||||||
|
result[engine_type] = devices
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error enumerating devices for engine '{engine_type}': {e}")
|
||||||
|
return result
|
||||||
165
server/src/wled_controller/core/audio/base.py
Normal file
165
server/src/wled_controller/core/audio/base.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""Base classes for audio capture engines."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioDeviceInfo:
|
||||||
|
"""Information about an audio device."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
name: str
|
||||||
|
is_input: bool
|
||||||
|
is_loopback: bool
|
||||||
|
channels: int
|
||||||
|
default_samplerate: float
|
||||||
|
|
||||||
|
|
||||||
|
class AudioCaptureStreamBase(ABC):
|
||||||
|
"""Abstract base class for an audio capture session.
|
||||||
|
|
||||||
|
An AudioCaptureStreamBase is a stateful session bound to a specific
|
||||||
|
audio device. It holds device-specific resources and provides raw
|
||||||
|
audio chunk reading.
|
||||||
|
|
||||||
|
Created by AudioCaptureEngine.create_stream().
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
stream = engine.create_stream(device_index, is_loopback, config)
|
||||||
|
stream.initialize()
|
||||||
|
chunk = stream.read_chunk()
|
||||||
|
stream.cleanup()
|
||||||
|
|
||||||
|
Or via context manager:
|
||||||
|
with engine.create_stream(device_index, is_loopback, config) as stream:
|
||||||
|
chunk = stream.read_chunk()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_index: int,
|
||||||
|
is_loopback: bool,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
):
|
||||||
|
self.device_index = device_index
|
||||||
|
self.is_loopback = is_loopback
|
||||||
|
self.config = config
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def channels(self) -> int:
|
||||||
|
"""Number of audio channels in the stream."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def sample_rate(self) -> int:
|
||||||
|
"""Sample rate of the audio stream."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def chunk_size(self) -> int:
|
||||||
|
"""Number of frames per read_chunk() call."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def initialize(self) -> None:
|
||||||
|
"""Initialize audio capture resources.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If initialization fails
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Release all audio capture resources."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def read_chunk(self) -> Optional[np.ndarray]:
|
||||||
|
"""Read one chunk of raw audio data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
1-D float32 ndarray of interleaved samples (length = chunk_size * channels),
|
||||||
|
or None if no data available.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If read fails
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Context manager entry — initialize stream."""
|
||||||
|
self.initialize()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Context manager exit — cleanup stream."""
|
||||||
|
self.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class AudioCaptureEngine(ABC):
|
||||||
|
"""Abstract base class for audio capture engines.
|
||||||
|
|
||||||
|
An AudioCaptureEngine is a stateless factory that knows about an audio
|
||||||
|
capture technology. It can enumerate devices, check availability, and
|
||||||
|
create AudioCaptureStreamBase instances.
|
||||||
|
|
||||||
|
All methods are classmethods — no instance creation needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENGINE_TYPE: str = "base"
|
||||||
|
ENGINE_PRIORITY: int = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def is_available(cls) -> bool:
|
||||||
|
"""Check if this engine is available on the current system."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def get_default_config(cls) -> Dict[str, Any]:
|
||||||
|
"""Get default configuration for this engine."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
||||||
|
"""Get list of available audio devices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of AudioDeviceInfo objects
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If unable to detect devices
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def create_stream(
|
||||||
|
cls,
|
||||||
|
device_index: int,
|
||||||
|
is_loopback: bool,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
) -> AudioCaptureStreamBase:
|
||||||
|
"""Create a capture stream for the specified device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_index: Index of audio device
|
||||||
|
is_loopback: Whether to capture loopback audio
|
||||||
|
config: Engine-specific configuration dict
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Uninitialized AudioCaptureStreamBase. Caller must call
|
||||||
|
initialize() or use as context manager.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
153
server/src/wled_controller/core/audio/demo_engine.py
Normal file
153
server/src/wled_controller/core/audio/demo_engine.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Demo audio engine — virtual audio devices with synthetic audio data."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.config import is_demo_mode
|
||||||
|
from wled_controller.core.audio.base import (
|
||||||
|
AudioCaptureEngine,
|
||||||
|
AudioCaptureStreamBase,
|
||||||
|
AudioDeviceInfo,
|
||||||
|
)
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Virtual audio device definitions: (name, is_loopback, channels, samplerate)
|
||||||
|
_VIRTUAL_DEVICES = [
|
||||||
|
("Demo Microphone", False, 2, 44100.0),
|
||||||
|
("Demo System Audio", True, 2, 44100.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DemoAudioCaptureStream(AudioCaptureStreamBase):
|
||||||
|
"""Demo audio capture stream that produces synthetic music-like audio.
|
||||||
|
|
||||||
|
Generates a mix of sine waves with slowly varying frequencies to
|
||||||
|
simulate beat-like patterns suitable for audio-reactive visualizations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device_index: int, is_loopback: bool, config: Dict[str, Any]):
|
||||||
|
super().__init__(device_index, is_loopback, config)
|
||||||
|
self._channels = 2
|
||||||
|
self._sample_rate = 44100
|
||||||
|
self._chunk_size = 1024
|
||||||
|
self._phase = 0.0 # Accumulated phase in samples for continuity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self) -> int:
|
||||||
|
return self._channels
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sample_rate(self) -> int:
|
||||||
|
return self._sample_rate
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chunk_size(self) -> int:
|
||||||
|
return self._chunk_size
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
self._phase = 0.0
|
||||||
|
self._initialized = True
|
||||||
|
logger.info(
|
||||||
|
f"Demo audio stream initialized "
|
||||||
|
f"(device={self.device_index}, loopback={self.is_loopback})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
self._initialized = False
|
||||||
|
logger.info(f"Demo audio stream cleaned up (device={self.device_index})")
|
||||||
|
|
||||||
|
def read_chunk(self) -> Optional[np.ndarray]:
|
||||||
|
if not self._initialized:
|
||||||
|
return None
|
||||||
|
|
||||||
|
t_now = time.time()
|
||||||
|
n = self._chunk_size
|
||||||
|
sr = self._sample_rate
|
||||||
|
|
||||||
|
# Sample indices for this chunk (continuous across calls)
|
||||||
|
t = (self._phase + np.arange(n, dtype=np.float64)) / sr
|
||||||
|
self._phase += n
|
||||||
|
|
||||||
|
# --- Synthetic "music" signal ---
|
||||||
|
# Bass drum: ~80 Hz with slow amplitude envelope (~2 Hz beat)
|
||||||
|
bass_freq = 80.0
|
||||||
|
beat_rate = 2.0 # beats per second
|
||||||
|
bass_env = np.maximum(0.0, np.sin(2.0 * np.pi * beat_rate * t)) ** 4
|
||||||
|
bass = 0.5 * bass_env * np.sin(2.0 * np.pi * bass_freq * t)
|
||||||
|
|
||||||
|
# Mid-range tone: slowly sweeping between 300-600 Hz
|
||||||
|
mid_freq = 450.0 + 150.0 * np.sin(2.0 * np.pi * 0.1 * t_now)
|
||||||
|
mid = 0.25 * np.sin(2.0 * np.pi * mid_freq * t)
|
||||||
|
|
||||||
|
# High shimmer: ~3 kHz with faster modulation
|
||||||
|
hi_freq = 3000.0 + 500.0 * np.sin(2.0 * np.pi * 0.3 * t_now)
|
||||||
|
hi_env = 0.5 + 0.5 * np.sin(2.0 * np.pi * 4.0 * t)
|
||||||
|
hi = 0.1 * hi_env * np.sin(2.0 * np.pi * hi_freq * t)
|
||||||
|
|
||||||
|
# Mix mono signal
|
||||||
|
mono = (bass + mid + hi).astype(np.float32)
|
||||||
|
|
||||||
|
# Interleave stereo (identical L/R)
|
||||||
|
stereo = np.empty(n * self._channels, dtype=np.float32)
|
||||||
|
stereo[0::2] = mono
|
||||||
|
stereo[1::2] = mono
|
||||||
|
|
||||||
|
return stereo
|
||||||
|
|
||||||
|
|
||||||
|
class DemoAudioEngine(AudioCaptureEngine):
|
||||||
|
"""Virtual audio engine for demo mode.
|
||||||
|
|
||||||
|
Provides virtual audio devices and produces synthetic audio data
|
||||||
|
so the full audio-reactive pipeline works without real audio hardware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENGINE_TYPE = "demo"
|
||||||
|
ENGINE_PRIORITY = 1000 # Highest priority in demo mode
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_available(cls) -> bool:
|
||||||
|
return is_demo_mode()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default_config(cls) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sample_rate": 44100,
|
||||||
|
"chunk_size": 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
||||||
|
devices = []
|
||||||
|
for idx, (name, is_loopback, channels, samplerate) in enumerate(_VIRTUAL_DEVICES):
|
||||||
|
devices.append(AudioDeviceInfo(
|
||||||
|
index=idx,
|
||||||
|
name=name,
|
||||||
|
is_input=True,
|
||||||
|
is_loopback=is_loopback,
|
||||||
|
channels=channels,
|
||||||
|
default_samplerate=samplerate,
|
||||||
|
))
|
||||||
|
logger.debug(f"Demo audio engine: {len(devices)} virtual device(s)")
|
||||||
|
return devices
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_stream(
|
||||||
|
cls,
|
||||||
|
device_index: int,
|
||||||
|
is_loopback: bool,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
) -> DemoAudioCaptureStream:
|
||||||
|
if device_index < 0 or device_index >= len(_VIRTUAL_DEVICES):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid demo audio device index {device_index}. "
|
||||||
|
f"Available: 0-{len(_VIRTUAL_DEVICES) - 1}"
|
||||||
|
)
|
||||||
|
merged = {**cls.get_default_config(), **config}
|
||||||
|
return DemoAudioCaptureStream(device_index, is_loopback, merged)
|
||||||
168
server/src/wled_controller/core/audio/factory.py
Normal file
168
server/src/wled_controller/core/audio/factory.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Engine registry and factory for audio capture engines."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Type
|
||||||
|
|
||||||
|
from wled_controller.core.audio.base import AudioCaptureEngine, AudioCaptureStreamBase
|
||||||
|
from wled_controller.config import is_demo_mode
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioEngineRegistry:
|
||||||
|
"""Registry for available audio capture engines.
|
||||||
|
|
||||||
|
Maintains a registry of all audio engine implementations
|
||||||
|
and provides factory methods for creating capture streams.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_engines: Dict[str, Type[AudioCaptureEngine]] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, engine_class: Type[AudioCaptureEngine]):
|
||||||
|
"""Register an audio capture engine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine_class: Engine class to register (must inherit from AudioCaptureEngine)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If engine_class is not a subclass of AudioCaptureEngine
|
||||||
|
"""
|
||||||
|
if not issubclass(engine_class, AudioCaptureEngine):
|
||||||
|
raise ValueError(f"{engine_class} must be a subclass of AudioCaptureEngine")
|
||||||
|
|
||||||
|
engine_type = engine_class.ENGINE_TYPE
|
||||||
|
if engine_type == "base":
|
||||||
|
raise ValueError("Cannot register base engine type")
|
||||||
|
|
||||||
|
if engine_type in cls._engines:
|
||||||
|
logger.warning(f"Audio engine '{engine_type}' already registered, overwriting")
|
||||||
|
|
||||||
|
cls._engines[engine_type] = engine_class
|
||||||
|
logger.info(f"Registered audio engine: {engine_type}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_engine(cls, engine_type: str) -> Type[AudioCaptureEngine]:
|
||||||
|
"""Get engine class by type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine_type: Engine type identifier (e.g., "wasapi", "sounddevice")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Engine class
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If engine type not found
|
||||||
|
"""
|
||||||
|
if engine_type not in cls._engines:
|
||||||
|
available = ", ".join(cls._engines.keys()) or "none"
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown audio engine type: '{engine_type}'. Available engines: {available}"
|
||||||
|
)
|
||||||
|
return cls._engines[engine_type]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_available_engines(cls) -> List[str]:
|
||||||
|
"""Get list of available engine types on this system.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of engine type identifiers that are available
|
||||||
|
"""
|
||||||
|
demo = is_demo_mode()
|
||||||
|
available = []
|
||||||
|
for engine_type, engine_class in cls._engines.items():
|
||||||
|
try:
|
||||||
|
# In demo mode, only demo engines are available
|
||||||
|
if demo and engine_type != "demo":
|
||||||
|
continue
|
||||||
|
if engine_class.is_available():
|
||||||
|
available.append(engine_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error checking availability for audio engine '{engine_type}': {e}"
|
||||||
|
)
|
||||||
|
return available
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_best_available_engine(cls) -> Optional[str]:
|
||||||
|
"""Get the highest-priority available engine type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Engine type string, or None if no engines are available.
|
||||||
|
"""
|
||||||
|
demo = is_demo_mode()
|
||||||
|
best_type = None
|
||||||
|
best_priority = -1
|
||||||
|
for engine_type, engine_class in cls._engines.items():
|
||||||
|
try:
|
||||||
|
if demo and engine_type != "demo":
|
||||||
|
continue
|
||||||
|
if engine_class.is_available() and engine_class.ENGINE_PRIORITY > best_priority:
|
||||||
|
best_priority = engine_class.ENGINE_PRIORITY
|
||||||
|
best_type = engine_type
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error checking availability for audio engine '{engine_type}': {e}"
|
||||||
|
)
|
||||||
|
return best_type
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all_engines(cls) -> Dict[str, Type[AudioCaptureEngine]]:
|
||||||
|
"""Get all registered engines (available or not).
|
||||||
|
|
||||||
|
In demo mode, only demo engines are returned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping engine type to engine class
|
||||||
|
"""
|
||||||
|
if is_demo_mode():
|
||||||
|
return {k: v for k, v in cls._engines.items() if k == "demo"}
|
||||||
|
return cls._engines.copy()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_stream(
|
||||||
|
cls,
|
||||||
|
engine_type: str,
|
||||||
|
device_index: int,
|
||||||
|
is_loopback: bool,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
) -> AudioCaptureStreamBase:
|
||||||
|
"""Create an AudioCaptureStreamBase for the specified engine and device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine_type: Engine type identifier
|
||||||
|
device_index: Audio device index
|
||||||
|
is_loopback: Whether to capture loopback audio
|
||||||
|
config: Engine-specific configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Uninitialized AudioCaptureStreamBase instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If engine type not found or not available
|
||||||
|
"""
|
||||||
|
engine_class = cls.get_engine(engine_type)
|
||||||
|
|
||||||
|
if not engine_class.is_available():
|
||||||
|
raise ValueError(
|
||||||
|
f"Audio engine '{engine_type}' is not available on this system"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stream = engine_class.create_stream(device_index, is_loopback, config)
|
||||||
|
logger.debug(
|
||||||
|
f"Created audio stream: {engine_type} "
|
||||||
|
f"(device={device_index}, loopback={is_loopback})"
|
||||||
|
)
|
||||||
|
return stream
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create stream for audio engine '{engine_type}': {e}")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to create stream for audio engine '{engine_type}': {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_registry(cls):
|
||||||
|
"""Clear all registered engines (for testing)."""
|
||||||
|
cls._engines.clear()
|
||||||
|
logger.debug("Cleared audio engine registry")
|
||||||
159
server/src/wled_controller/core/audio/sounddevice_engine.py
Normal file
159
server/src/wled_controller/core/audio/sounddevice_engine.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""Sounddevice audio capture engine (cross-platform, via PortAudio)."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.audio.base import (
|
||||||
|
AudioCaptureEngine,
|
||||||
|
AudioCaptureStreamBase,
|
||||||
|
AudioDeviceInfo,
|
||||||
|
)
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SounddeviceCaptureStream(AudioCaptureStreamBase):
|
||||||
|
"""Audio capture stream using sounddevice (PortAudio)."""
|
||||||
|
|
||||||
|
def __init__(self, device_index: int, is_loopback: bool, config: Dict[str, Any]):
|
||||||
|
super().__init__(device_index, is_loopback, config)
|
||||||
|
self._sd_stream = None
|
||||||
|
self._channels = config.get("channels", 2)
|
||||||
|
self._sample_rate = config.get("sample_rate", 44100)
|
||||||
|
self._chunk_size = config.get("chunk_size", 2048)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self) -> int:
|
||||||
|
return self._channels
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sample_rate(self) -> int:
|
||||||
|
return self._sample_rate
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chunk_size(self) -> int:
|
||||||
|
return self._chunk_size
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import sounddevice as sd
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError("sounddevice is not installed — sounddevice engine unavailable")
|
||||||
|
|
||||||
|
# Resolve device
|
||||||
|
device_id = self.device_index if self.device_index >= 0 else None
|
||||||
|
if device_id is not None:
|
||||||
|
dev_info = sd.query_devices(device_id)
|
||||||
|
self._channels = min(self._channels, int(dev_info["max_input_channels"]))
|
||||||
|
if self._channels < 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Device {device_id} ({dev_info['name']}) has no input channels"
|
||||||
|
)
|
||||||
|
self._sample_rate = int(dev_info["default_samplerate"])
|
||||||
|
|
||||||
|
self._sd_stream = sd.InputStream(
|
||||||
|
device=device_id,
|
||||||
|
channels=self._channels,
|
||||||
|
samplerate=self._sample_rate,
|
||||||
|
blocksize=self._chunk_size,
|
||||||
|
dtype="float32",
|
||||||
|
)
|
||||||
|
self._sd_stream.start()
|
||||||
|
self._initialized = True
|
||||||
|
logger.info(
|
||||||
|
f"sounddevice stream opened: device={device_id} loopback={self.is_loopback} "
|
||||||
|
f"channels={self._channels} sr={self._sample_rate}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
if self._sd_stream is not None:
|
||||||
|
try:
|
||||||
|
self._sd_stream.stop()
|
||||||
|
self._sd_stream.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._sd_stream = None
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def read_chunk(self) -> Optional[np.ndarray]:
|
||||||
|
if self._sd_stream is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# sd.InputStream.read() returns (data, overflowed)
|
||||||
|
data, _ = self._sd_stream.read(self._chunk_size)
|
||||||
|
# data shape: (chunk_size, channels) — flatten to interleaved 1-D
|
||||||
|
return data.flatten().astype(np.float32)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"sounddevice read error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SounddeviceEngine(AudioCaptureEngine):
|
||||||
|
"""Sounddevice (PortAudio) audio capture engine — cross-platform."""
|
||||||
|
|
||||||
|
ENGINE_TYPE = "sounddevice"
|
||||||
|
ENGINE_PRIORITY = 5
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_available(cls) -> bool:
|
||||||
|
try:
|
||||||
|
import sounddevice # noqa: F401
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default_config(cls) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sample_rate": 44100,
|
||||||
|
"chunk_size": 2048,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
||||||
|
try:
|
||||||
|
import sounddevice as sd
|
||||||
|
except ImportError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
devices = sd.query_devices()
|
||||||
|
result = []
|
||||||
|
for i, dev in enumerate(devices):
|
||||||
|
max_in = int(dev["max_input_channels"])
|
||||||
|
if max_in < 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = dev["name"]
|
||||||
|
# On PulseAudio/PipeWire, monitor sources are loopback-capable
|
||||||
|
is_loopback = "monitor" in name.lower()
|
||||||
|
|
||||||
|
result.append(AudioDeviceInfo(
|
||||||
|
index=i,
|
||||||
|
name=name,
|
||||||
|
is_input=True,
|
||||||
|
is_loopback=is_loopback,
|
||||||
|
channels=max_in,
|
||||||
|
default_samplerate=dev["default_samplerate"],
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to enumerate sounddevice devices: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_stream(
|
||||||
|
cls,
|
||||||
|
device_index: int,
|
||||||
|
is_loopback: bool,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
) -> SounddeviceCaptureStream:
|
||||||
|
merged = {**cls.get_default_config(), **config}
|
||||||
|
return SounddeviceCaptureStream(device_index, is_loopback, merged)
|
||||||
237
server/src/wled_controller/core/audio/wasapi_engine.py
Normal file
237
server/src/wled_controller/core/audio/wasapi_engine.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"""WASAPI audio capture engine (Windows only, via PyAudioWPatch)."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.audio.base import (
|
||||||
|
AudioCaptureEngine,
|
||||||
|
AudioCaptureStreamBase,
|
||||||
|
AudioDeviceInfo,
|
||||||
|
)
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WasapiCaptureStream(AudioCaptureStreamBase):
|
||||||
|
"""Audio capture stream using PyAudioWPatch (WASAPI)."""
|
||||||
|
|
||||||
|
def __init__(self, device_index: int, is_loopback: bool, config: Dict[str, Any]):
|
||||||
|
super().__init__(device_index, is_loopback, config)
|
||||||
|
self._pa = None
|
||||||
|
self._stream = None
|
||||||
|
self._channels = config.get("channels", 2)
|
||||||
|
self._sample_rate = config.get("sample_rate", 44100)
|
||||||
|
self._chunk_size = config.get("chunk_size", 2048)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self) -> int:
|
||||||
|
return self._channels
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sample_rate(self) -> int:
|
||||||
|
return self._sample_rate
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chunk_size(self) -> int:
|
||||||
|
return self._chunk_size
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pyaudiowpatch as pyaudio
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError("PyAudioWPatch is not installed — WASAPI engine unavailable")
|
||||||
|
|
||||||
|
self._pa = pyaudio.PyAudio()
|
||||||
|
|
||||||
|
if self.is_loopback:
|
||||||
|
loopback_device = self._find_loopback_device(self._pa, self.device_index)
|
||||||
|
if loopback_device is None:
|
||||||
|
self._pa.terminate()
|
||||||
|
self._pa = None
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No loopback device found for output device {self.device_index}"
|
||||||
|
)
|
||||||
|
device_idx = loopback_device["index"]
|
||||||
|
self._channels = loopback_device["maxInputChannels"]
|
||||||
|
self._sample_rate = int(loopback_device["defaultSampleRate"])
|
||||||
|
else:
|
||||||
|
device_idx = self.device_index if self.device_index >= 0 else None
|
||||||
|
if device_idx is not None:
|
||||||
|
dev_info = self._pa.get_device_info_by_index(device_idx)
|
||||||
|
self._channels = max(1, dev_info["maxInputChannels"])
|
||||||
|
self._sample_rate = int(dev_info["defaultSampleRate"])
|
||||||
|
|
||||||
|
self._stream = self._pa.open(
|
||||||
|
format=pyaudio.paFloat32,
|
||||||
|
channels=self._channels,
|
||||||
|
rate=self._sample_rate,
|
||||||
|
input=True,
|
||||||
|
input_device_index=device_idx,
|
||||||
|
frames_per_buffer=self._chunk_size,
|
||||||
|
)
|
||||||
|
self._initialized = True
|
||||||
|
logger.info(
|
||||||
|
f"WASAPI stream opened: device={device_idx} loopback={self.is_loopback} "
|
||||||
|
f"channels={self._channels} sr={self._sample_rate}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
if self._stream is not None:
|
||||||
|
try:
|
||||||
|
self._stream.stop_stream()
|
||||||
|
self._stream.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._stream = None
|
||||||
|
if self._pa is not None:
|
||||||
|
try:
|
||||||
|
self._pa.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._pa = None
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def read_chunk(self) -> Optional[np.ndarray]:
|
||||||
|
if self._stream is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw_data = self._stream.read(self._chunk_size, exception_on_overflow=False)
|
||||||
|
return np.frombuffer(raw_data, dtype=np.float32)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"WASAPI read error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_loopback_device(pa, output_device_index: int) -> Optional[dict]:
|
||||||
|
"""Find the PyAudioWPatch loopback device for a given output device."""
|
||||||
|
try:
|
||||||
|
first_loopback = None
|
||||||
|
for loopback in pa.get_loopback_device_info_generator():
|
||||||
|
if first_loopback is None:
|
||||||
|
first_loopback = loopback
|
||||||
|
|
||||||
|
if output_device_index < 0:
|
||||||
|
return loopback
|
||||||
|
|
||||||
|
target_info = pa.get_device_info_by_index(output_device_index)
|
||||||
|
if target_info["name"] in loopback["name"]:
|
||||||
|
return loopback
|
||||||
|
|
||||||
|
return first_loopback
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding loopback device: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class WasapiEngine(AudioCaptureEngine):
|
||||||
|
"""WASAPI audio capture engine (Windows only)."""
|
||||||
|
|
||||||
|
ENGINE_TYPE = "wasapi"
|
||||||
|
ENGINE_PRIORITY = 10
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_available(cls) -> bool:
|
||||||
|
try:
|
||||||
|
import pyaudiowpatch # noqa: F401
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default_config(cls) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sample_rate": 44100,
|
||||||
|
"chunk_size": 2048,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
||||||
|
try:
|
||||||
|
import pyaudiowpatch as pyaudio
|
||||||
|
except ImportError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
pa = None
|
||||||
|
try:
|
||||||
|
pa = pyaudio.PyAudio()
|
||||||
|
wasapi_info = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
||||||
|
wasapi_idx = wasapi_info["index"]
|
||||||
|
|
||||||
|
result = []
|
||||||
|
loopback_names: set = set()
|
||||||
|
device_count = pa.get_device_count()
|
||||||
|
|
||||||
|
# First pass: collect input devices. PyAudioWPatch creates
|
||||||
|
# dedicated loopback input endpoints for output devices; these
|
||||||
|
# show up as input devices whose name already contains
|
||||||
|
# "[Loopback]". We mark them as loopback and remember the name
|
||||||
|
# so the second pass won't duplicate them.
|
||||||
|
for i in range(device_count):
|
||||||
|
dev = pa.get_device_info_by_index(i)
|
||||||
|
if dev["hostApi"] != wasapi_idx:
|
||||||
|
continue
|
||||||
|
if dev["maxInputChannels"] <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = dev["name"]
|
||||||
|
is_loopback = "[Loopback]" in name
|
||||||
|
if is_loopback:
|
||||||
|
loopback_names.add(name)
|
||||||
|
|
||||||
|
result.append(AudioDeviceInfo(
|
||||||
|
index=i,
|
||||||
|
name=name,
|
||||||
|
is_input=True,
|
||||||
|
is_loopback=is_loopback,
|
||||||
|
channels=dev["maxInputChannels"],
|
||||||
|
default_samplerate=dev["defaultSampleRate"],
|
||||||
|
))
|
||||||
|
|
||||||
|
# Second pass: add loopback entries for output devices that
|
||||||
|
# don't already have a dedicated loopback input endpoint.
|
||||||
|
for i in range(device_count):
|
||||||
|
dev = pa.get_device_info_by_index(i)
|
||||||
|
if dev["hostApi"] != wasapi_idx:
|
||||||
|
continue
|
||||||
|
if dev["maxOutputChannels"] <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
loopback_name = f"{dev['name']} [Loopback]"
|
||||||
|
if loopback_name in loopback_names:
|
||||||
|
continue # already covered by a dedicated loopback endpoint
|
||||||
|
|
||||||
|
result.append(AudioDeviceInfo(
|
||||||
|
index=i,
|
||||||
|
name=loopback_name,
|
||||||
|
is_input=False,
|
||||||
|
is_loopback=True,
|
||||||
|
channels=dev["maxOutputChannels"],
|
||||||
|
default_samplerate=dev["defaultSampleRate"],
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to enumerate WASAPI devices: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
if pa is not None:
|
||||||
|
try:
|
||||||
|
pa.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_stream(
|
||||||
|
cls,
|
||||||
|
device_index: int,
|
||||||
|
is_loopback: bool,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
) -> WasapiCaptureStream:
|
||||||
|
merged = {**cls.get_default_config(), **config}
|
||||||
|
return WasapiCaptureStream(device_index, is_loopback, merged)
|
||||||
1
server/src/wled_controller/core/automations/__init__.py
Normal file
1
server/src/wled_controller/core/automations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Automation engine — condition evaluation and scene activation."""
|
||||||
441
server/src/wled_controller/core/automations/automation_engine.py
Normal file
441
server/src/wled_controller/core/automations/automation_engine.py
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
"""Automation engine — background loop that evaluates conditions and activates scenes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from wled_controller.core.automations.platform_detector import PlatformDetector
|
||||||
|
from wled_controller.storage.automation import (
|
||||||
|
AlwaysCondition,
|
||||||
|
ApplicationCondition,
|
||||||
|
Automation,
|
||||||
|
Condition,
|
||||||
|
DisplayStateCondition,
|
||||||
|
MQTTCondition,
|
||||||
|
StartupCondition,
|
||||||
|
SystemIdleCondition,
|
||||||
|
TimeOfDayCondition,
|
||||||
|
WebhookCondition,
|
||||||
|
)
|
||||||
|
from wled_controller.storage.automation_store import AutomationStore
|
||||||
|
from wled_controller.storage.scene_preset import ScenePreset
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationEngine:
|
||||||
|
"""Evaluates automation conditions and activates/deactivates scene presets."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
automation_store: AutomationStore,
|
||||||
|
processor_manager,
|
||||||
|
poll_interval: float = 1.0,
|
||||||
|
mqtt_service=None,
|
||||||
|
scene_preset_store=None,
|
||||||
|
target_store=None,
|
||||||
|
device_store=None,
|
||||||
|
):
|
||||||
|
self._store = automation_store
|
||||||
|
self._manager = processor_manager
|
||||||
|
self._poll_interval = poll_interval
|
||||||
|
self._detector = PlatformDetector()
|
||||||
|
self._mqtt_service = mqtt_service
|
||||||
|
self._scene_preset_store = scene_preset_store
|
||||||
|
self._target_store = target_store
|
||||||
|
self._device_store = device_store
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._eval_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Runtime state (not persisted)
|
||||||
|
# automation_id → True when automation is currently active
|
||||||
|
self._active_automations: Dict[str, bool] = {}
|
||||||
|
# automation_id → snapshot captured before activation (for "revert" mode)
|
||||||
|
self._pre_activation_snapshots: Dict[str, ScenePreset] = {}
|
||||||
|
# automation_id → datetime of last activation / deactivation
|
||||||
|
self._last_activated: Dict[str, datetime] = {}
|
||||||
|
self._last_deactivated: Dict[str, datetime] = {}
|
||||||
|
# webhook_token → bool (volatile state set by webhook calls)
|
||||||
|
self._webhook_states: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._task is not None:
|
||||||
|
return
|
||||||
|
self._task = asyncio.create_task(self._poll_loop())
|
||||||
|
logger.info("Automation engine started")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._task is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
# Deactivate all automations
|
||||||
|
for automation_id in list(self._active_automations.keys()):
|
||||||
|
await self._deactivate_automation(automation_id)
|
||||||
|
|
||||||
|
logger.info("Automation engine stopped")
|
||||||
|
|
||||||
|
async def _poll_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self._evaluate_all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Automation evaluation error: {e}", exc_info=True)
|
||||||
|
await asyncio.sleep(self._poll_interval)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _evaluate_all(self) -> None:
|
||||||
|
async with self._eval_lock:
|
||||||
|
await self._evaluate_all_locked()
|
||||||
|
|
||||||
|
def _detect_all_sync(
|
||||||
|
self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool,
|
||||||
|
needs_idle: bool, needs_display_state: bool,
|
||||||
|
) -> tuple:
|
||||||
|
"""Run all platform detection in a single thread call.
|
||||||
|
|
||||||
|
Batching detection calls into one executor submission reduces
|
||||||
|
event-loop wake-ups, minimising asyncio.sleep() jitter in
|
||||||
|
latency-sensitive processing loops.
|
||||||
|
"""
|
||||||
|
running_procs = self._detector._get_running_processes_sync() if needs_running else set()
|
||||||
|
if needs_topmost:
|
||||||
|
topmost_proc, topmost_fullscreen = self._detector._get_topmost_process_sync()
|
||||||
|
else:
|
||||||
|
topmost_proc, topmost_fullscreen = None, False
|
||||||
|
fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
|
||||||
|
idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None
|
||||||
|
display_state = self._detector._get_display_power_state_sync() if needs_display_state else None
|
||||||
|
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state
|
||||||
|
|
||||||
|
async def _evaluate_all_locked(self) -> None:
|
||||||
|
automations = self._store.get_all_automations()
|
||||||
|
if not automations:
|
||||||
|
# No automations — deactivate any stale state
|
||||||
|
for aid in list(self._active_automations.keys()):
|
||||||
|
await self._deactivate_automation(aid)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine which detection methods are actually needed
|
||||||
|
match_types_used: set = set()
|
||||||
|
needs_idle = False
|
||||||
|
needs_display_state = False
|
||||||
|
for a in automations:
|
||||||
|
if a.enabled:
|
||||||
|
for c in a.conditions:
|
||||||
|
if isinstance(c, ApplicationCondition):
|
||||||
|
match_types_used.add(c.match_type)
|
||||||
|
elif isinstance(c, SystemIdleCondition):
|
||||||
|
needs_idle = True
|
||||||
|
elif isinstance(c, DisplayStateCondition):
|
||||||
|
needs_display_state = True
|
||||||
|
|
||||||
|
needs_running = "running" in match_types_used
|
||||||
|
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
|
||||||
|
needs_fullscreen = "fullscreen" in match_types_used
|
||||||
|
|
||||||
|
# Single executor call for all platform detection
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
(running_procs, topmost_proc, topmost_fullscreen,
|
||||||
|
fullscreen_procs, idle_seconds, display_state) = (
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, self._detect_all_sync,
|
||||||
|
needs_running, needs_topmost, needs_fullscreen,
|
||||||
|
needs_idle, needs_display_state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
active_automation_ids = set()
|
||||||
|
|
||||||
|
for automation in automations:
|
||||||
|
should_be_active = (
|
||||||
|
automation.enabled
|
||||||
|
and (len(automation.conditions) == 0
|
||||||
|
or self._evaluate_conditions(
|
||||||
|
automation, running_procs, topmost_proc, topmost_fullscreen,
|
||||||
|
fullscreen_procs, idle_seconds, display_state))
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = automation.id in self._active_automations
|
||||||
|
|
||||||
|
if should_be_active and not is_active:
|
||||||
|
await self._activate_automation(automation)
|
||||||
|
active_automation_ids.add(automation.id)
|
||||||
|
elif should_be_active and is_active:
|
||||||
|
active_automation_ids.add(automation.id)
|
||||||
|
elif not should_be_active and is_active:
|
||||||
|
await self._deactivate_automation(automation.id)
|
||||||
|
|
||||||
|
# Deactivate automations that were removed from store while active
|
||||||
|
for aid in list(self._active_automations.keys()):
|
||||||
|
if aid not in active_automation_ids:
|
||||||
|
await self._deactivate_automation(aid)
|
||||||
|
|
||||||
|
def _evaluate_conditions(
|
||||||
|
self, automation: Automation, running_procs: Set[str],
|
||||||
|
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
||||||
|
fullscreen_procs: Set[str],
|
||||||
|
idle_seconds: Optional[float], display_state: Optional[str],
|
||||||
|
) -> bool:
|
||||||
|
results = [
|
||||||
|
self._evaluate_condition(
|
||||||
|
c, running_procs, topmost_proc, topmost_fullscreen,
|
||||||
|
fullscreen_procs, idle_seconds, display_state,
|
||||||
|
)
|
||||||
|
for c in automation.conditions
|
||||||
|
]
|
||||||
|
|
||||||
|
if automation.condition_logic == "and":
|
||||||
|
return all(results)
|
||||||
|
return any(results) # "or" is default
|
||||||
|
|
||||||
|
def _evaluate_condition(
|
||||||
|
self, condition: Condition, running_procs: Set[str],
|
||||||
|
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
||||||
|
fullscreen_procs: Set[str],
|
||||||
|
idle_seconds: Optional[float], display_state: Optional[str],
|
||||||
|
) -> bool:
|
||||||
|
if isinstance(condition, (AlwaysCondition, StartupCondition)):
|
||||||
|
return True
|
||||||
|
if isinstance(condition, ApplicationCondition):
|
||||||
|
return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
|
||||||
|
if isinstance(condition, TimeOfDayCondition):
|
||||||
|
return self._evaluate_time_of_day(condition)
|
||||||
|
if isinstance(condition, SystemIdleCondition):
|
||||||
|
return self._evaluate_idle(condition, idle_seconds)
|
||||||
|
if isinstance(condition, DisplayStateCondition):
|
||||||
|
return self._evaluate_display_state(condition, display_state)
|
||||||
|
if isinstance(condition, MQTTCondition):
|
||||||
|
return self._evaluate_mqtt(condition)
|
||||||
|
if isinstance(condition, WebhookCondition):
|
||||||
|
return self._webhook_states.get(condition.token, False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool:
|
||||||
|
now = datetime.now()
|
||||||
|
current = now.hour * 60 + now.minute
|
||||||
|
parts_s = condition.start_time.split(":")
|
||||||
|
parts_e = condition.end_time.split(":")
|
||||||
|
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
||||||
|
end = int(parts_e[0]) * 60 + int(parts_e[1])
|
||||||
|
if start <= end:
|
||||||
|
return start <= current <= end
|
||||||
|
# Overnight range (e.g. 22:00 → 06:00)
|
||||||
|
return current >= start or current <= end
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _evaluate_idle(condition: SystemIdleCondition, idle_seconds: Optional[float]) -> bool:
|
||||||
|
if idle_seconds is None:
|
||||||
|
return False
|
||||||
|
is_idle = idle_seconds >= (condition.idle_minutes * 60)
|
||||||
|
return is_idle if condition.when_idle else not is_idle
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _evaluate_display_state(condition: DisplayStateCondition, display_state: Optional[str]) -> bool:
|
||||||
|
if display_state is None:
|
||||||
|
return False
|
||||||
|
return display_state == condition.state
|
||||||
|
|
||||||
|
def _evaluate_mqtt(self, condition: MQTTCondition) -> bool:
|
||||||
|
if self._mqtt_service is None or not self._mqtt_service.is_connected:
|
||||||
|
return False
|
||||||
|
value = self._mqtt_service.get_last_value(condition.topic)
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if condition.match_mode == "exact":
|
||||||
|
return value == condition.payload
|
||||||
|
if condition.match_mode == "contains":
|
||||||
|
return condition.payload in value
|
||||||
|
if condition.match_mode == "regex":
|
||||||
|
try:
|
||||||
|
return bool(re.search(condition.payload, value))
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _evaluate_app_condition(
|
||||||
|
self,
|
||||||
|
condition: ApplicationCondition,
|
||||||
|
running_procs: Set[str],
|
||||||
|
topmost_proc: Optional[str],
|
||||||
|
topmost_fullscreen: bool,
|
||||||
|
fullscreen_procs: Set[str],
|
||||||
|
) -> bool:
|
||||||
|
if not condition.apps:
|
||||||
|
return False
|
||||||
|
|
||||||
|
apps_lower = [a.lower() for a in condition.apps]
|
||||||
|
|
||||||
|
if condition.match_type == "fullscreen":
|
||||||
|
return any(app in fullscreen_procs for app in apps_lower)
|
||||||
|
|
||||||
|
if condition.match_type == "topmost_fullscreen":
|
||||||
|
if topmost_proc is None or not topmost_fullscreen:
|
||||||
|
return False
|
||||||
|
return any(app == topmost_proc for app in apps_lower)
|
||||||
|
|
||||||
|
if condition.match_type == "topmost":
|
||||||
|
if topmost_proc is None:
|
||||||
|
return False
|
||||||
|
return any(app == topmost_proc for app in apps_lower)
|
||||||
|
|
||||||
|
# Default: "running"
|
||||||
|
return any(app in running_procs for app in apps_lower)
|
||||||
|
|
||||||
|
async def _activate_automation(self, automation: Automation) -> None:
|
||||||
|
if not automation.scene_preset_id:
|
||||||
|
# No scene configured — just mark active (conditions matched but nothing to do)
|
||||||
|
self._active_automations[automation.id] = True
|
||||||
|
self._last_activated[automation.id] = datetime.now(timezone.utc)
|
||||||
|
self._fire_event(automation.id, "activated")
|
||||||
|
logger.info(f"Automation '{automation.name}' activated (no scene configured)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._scene_preset_store or not self._target_store or not self._device_store:
|
||||||
|
logger.warning(f"Automation '{automation.name}' matched but scene stores not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load the scene preset
|
||||||
|
try:
|
||||||
|
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# For "revert" mode, capture current state before activating
|
||||||
|
if automation.deactivation_mode == "revert":
|
||||||
|
from wled_controller.core.scenes.scene_activator import capture_current_snapshot
|
||||||
|
targets = capture_current_snapshot(self._target_store, self._manager)
|
||||||
|
self._pre_activation_snapshots[automation.id] = ScenePreset(
|
||||||
|
id=f"_revert_{automation.id}",
|
||||||
|
name=f"Pre-activation snapshot for {automation.name}",
|
||||||
|
targets=targets,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the scene
|
||||||
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
status, errors = await apply_scene_state(
|
||||||
|
preset, self._target_store, self._manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._active_automations[automation.id] = True
|
||||||
|
self._last_activated[automation.id] = datetime.now(timezone.utc)
|
||||||
|
self._fire_event(automation.id, "activated")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
logger.warning(f"Automation '{automation.name}' activated with errors: {errors}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
|
||||||
|
|
||||||
|
async def _deactivate_automation(self, automation_id: str) -> None:
|
||||||
|
was_active = self._active_automations.pop(automation_id, False)
|
||||||
|
if not was_active:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Look up the automation for deactivation settings
|
||||||
|
try:
|
||||||
|
automation = self._store.get_automation(automation_id)
|
||||||
|
except ValueError:
|
||||||
|
automation = None
|
||||||
|
|
||||||
|
deactivation_mode = automation.deactivation_mode if automation else "none"
|
||||||
|
|
||||||
|
if deactivation_mode == "revert":
|
||||||
|
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||||
|
if snapshot and self._target_store:
|
||||||
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
status, errors = await apply_scene_state(
|
||||||
|
snapshot, self._target_store, self._manager,
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
logger.warning(f"Automation {automation_id} revert errors: {errors}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Automation {automation_id} deactivated (reverted to previous state)")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Automation {automation_id}: no snapshot available for revert")
|
||||||
|
|
||||||
|
elif deactivation_mode == "fallback_scene":
|
||||||
|
fallback_id = automation.deactivation_scene_preset_id if automation else None
|
||||||
|
if fallback_id and self._scene_preset_store and self._target_store:
|
||||||
|
try:
|
||||||
|
fallback = self._scene_preset_store.get_preset(fallback_id)
|
||||||
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
status, errors = await apply_scene_state(
|
||||||
|
fallback, self._target_store, self._manager,
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)")
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found")
|
||||||
|
else:
|
||||||
|
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
|
||||||
|
else:
|
||||||
|
# "none" mode — just clear active state
|
||||||
|
logger.info(f"Automation {automation_id} deactivated")
|
||||||
|
|
||||||
|
self._last_deactivated[automation_id] = datetime.now(timezone.utc)
|
||||||
|
self._fire_event(automation_id, "deactivated")
|
||||||
|
# Clean up any leftover snapshot
|
||||||
|
self._pre_activation_snapshots.pop(automation_id, None)
|
||||||
|
|
||||||
|
def _fire_event(self, automation_id: str, action: str) -> None:
|
||||||
|
try:
|
||||||
|
self._manager.fire_event({
|
||||||
|
"type": "automation_state_changed",
|
||||||
|
"automation_id": automation_id,
|
||||||
|
"action": action,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Automation action failed: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
# ===== Public query methods (used by API) =====
|
||||||
|
|
||||||
|
def get_automation_state(self, automation_id: str) -> dict:
|
||||||
|
"""Get runtime state of a single automation."""
|
||||||
|
is_active = automation_id in self._active_automations
|
||||||
|
return {
|
||||||
|
"is_active": is_active,
|
||||||
|
"last_activated_at": self._last_activated.get(automation_id),
|
||||||
|
"last_deactivated_at": self._last_deactivated.get(automation_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_all_automation_states(self) -> Dict[str, dict]:
|
||||||
|
"""Get runtime states of all automations."""
|
||||||
|
result = {}
|
||||||
|
for automation in self._store.get_all_automations():
|
||||||
|
result[automation.id] = self.get_automation_state(automation.id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def trigger_evaluate(self) -> None:
|
||||||
|
"""Run a single evaluation cycle immediately (used after enabling an automation)."""
|
||||||
|
try:
|
||||||
|
await self._evaluate_all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Immediate automation evaluation error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
async def set_webhook_state(self, token: str, active: bool) -> None:
|
||||||
|
"""Set webhook condition state and trigger immediate evaluation."""
|
||||||
|
self._webhook_states[token] = active
|
||||||
|
await self.trigger_evaluate()
|
||||||
|
|
||||||
|
def get_webhook_state(self, token: str) -> bool:
|
||||||
|
"""Read current webhook state (False if never called)."""
|
||||||
|
return self._webhook_states.get(token, False)
|
||||||
|
|
||||||
|
async def deactivate_if_active(self, automation_id: str) -> None:
|
||||||
|
"""Deactivate an automation immediately (used when disabling/deleting)."""
|
||||||
|
if automation_id in self._active_automations:
|
||||||
|
await self._deactivate_automation(automation_id)
|
||||||
410
server/src/wled_controller/core/automations/platform_detector.py
Normal file
410
server/src/wled_controller/core/automations/platform_detector.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
"""Platform-specific process and window detection.
|
||||||
|
|
||||||
|
Windows: uses wmi for process listing, ctypes for foreground window detection.
|
||||||
|
Non-Windows: graceful degradation (returns empty results).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ctypes
|
||||||
|
import ctypes.wintypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from typing import Optional, Set
|
||||||
|
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_IS_WINDOWS = sys.platform == "win32"
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformDetector:
|
||||||
|
"""Detect running processes and the foreground window's process."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._display_on: bool = True
|
||||||
|
self._display_listener_started = False
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
t = threading.Thread(target=self._display_power_listener, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
# ---- Display power state (event-driven) ----
|
||||||
|
|
||||||
|
def _display_power_listener(self) -> None:
|
||||||
|
"""Background thread: hidden window that receives display power events."""
|
||||||
|
try:
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
|
||||||
|
WNDPROC = ctypes.WINFUNCTYPE(
|
||||||
|
ctypes.c_long,
|
||||||
|
ctypes.wintypes.HWND,
|
||||||
|
ctypes.c_uint,
|
||||||
|
ctypes.wintypes.WPARAM,
|
||||||
|
ctypes.wintypes.LPARAM,
|
||||||
|
)
|
||||||
|
|
||||||
|
WM_POWERBROADCAST = 0x0218
|
||||||
|
PBT_POWERSETTINGCHANGE = 0x8013
|
||||||
|
|
||||||
|
class POWERBROADCAST_SETTING(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("PowerSetting", ctypes.c_ubyte * 16), # GUID
|
||||||
|
("DataLength", ctypes.wintypes.DWORD),
|
||||||
|
("Data", ctypes.c_ubyte * 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
# GUID_CONSOLE_DISPLAY_STATE = {6FE69556-704A-47A0-8F24-C28D936FDA47}
|
||||||
|
GUID_CONSOLE_DISPLAY_STATE = (ctypes.c_ubyte * 16)(
|
||||||
|
0x56, 0x95, 0xE6, 0x6F, 0x4A, 0x70, 0xA0, 0x47,
|
||||||
|
0x8F, 0x24, 0xC2, 0x8D, 0x93, 0x6F, 0xDA, 0x47,
|
||||||
|
)
|
||||||
|
|
||||||
|
def wnd_proc(hwnd, msg, wparam, lparam):
|
||||||
|
if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE:
|
||||||
|
try:
|
||||||
|
setting = ctypes.cast(
|
||||||
|
lparam, ctypes.POINTER(POWERBROADCAST_SETTING)
|
||||||
|
).contents
|
||||||
|
# Data: 0=off, 1=on, 2=dimmed (treat dimmed as on)
|
||||||
|
value = setting.Data[0]
|
||||||
|
self._display_on = value != 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||||
|
|
||||||
|
wnd_proc_cb = WNDPROC(wnd_proc)
|
||||||
|
|
||||||
|
# Register window class
|
||||||
|
class WNDCLASSEXW(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("cbSize", ctypes.c_uint),
|
||||||
|
("style", ctypes.c_uint),
|
||||||
|
("lpfnWndProc", WNDPROC),
|
||||||
|
("cbClsExtra", ctypes.c_int),
|
||||||
|
("cbWndExtra", ctypes.c_int),
|
||||||
|
("hInstance", ctypes.wintypes.HINSTANCE),
|
||||||
|
("hIcon", ctypes.wintypes.HICON),
|
||||||
|
("hCursor", ctypes.wintypes.HANDLE),
|
||||||
|
("hbrBackground", ctypes.wintypes.HBRUSH),
|
||||||
|
("lpszMenuName", ctypes.wintypes.LPCWSTR),
|
||||||
|
("lpszClassName", ctypes.wintypes.LPCWSTR),
|
||||||
|
("hIconSm", ctypes.wintypes.HICON),
|
||||||
|
]
|
||||||
|
|
||||||
|
wc = WNDCLASSEXW()
|
||||||
|
wc.cbSize = ctypes.sizeof(WNDCLASSEXW)
|
||||||
|
wc.lpfnWndProc = wnd_proc_cb
|
||||||
|
wc.lpszClassName = "LedGrabDisplayMonitor"
|
||||||
|
wc.hInstance = ctypes.windll.kernel32.GetModuleHandleW(None)
|
||||||
|
|
||||||
|
atom = user32.RegisterClassExW(ctypes.byref(wc))
|
||||||
|
if not atom:
|
||||||
|
logger.warning("Failed to register display monitor window class")
|
||||||
|
return
|
||||||
|
|
||||||
|
HWND_MESSAGE = ctypes.wintypes.HWND(-3)
|
||||||
|
hwnd = user32.CreateWindowExW(
|
||||||
|
0, wc.lpszClassName, "LedGrab Display Monitor",
|
||||||
|
0, 0, 0, 0, 0, HWND_MESSAGE, None, wc.hInstance, None,
|
||||||
|
)
|
||||||
|
if not hwnd:
|
||||||
|
logger.warning("Failed to create display monitor hidden window")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Register for display power notifications
|
||||||
|
user32.RegisterPowerSettingNotification(
|
||||||
|
hwnd, ctypes.byref(GUID_CONSOLE_DISPLAY_STATE), 0
|
||||||
|
)
|
||||||
|
|
||||||
|
self._display_listener_started = True
|
||||||
|
logger.debug("Display power listener started")
|
||||||
|
|
||||||
|
# Message pump
|
||||||
|
msg = ctypes.wintypes.MSG()
|
||||||
|
while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
|
||||||
|
user32.TranslateMessage(ctypes.byref(msg))
|
||||||
|
user32.DispatchMessageW(ctypes.byref(msg))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Display power listener failed: {e}")
|
||||||
|
|
||||||
|
def _get_display_power_state_sync(self) -> Optional[str]:
|
||||||
|
"""Get display power state: 'on' or 'off'. Returns None if unavailable."""
|
||||||
|
if not _IS_WINDOWS:
|
||||||
|
return None
|
||||||
|
return "on" if self._display_on else "off"
|
||||||
|
|
||||||
|
# ---- System idle detection ----
|
||||||
|
|
||||||
|
def _get_idle_seconds_sync(self) -> Optional[float]:
|
||||||
|
"""Get system idle time in seconds (keyboard/mouse inactivity).
|
||||||
|
|
||||||
|
Returns None if detection is unavailable.
|
||||||
|
"""
|
||||||
|
if not _IS_WINDOWS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
class LASTINPUTINFO(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("cbSize", ctypes.c_uint),
|
||||||
|
("dwTime", ctypes.c_uint),
|
||||||
|
]
|
||||||
|
|
||||||
|
lii = LASTINPUTINFO()
|
||||||
|
lii.cbSize = ctypes.sizeof(LASTINPUTINFO)
|
||||||
|
if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(lii)):
|
||||||
|
return None
|
||||||
|
millis = ctypes.windll.kernel32.GetTickCount() - lii.dwTime
|
||||||
|
return millis / 1000.0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get idle time: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ---- Process detection ----
|
||||||
|
|
||||||
|
def _get_running_processes_sync(self) -> Set[str]:
|
||||||
|
"""Get set of lowercase process names via Win32 EnumProcesses.
|
||||||
|
|
||||||
|
Uses PROCESS_QUERY_LIMITED_INFORMATION + QueryFullProcessImageNameW
|
||||||
|
which is ~300x faster than WMI (~8ms vs ~3s). System services
|
||||||
|
running under protected accounts are not visible, but all
|
||||||
|
user-facing applications are covered.
|
||||||
|
"""
|
||||||
|
if not _IS_WINDOWS:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
psapi = ctypes.windll.psapi
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||||
|
|
||||||
|
# Enumerate all PIDs
|
||||||
|
pid_array = (ctypes.wintypes.DWORD * 2048)()
|
||||||
|
cb_needed = ctypes.wintypes.DWORD()
|
||||||
|
psapi.EnumProcesses(
|
||||||
|
ctypes.byref(pid_array), ctypes.sizeof(pid_array),
|
||||||
|
ctypes.byref(cb_needed),
|
||||||
|
)
|
||||||
|
n_pids = cb_needed.value // ctypes.sizeof(ctypes.wintypes.DWORD)
|
||||||
|
|
||||||
|
procs: Set[str] = set()
|
||||||
|
name_buf = ctypes.create_unicode_buffer(512)
|
||||||
|
|
||||||
|
for i in range(n_pids):
|
||||||
|
pid = pid_array[i]
|
||||||
|
if pid == 0:
|
||||||
|
continue
|
||||||
|
handle = kernel32.OpenProcess(
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION, False, pid,
|
||||||
|
)
|
||||||
|
if not handle:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
buf_size = ctypes.wintypes.DWORD(512)
|
||||||
|
if kernel32.QueryFullProcessImageNameW(
|
||||||
|
handle, 0, name_buf, ctypes.byref(buf_size),
|
||||||
|
):
|
||||||
|
procs.add(os.path.basename(name_buf.value).lower())
|
||||||
|
finally:
|
||||||
|
kernel32.CloseHandle(handle)
|
||||||
|
|
||||||
|
return procs
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to enumerate processes: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def _get_topmost_process_sync(self) -> tuple:
|
||||||
|
"""Get (process_name, is_fullscreen) of the foreground window.
|
||||||
|
|
||||||
|
Returns (None, False) when detection fails.
|
||||||
|
Blocking — call via executor.
|
||||||
|
"""
|
||||||
|
if not _IS_WINDOWS:
|
||||||
|
return (None, False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
psapi = ctypes.windll.psapi
|
||||||
|
|
||||||
|
hwnd = user32.GetForegroundWindow()
|
||||||
|
if not hwnd:
|
||||||
|
return (None, False)
|
||||||
|
|
||||||
|
pid = ctypes.wintypes.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||||
|
if not pid.value:
|
||||||
|
return (None, False)
|
||||||
|
|
||||||
|
PROCESS_QUERY_INFORMATION = 0x0400
|
||||||
|
PROCESS_VM_READ = 0x0010
|
||||||
|
handle = kernel32.OpenProcess(
|
||||||
|
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid.value
|
||||||
|
)
|
||||||
|
if not handle:
|
||||||
|
return (None, False)
|
||||||
|
|
||||||
|
proc_name = None
|
||||||
|
try:
|
||||||
|
buf = ctypes.create_unicode_buffer(512)
|
||||||
|
psapi.GetModuleFileNameExW(handle, None, buf, 512)
|
||||||
|
full_path = buf.value
|
||||||
|
if full_path:
|
||||||
|
proc_name = os.path.basename(full_path).lower()
|
||||||
|
finally:
|
||||||
|
kernel32.CloseHandle(handle)
|
||||||
|
|
||||||
|
if proc_name is None:
|
||||||
|
return (None, False)
|
||||||
|
|
||||||
|
# Check if the foreground window covers its entire monitor
|
||||||
|
is_fullscreen = self._is_window_fullscreen(user32, hwnd)
|
||||||
|
|
||||||
|
return (proc_name, is_fullscreen)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get foreground process: {e}")
|
||||||
|
return (None, False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_window_fullscreen(user32, hwnd) -> bool:
|
||||||
|
"""Check whether *hwnd* covers its monitor completely."""
|
||||||
|
try:
|
||||||
|
# Get window rectangle
|
||||||
|
win_rect = ctypes.wintypes.RECT()
|
||||||
|
user32.GetWindowRect(hwnd, ctypes.byref(win_rect))
|
||||||
|
|
||||||
|
# Get the monitor this window is on
|
||||||
|
MONITOR_DEFAULTTONEAREST = 2
|
||||||
|
hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
|
||||||
|
if not hmon:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# MONITORINFO struct
|
||||||
|
class MONITORINFO(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("cbSize", ctypes.wintypes.DWORD),
|
||||||
|
("rcMonitor", ctypes.wintypes.RECT),
|
||||||
|
("rcWork", ctypes.wintypes.RECT),
|
||||||
|
("dwFlags", ctypes.wintypes.DWORD),
|
||||||
|
]
|
||||||
|
|
||||||
|
mi = MONITORINFO()
|
||||||
|
mi.cbSize = ctypes.sizeof(MONITORINFO)
|
||||||
|
if not user32.GetMonitorInfoW(hmon, ctypes.byref(mi)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
mr = mi.rcMonitor
|
||||||
|
return (
|
||||||
|
win_rect.left <= mr.left
|
||||||
|
and win_rect.top <= mr.top
|
||||||
|
and win_rect.right >= mr.right
|
||||||
|
and win_rect.bottom >= mr.bottom
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_fullscreen_processes_sync(self) -> Set[str]:
|
||||||
|
"""Get set of lowercase process names that have a fullscreen window.
|
||||||
|
|
||||||
|
Enumerates all top-level windows and checks each for fullscreen.
|
||||||
|
Returns process names (lowercase) whose window covers an entire monitor.
|
||||||
|
"""
|
||||||
|
if not _IS_WINDOWS:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
psapi = ctypes.windll.psapi
|
||||||
|
|
||||||
|
PROCESS_QUERY_INFORMATION = 0x0400
|
||||||
|
PROCESS_VM_READ = 0x0010
|
||||||
|
|
||||||
|
fullscreen_procs: Set[str] = set()
|
||||||
|
|
||||||
|
# Callback receives (hwnd, lparam); return True to continue enumeration
|
||||||
|
WNDENUMPROC = ctypes.WINFUNCTYPE(
|
||||||
|
ctypes.wintypes.BOOL,
|
||||||
|
ctypes.wintypes.HWND,
|
||||||
|
ctypes.wintypes.LPARAM,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Skip the desktop and shell windows (always cover the full monitor)
|
||||||
|
desktop_hwnd = user32.GetDesktopWindow()
|
||||||
|
shell_hwnd = user32.GetShellWindow()
|
||||||
|
|
||||||
|
# Get shell process PID to filter all explorer desktop windows
|
||||||
|
shell_pid = 0
|
||||||
|
if shell_hwnd:
|
||||||
|
_spid = ctypes.wintypes.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(shell_hwnd, ctypes.byref(_spid))
|
||||||
|
shell_pid = _spid.value
|
||||||
|
|
||||||
|
GWL_EXSTYLE = -20
|
||||||
|
WS_EX_TOOLWINDOW = 0x00000080
|
||||||
|
WS_EX_NOACTIVATE = 0x08000000
|
||||||
|
|
||||||
|
def _enum_callback(hwnd, _lparam):
|
||||||
|
# Skip invisible windows
|
||||||
|
if not user32.IsWindowVisible(hwnd):
|
||||||
|
return True
|
||||||
|
if hwnd == desktop_hwnd:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Skip tool/overlay/non-activatable windows (system UI, input hosts)
|
||||||
|
ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
|
||||||
|
if ex_style & (WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Quick fullscreen check before expensive process name lookup
|
||||||
|
if not self._is_window_fullscreen(user32, hwnd):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Get PID; skip shell process windows (desktop, taskbar)
|
||||||
|
pid = ctypes.wintypes.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||||
|
if not pid.value or pid.value == shell_pid:
|
||||||
|
return True
|
||||||
|
|
||||||
|
handle = kernel32.OpenProcess(
|
||||||
|
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid.value
|
||||||
|
)
|
||||||
|
if not handle:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
buf = ctypes.create_unicode_buffer(512)
|
||||||
|
psapi.GetModuleFileNameExW(handle, None, buf, 512)
|
||||||
|
full_path = buf.value
|
||||||
|
if full_path:
|
||||||
|
fullscreen_procs.add(os.path.basename(full_path).lower())
|
||||||
|
finally:
|
||||||
|
kernel32.CloseHandle(handle)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
callback = WNDENUMPROC(_enum_callback)
|
||||||
|
user32.EnumWindows(callback, 0)
|
||||||
|
|
||||||
|
return fullscreen_procs
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to enumerate fullscreen windows: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
async def get_running_processes(self) -> Set[str]:
|
||||||
|
"""Get set of lowercase process names (async-safe)."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, self._get_running_processes_sync)
|
||||||
|
|
||||||
|
async def get_topmost_process(self) -> tuple:
|
||||||
|
"""Get (process_name, is_fullscreen) of the foreground window (async-safe)."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, self._get_topmost_process_sync)
|
||||||
|
|
||||||
|
async def get_fullscreen_processes(self) -> Set[str]:
|
||||||
|
"""Get set of process names that have a fullscreen window (async-safe)."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, self._get_fullscreen_processes_sync)
|
||||||
0
server/src/wled_controller/core/backup/__init__.py
Normal file
0
server/src/wled_controller/core/backup/__init__.py
Normal file
233
server/src/wled_controller/core/backup/auto_backup.py
Normal file
233
server/src/wled_controller/core/backup/auto_backup.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"""Auto-backup engine — periodic background backups of all configuration stores."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from wled_controller import __version__
|
||||||
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"enabled": False,
|
||||||
|
"interval_hours": 24,
|
||||||
|
"max_backups": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AutoBackupEngine:
|
||||||
|
"""Creates periodic backups of all configuration stores."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings_path: Path,
|
||||||
|
backup_dir: Path,
|
||||||
|
store_map: Dict[str, str],
|
||||||
|
storage_config: Any,
|
||||||
|
):
|
||||||
|
self._settings_path = Path(settings_path)
|
||||||
|
self._backup_dir = Path(backup_dir)
|
||||||
|
self._store_map = store_map
|
||||||
|
self._storage_config = storage_config
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._last_backup_time: Optional[datetime] = None
|
||||||
|
|
||||||
|
self._settings = self._load_settings()
|
||||||
|
self._backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ─── Settings persistence ──────────────────────────────────
|
||||||
|
|
||||||
|
def _load_settings(self) -> dict:
|
||||||
|
if self._settings_path.exists():
|
||||||
|
try:
|
||||||
|
with open(self._settings_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return {**DEFAULT_SETTINGS, **data}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load auto-backup settings: {e}")
|
||||||
|
return dict(DEFAULT_SETTINGS)
|
||||||
|
|
||||||
|
def _save_settings(self) -> None:
|
||||||
|
atomic_write_json(self._settings_path, {
|
||||||
|
"enabled": self._settings["enabled"],
|
||||||
|
"interval_hours": self._settings["interval_hours"],
|
||||||
|
"max_backups": self._settings["max_backups"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# ─── Lifecycle ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._settings["enabled"]:
|
||||||
|
self._start_loop()
|
||||||
|
logger.info(
|
||||||
|
f"Auto-backup engine started (every {self._settings['interval_hours']}h, "
|
||||||
|
f"max {self._settings['max_backups']})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Auto-backup engine initialized (disabled)")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._cancel_loop()
|
||||||
|
logger.info("Auto-backup engine stopped")
|
||||||
|
|
||||||
|
def _start_loop(self) -> None:
|
||||||
|
self._cancel_loop()
|
||||||
|
self._task = asyncio.create_task(self._backup_loop())
|
||||||
|
|
||||||
|
def _cancel_loop(self) -> None:
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
async def _backup_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
# Perform first backup immediately on start
|
||||||
|
await self._perform_backup()
|
||||||
|
self._prune_old_backups()
|
||||||
|
|
||||||
|
interval_secs = self._settings["interval_hours"] * 3600
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval_secs)
|
||||||
|
try:
|
||||||
|
await self._perform_backup()
|
||||||
|
self._prune_old_backups()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auto-backup failed: {e}", exc_info=True)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ─── Backup operations ─────────────────────────────────────
|
||||||
|
|
||||||
|
async def _perform_backup(self) -> None:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, self._perform_backup_sync)
|
||||||
|
|
||||||
|
def _perform_backup_sync(self) -> None:
|
||||||
|
stores = {}
|
||||||
|
for store_key, config_attr in self._store_map.items():
|
||||||
|
file_path = Path(getattr(self._storage_config, 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] = {}
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
backup = {
|
||||||
|
"meta": {
|
||||||
|
"format": "ledgrab-backup",
|
||||||
|
"format_version": 1,
|
||||||
|
"app_version": __version__,
|
||||||
|
"created_at": now.isoformat(),
|
||||||
|
"store_count": len(stores),
|
||||||
|
"auto_backup": True,
|
||||||
|
},
|
||||||
|
"stores": stores,
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp = now.strftime("%Y-%m-%dT%H%M%S")
|
||||||
|
filename = f"ledgrab-autobackup-{timestamp}.json"
|
||||||
|
file_path = self._backup_dir / filename
|
||||||
|
|
||||||
|
content = json.dumps(backup, indent=2, ensure_ascii=False)
|
||||||
|
file_path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
self._last_backup_time = now
|
||||||
|
logger.info(f"Auto-backup created: {filename}")
|
||||||
|
|
||||||
|
def _prune_old_backups(self) -> None:
|
||||||
|
max_backups = self._settings["max_backups"]
|
||||||
|
files = sorted(self._backup_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
|
||||||
|
excess = len(files) - max_backups
|
||||||
|
if excess > 0:
|
||||||
|
for f in files[:excess]:
|
||||||
|
try:
|
||||||
|
f.unlink()
|
||||||
|
logger.info(f"Pruned old backup: {f.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to prune {f.name}: {e}")
|
||||||
|
|
||||||
|
# ─── Public API ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_settings(self) -> dict:
|
||||||
|
next_backup = None
|
||||||
|
if self._settings["enabled"] and self._last_backup_time:
|
||||||
|
from datetime import timedelta
|
||||||
|
next_backup = (
|
||||||
|
self._last_backup_time + timedelta(hours=self._settings["interval_hours"])
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": self._settings["enabled"],
|
||||||
|
"interval_hours": self._settings["interval_hours"],
|
||||||
|
"max_backups": self._settings["max_backups"],
|
||||||
|
"last_backup_time": self._last_backup_time.isoformat() if self._last_backup_time else None,
|
||||||
|
"next_backup_time": next_backup,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def update_settings(self, enabled: bool, interval_hours: float, max_backups: int) -> dict:
|
||||||
|
self._settings["enabled"] = enabled
|
||||||
|
self._settings["interval_hours"] = interval_hours
|
||||||
|
self._settings["max_backups"] = max_backups
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
# Restart or stop the loop
|
||||||
|
if enabled:
|
||||||
|
self._start_loop()
|
||||||
|
logger.info(
|
||||||
|
f"Auto-backup enabled (every {interval_hours}h, max {max_backups})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._cancel_loop()
|
||||||
|
logger.info("Auto-backup disabled")
|
||||||
|
|
||||||
|
# Prune if max_backups was reduced
|
||||||
|
self._prune_old_backups()
|
||||||
|
|
||||||
|
return self.get_settings()
|
||||||
|
|
||||||
|
def list_backups(self) -> List[dict]:
|
||||||
|
backups = []
|
||||||
|
for f in sorted(self._backup_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||||
|
stat = f.stat()
|
||||||
|
backups.append({
|
||||||
|
"filename": f.name,
|
||||||
|
"size_bytes": stat.st_size,
|
||||||
|
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
return backups
|
||||||
|
|
||||||
|
def _safe_backup_path(self, filename: str) -> Path:
|
||||||
|
"""Resolve a backup filename to an absolute path, guarding against path traversal."""
|
||||||
|
if not filename or os.sep in filename or "/" in filename or ".." in filename:
|
||||||
|
raise ValueError("Invalid filename")
|
||||||
|
target = (self._backup_dir / filename).resolve()
|
||||||
|
# Ensure resolved path is still inside the backup directory
|
||||||
|
if not target.is_relative_to(self._backup_dir.resolve()):
|
||||||
|
raise ValueError("Invalid filename")
|
||||||
|
return target
|
||||||
|
|
||||||
|
async def trigger_backup(self) -> dict:
|
||||||
|
"""Manually trigger a backup and prune old ones. Returns the created backup info."""
|
||||||
|
await self._perform_backup()
|
||||||
|
self._prune_old_backups()
|
||||||
|
# Return the most recent backup entry
|
||||||
|
backups = self.list_backups()
|
||||||
|
return backups[0] if backups else {}
|
||||||
|
|
||||||
|
def delete_backup(self, filename: str) -> None:
|
||||||
|
target = self._safe_backup_path(filename)
|
||||||
|
if not target.exists():
|
||||||
|
raise FileNotFoundError(f"Backup not found: {filename}")
|
||||||
|
target.unlink()
|
||||||
|
logger.info(f"Deleted backup: {filename}")
|
||||||
|
|
||||||
|
def get_backup_path(self, filename: str) -> Path:
|
||||||
|
target = self._safe_backup_path(filename)
|
||||||
|
if not target.exists():
|
||||||
|
raise FileNotFoundError(f"Backup not found: {filename}")
|
||||||
|
return target
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
"""Calibration system for mapping screen pixels to LED positions."""
|
"""Calibration system for mapping screen pixels to LED positions."""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Literal, Tuple
|
from typing import Dict, List, Literal, Optional, Set, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from wled_controller.core.capture.screen_capture import (
|
from wled_controller.core.capture.screen_capture import (
|
||||||
BorderPixels,
|
BorderPixels,
|
||||||
get_edge_segments,
|
|
||||||
calculate_average_color,
|
calculate_average_color,
|
||||||
calculate_median_color,
|
calculate_median_color,
|
||||||
calculate_dominant_color,
|
calculate_dominant_color,
|
||||||
@@ -52,6 +51,19 @@ class CalibrationSegment:
|
|||||||
reverse: bool = False
|
reverse: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CalibrationLine:
|
||||||
|
"""One LED line in advanced calibration — references one picture source edge."""
|
||||||
|
|
||||||
|
picture_source_id: str
|
||||||
|
edge: Literal["top", "right", "bottom", "left"]
|
||||||
|
led_count: int
|
||||||
|
span_start: float = 0.0
|
||||||
|
span_end: float = 1.0
|
||||||
|
reverse: bool = False
|
||||||
|
border_width: int = 10
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CalibrationConfig:
|
class CalibrationConfig:
|
||||||
"""Complete calibration configuration.
|
"""Complete calibration configuration.
|
||||||
@@ -60,8 +72,14 @@ class CalibrationConfig:
|
|||||||
are derived at runtime via the `segments` property.
|
are derived at runtime via the `segments` property.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
layout: Literal["clockwise", "counterclockwise"]
|
# Mode: "simple" = 4-edge model (backward compat), "advanced" = generic line list
|
||||||
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
|
mode: Literal["simple", "advanced"] = "simple"
|
||||||
|
# Advanced mode: ordered list of CalibrationLine objects (ignored in simple mode)
|
||||||
|
lines: List[CalibrationLine] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Simple mode fields (also used as defaults for CalibrationConfig constructor)
|
||||||
|
layout: Literal["clockwise", "counterclockwise"] = "clockwise"
|
||||||
|
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = "bottom_left"
|
||||||
offset: int = 0
|
offset: int = 0
|
||||||
leds_top: int = 0
|
leds_top: int = 0
|
||||||
leds_right: int = 0
|
leds_right: int = 0
|
||||||
@@ -110,10 +128,15 @@ class CalibrationConfig:
|
|||||||
|
|
||||||
return segments
|
return segments
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self._cached_segments: List[CalibrationSegment] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def segments(self) -> List[CalibrationSegment]:
|
def segments(self) -> List[CalibrationSegment]:
|
||||||
"""Get derived segment list."""
|
"""Get derived segment list (cached after first call)."""
|
||||||
return self.build_segments()
|
if self._cached_segments is None:
|
||||||
|
self._cached_segments = self.build_segments()
|
||||||
|
return self._cached_segments
|
||||||
|
|
||||||
def get_edge_span(self, edge: str) -> tuple[float, float]:
|
def get_edge_span(self, edge: str) -> tuple[float, float]:
|
||||||
"""Get span (start, end) for a given edge."""
|
"""Get span (start, end) for a given edge."""
|
||||||
@@ -131,6 +154,20 @@ class CalibrationConfig:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If configuration is invalid
|
ValueError: If configuration is invalid
|
||||||
"""
|
"""
|
||||||
|
if self.mode == "advanced":
|
||||||
|
if not self.lines:
|
||||||
|
raise ValueError("Advanced calibration must have at least one line")
|
||||||
|
for i, line in enumerate(self.lines):
|
||||||
|
if line.led_count <= 0:
|
||||||
|
raise ValueError(f"Line {i}: LED count must be positive, got {line.led_count}")
|
||||||
|
if not (0.0 <= line.span_start <= 1.0) or not (0.0 <= line.span_end <= 1.0):
|
||||||
|
raise ValueError(f"Line {i}: span must be in [0.0, 1.0]")
|
||||||
|
if line.span_end <= line.span_start:
|
||||||
|
raise ValueError(f"Line {i}: span_end must be greater than span_start")
|
||||||
|
if line.border_width < 1:
|
||||||
|
raise ValueError(f"Line {i}: border_width must be at least 1")
|
||||||
|
return True
|
||||||
|
|
||||||
total = self.get_total_leds()
|
total = self.get_total_leds()
|
||||||
if total <= 0:
|
if total <= 0:
|
||||||
raise ValueError("Calibration must have at least one LED")
|
raise ValueError("Calibration must have at least one LED")
|
||||||
@@ -150,9 +187,26 @@ class CalibrationConfig:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def get_total_leds(self) -> int:
|
def get_total_leds(self) -> int:
|
||||||
"""Get total number of LEDs across all edges."""
|
"""Get total number of LEDs across all edges/lines."""
|
||||||
|
if self.mode == "advanced":
|
||||||
|
return sum(line.led_count for line in self.lines)
|
||||||
return self.leds_top + self.leds_right + self.leds_bottom + self.leds_left
|
return self.leds_top + self.leds_right + self.leds_bottom + self.leds_left
|
||||||
|
|
||||||
|
def get_required_picture_source_ids(self) -> List[str]:
|
||||||
|
"""Get deduplicated list of picture source IDs referenced by lines.
|
||||||
|
|
||||||
|
Returns empty list for simple mode (the stream provides the source).
|
||||||
|
"""
|
||||||
|
if self.mode != "advanced":
|
||||||
|
return []
|
||||||
|
seen: Set[str] = set()
|
||||||
|
result: List[str] = []
|
||||||
|
for line in self.lines:
|
||||||
|
if line.picture_source_id not in seen:
|
||||||
|
seen.add(line.picture_source_id)
|
||||||
|
result.append(line.picture_source_id)
|
||||||
|
return result
|
||||||
|
|
||||||
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
|
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
|
||||||
"""Get segment configuration for a specific edge."""
|
"""Get segment configuration for a specific edge."""
|
||||||
for seg in self.segments:
|
for seg in self.segments:
|
||||||
@@ -191,8 +245,42 @@ class PixelMapper:
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid interpolation mode: {interpolation_mode}")
|
raise ValueError(f"Invalid interpolation mode: {interpolation_mode}")
|
||||||
|
|
||||||
|
# Pre-allocate LED output buffer (reused every call)
|
||||||
|
total_leds = calibration.get_total_leds()
|
||||||
|
self._total_leds = total_leds
|
||||||
|
self._led_buf = np.zeros((total_leds, 3), dtype=np.uint8)
|
||||||
|
self._use_fast_avg = interpolation_mode == "average"
|
||||||
|
|
||||||
|
# Pre-compute offset-adjusted index arrays per segment (avoids np.roll)
|
||||||
|
offset = calibration.offset % total_leds if total_leds > 0 else 0
|
||||||
|
self._segment_indices: List[np.ndarray] = []
|
||||||
|
for segment in calibration.segments:
|
||||||
|
indices = np.arange(segment.led_start, segment.led_start + segment.led_count)
|
||||||
|
if segment.reverse:
|
||||||
|
indices = indices[::-1]
|
||||||
|
if offset > 0:
|
||||||
|
indices = (indices + offset) % total_leds
|
||||||
|
self._segment_indices.append(indices)
|
||||||
|
|
||||||
|
# Pre-compute Phase 3 skip arrays (static geometry)
|
||||||
|
skip_start = calibration.skip_leds_start
|
||||||
|
skip_end = calibration.skip_leds_end
|
||||||
|
self._skip_start = skip_start
|
||||||
|
self._skip_end = skip_end
|
||||||
|
self._active_count = max(0, total_leds - skip_start - skip_end)
|
||||||
|
if 0 < self._active_count < total_leds:
|
||||||
|
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
|
||||||
|
self._skip_x = np.arange(total_leds, dtype=np.float64)
|
||||||
|
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
|
||||||
|
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
|
||||||
|
else:
|
||||||
|
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
|
||||||
|
|
||||||
|
# Per-edge average computation cache (lazy-initialized on first frame)
|
||||||
|
self._edge_cache: Dict[str, tuple] = {}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Initialized pixel mapper with {self.calibration.get_total_leds()} LEDs "
|
f"Initialized pixel mapper with {total_leds} LEDs "
|
||||||
f"using {interpolation_mode} interpolation"
|
f"using {interpolation_mode} interpolation"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -219,38 +307,73 @@ class PixelMapper:
|
|||||||
edge_pixels = edge_pixels[s:e, :, :]
|
edge_pixels = edge_pixels[s:e, :, :]
|
||||||
return edge_pixels
|
return edge_pixels
|
||||||
|
|
||||||
|
def _map_edge_fallback(
|
||||||
|
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Per-LED color mapping for median/dominant modes. Returns (led_count, 3) uint8."""
|
||||||
|
if edge_name in ("top", "bottom"):
|
||||||
|
edge_len = edge_pixels.shape[1]
|
||||||
|
else:
|
||||||
|
edge_len = edge_pixels.shape[0]
|
||||||
|
|
||||||
|
step = edge_len / led_count
|
||||||
|
result = np.empty((led_count, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
for i in range(led_count):
|
||||||
|
start = int(i * step)
|
||||||
|
end = max(start + 1, int((i + 1) * step))
|
||||||
|
end = min(end, edge_len)
|
||||||
|
|
||||||
|
if edge_name in ("top", "bottom"):
|
||||||
|
segment = edge_pixels[:, start:end, :]
|
||||||
|
else:
|
||||||
|
segment = edge_pixels[start:end, :, :]
|
||||||
|
|
||||||
|
color = self._calc_color(segment)
|
||||||
|
result[i] = color
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def _map_edge_average(
|
def _map_edge_average(
|
||||||
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
|
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8."""
|
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8.
|
||||||
# Reduce border dimension → 1D array of shape (edge_length, 3)
|
|
||||||
|
Uses pre-allocated cumsum/mean buffers (lazy-initialized per edge) to
|
||||||
|
avoid per-frame allocations that cause GC-induced timing spikes.
|
||||||
|
"""
|
||||||
if edge_name in ("top", "bottom"):
|
if edge_name in ("top", "bottom"):
|
||||||
edge_1d = edge_pixels.mean(axis=0) # mean across border_width
|
axis = 0
|
||||||
|
edge_len = edge_pixels.shape[1]
|
||||||
else:
|
else:
|
||||||
edge_1d = edge_pixels.mean(axis=1) # mean across border_width
|
axis = 1
|
||||||
|
edge_len = edge_pixels.shape[0]
|
||||||
|
|
||||||
edge_len = edge_1d.shape[0]
|
# Lazy-init / resize per-edge scratch buffers
|
||||||
|
cache = self._edge_cache.get(edge_name)
|
||||||
|
if cache is None or cache[0] != edge_len:
|
||||||
|
step = edge_len / led_count
|
||||||
|
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||||
|
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||||
|
np.minimum(boundaries, edge_len, out=boundaries)
|
||||||
|
starts = boundaries[:-1]
|
||||||
|
ends = boundaries[1:]
|
||||||
|
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
|
||||||
|
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
|
||||||
|
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
|
||||||
|
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
|
||||||
|
self._edge_cache[edge_name] = cache
|
||||||
|
|
||||||
# Compute segment boundaries (matching get_edge_segments float stepping)
|
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
|
||||||
step = edge_len / led_count
|
|
||||||
boundaries = np.empty(led_count + 1, dtype=np.int64)
|
|
||||||
for i in range(led_count + 1):
|
|
||||||
boundaries[i] = int(i * step)
|
|
||||||
# Ensure each segment has at least 1 pixel
|
|
||||||
for i in range(led_count):
|
|
||||||
if boundaries[i + 1] <= boundaries[i]:
|
|
||||||
boundaries[i + 1] = boundaries[i] + 1
|
|
||||||
# Clamp all boundaries to edge_len (not just the last one)
|
|
||||||
boundaries = np.minimum(boundaries, edge_len)
|
|
||||||
|
|
||||||
# Cumulative sum for O(1) range means — no per-LED Python numpy calls
|
# Mean into pre-allocated buffer (no intermediate float64 array)
|
||||||
cumsum = np.zeros((edge_len + 1, 3), dtype=np.float64)
|
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||||
cumsum[1:] = np.cumsum(edge_1d.astype(np.float64), axis=0)
|
|
||||||
|
|
||||||
starts = boundaries[:-1]
|
# Cumsum into pre-allocated buffer
|
||||||
ends = boundaries[1:]
|
cumsum_buf[0] = 0
|
||||||
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
|
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||||
segment_sums = cumsum[ends] - cumsum[starts]
|
|
||||||
|
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
|
||||||
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
|
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
def map_border_to_leds(
|
def map_border_to_leds(
|
||||||
@@ -259,6 +382,9 @@ class PixelMapper:
|
|||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Map screen border pixels to LED colors.
|
"""Map screen border pixels to LED colors.
|
||||||
|
|
||||||
|
Uses pre-allocated buffers and pre-computed index arrays to avoid
|
||||||
|
per-frame allocations (np.zeros, np.roll, np.arange, np.linspace).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
border_pixels: Extracted border pixels from screen
|
border_pixels: Extracted border pixels from screen
|
||||||
|
|
||||||
@@ -268,98 +394,38 @@ class PixelMapper:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If border pixels don't match calibration
|
ValueError: If border pixels don't match calibration
|
||||||
"""
|
"""
|
||||||
total_leds = self.calibration.get_total_leds()
|
led_array = self._led_buf
|
||||||
skip_start = self.calibration.skip_leds_start
|
led_array[:] = 0
|
||||||
skip_end = self.calibration.skip_leds_end
|
|
||||||
active_count = max(0, total_leds - skip_start - skip_end)
|
|
||||||
use_fast_avg = self.interpolation_mode == "average"
|
|
||||||
|
|
||||||
# Phase 1: Map full perimeter to total_leds positions
|
# Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll)
|
||||||
if use_fast_avg:
|
for i, segment in enumerate(self.calibration.segments):
|
||||||
led_array = np.zeros((total_leds, 3), dtype=np.uint8)
|
edge_pixels = self._get_edge_pixels(border_pixels, segment.edge)
|
||||||
else:
|
|
||||||
led_colors = [(0, 0, 0)] * total_leds
|
|
||||||
|
|
||||||
for edge_name in ["top", "right", "bottom", "left"]:
|
if self._use_fast_avg:
|
||||||
segment = self.calibration.get_segment_for_edge(edge_name)
|
|
||||||
if not segment:
|
|
||||||
continue
|
|
||||||
|
|
||||||
edge_pixels = self._get_edge_pixels(border_pixels, edge_name)
|
|
||||||
|
|
||||||
if use_fast_avg:
|
|
||||||
# Vectorized: compute all LED colors for this edge at once
|
|
||||||
colors = self._map_edge_average(
|
colors = self._map_edge_average(
|
||||||
edge_pixels, edge_name, segment.led_count
|
edge_pixels, segment.edge, segment.led_count
|
||||||
)
|
)
|
||||||
led_indices = np.arange(segment.led_start, segment.led_start + segment.led_count)
|
|
||||||
if segment.reverse:
|
|
||||||
led_indices = led_indices[::-1]
|
|
||||||
led_array[led_indices] = colors
|
|
||||||
else:
|
else:
|
||||||
# Per-LED fallback for median/dominant modes
|
colors = self._map_edge_fallback(
|
||||||
try:
|
edge_pixels, segment.edge, segment.led_count
|
||||||
pixel_segments = get_edge_segments(
|
)
|
||||||
edge_pixels, segment.led_count, edge_name
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"Failed to segment {edge_name} edge: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
led_indices = list(range(segment.led_start, segment.led_start + segment.led_count))
|
led_array[self._segment_indices[i]] = colors
|
||||||
if segment.reverse:
|
|
||||||
led_indices = list(reversed(led_indices))
|
|
||||||
|
|
||||||
for led_idx, pixel_segment in zip(led_indices, pixel_segments):
|
# Phase 3: Physical skip — resample full perimeter to active LEDs
|
||||||
color = self._calc_color(pixel_segment)
|
if self._skip_src is not None:
|
||||||
led_colors[led_idx] = color
|
np.copyto(self._skip_float, led_array, casting='unsafe')
|
||||||
|
for ch in range(3):
|
||||||
|
self._skip_resampled[:, ch] = np.round(
|
||||||
|
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
|
||||||
|
).astype(np.uint8)
|
||||||
|
led_array[:] = 0
|
||||||
|
end_idx = self._total_leds - self._skip_end
|
||||||
|
led_array[self._skip_start:end_idx] = self._skip_resampled
|
||||||
|
elif self._active_count <= 0:
|
||||||
|
led_array[:] = 0
|
||||||
|
|
||||||
# Phase 2: Offset rotation
|
return led_array
|
||||||
offset = self.calibration.offset % total_leds if total_leds > 0 else 0
|
|
||||||
|
|
||||||
if use_fast_avg:
|
|
||||||
if offset > 0:
|
|
||||||
led_array = np.roll(led_array, offset, axis=0)
|
|
||||||
|
|
||||||
# Phase 3: Physical skip — resample full perimeter to active LEDs
|
|
||||||
# Maps the entire screen to active_count positions so each active LED
|
|
||||||
# covers a proportionally larger slice of the perimeter.
|
|
||||||
if active_count > 0 and active_count < total_leds:
|
|
||||||
src = np.linspace(0, total_leds - 1, active_count)
|
|
||||||
full_f = led_array.astype(np.float64)
|
|
||||||
x = np.arange(total_leds, dtype=np.float64)
|
|
||||||
resampled = np.empty((active_count, 3), dtype=np.uint8)
|
|
||||||
for ch in range(3):
|
|
||||||
resampled[:, ch] = np.round(
|
|
||||||
np.interp(src, x, full_f[:, ch])
|
|
||||||
).astype(np.uint8)
|
|
||||||
led_array[:] = 0
|
|
||||||
end_idx = total_leds - skip_end
|
|
||||||
led_array[skip_start:end_idx] = resampled
|
|
||||||
elif active_count <= 0:
|
|
||||||
led_array[:] = 0
|
|
||||||
|
|
||||||
return led_array
|
|
||||||
else:
|
|
||||||
if offset > 0:
|
|
||||||
led_colors = led_colors[total_leds - offset:] + led_colors[:total_leds - offset]
|
|
||||||
|
|
||||||
# Phase 3: Physical skip — resample full perimeter to active LEDs
|
|
||||||
if active_count > 0 and active_count < total_leds:
|
|
||||||
arr = np.array(led_colors, dtype=np.float64)
|
|
||||||
src = np.linspace(0, total_leds - 1, active_count)
|
|
||||||
x = np.arange(total_leds, dtype=np.float64)
|
|
||||||
resampled = np.empty((active_count, 3), dtype=np.float64)
|
|
||||||
for ch in range(3):
|
|
||||||
resampled[:, ch] = np.interp(src, x, arr[:, ch])
|
|
||||||
led_colors = [(0, 0, 0)] * total_leds
|
|
||||||
for i in range(active_count):
|
|
||||||
r, g, b = resampled[i]
|
|
||||||
led_colors[skip_start + i] = (int(round(r)), int(round(g)), int(round(b)))
|
|
||||||
elif active_count <= 0:
|
|
||||||
led_colors = [(0, 0, 0)] * total_leds
|
|
||||||
|
|
||||||
return np.array(led_colors, dtype=np.uint8)
|
|
||||||
|
|
||||||
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
|
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
|
||||||
"""Generate test pattern to light up specific edge.
|
"""Generate test pattern to light up specific edge.
|
||||||
@@ -392,6 +458,216 @@ class PixelMapper:
|
|||||||
return led_colors
|
return led_colors
|
||||||
|
|
||||||
|
|
||||||
|
class AdvancedPixelMapper:
|
||||||
|
"""Maps multi-source screen pixels to LED colors for advanced calibration.
|
||||||
|
|
||||||
|
Each CalibrationLine references a picture source and an edge, with its own
|
||||||
|
span and border_width. Frames from multiple sources are passed in as a dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
calibration: CalibrationConfig,
|
||||||
|
interpolation_mode: Literal["average", "median", "dominant"] = "average",
|
||||||
|
):
|
||||||
|
self.calibration = calibration
|
||||||
|
self.interpolation_mode = interpolation_mode
|
||||||
|
calibration.validate()
|
||||||
|
|
||||||
|
if interpolation_mode == "average":
|
||||||
|
self._calc_color = calculate_average_color
|
||||||
|
elif interpolation_mode == "median":
|
||||||
|
self._calc_color = calculate_median_color
|
||||||
|
elif interpolation_mode == "dominant":
|
||||||
|
self._calc_color = calculate_dominant_color
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid interpolation mode: {interpolation_mode}")
|
||||||
|
|
||||||
|
total_leds = calibration.get_total_leds()
|
||||||
|
self._total_leds = total_leds
|
||||||
|
self._led_buf = np.zeros((total_leds, 3), dtype=np.uint8)
|
||||||
|
self._use_fast_avg = interpolation_mode == "average"
|
||||||
|
|
||||||
|
# Build segment-like metadata from lines (led_start for each line)
|
||||||
|
offset = calibration.offset % total_leds if total_leds > 0 else 0
|
||||||
|
self._line_indices: List[np.ndarray] = []
|
||||||
|
led_start = 0
|
||||||
|
for line in calibration.lines:
|
||||||
|
indices = np.arange(led_start, led_start + line.led_count)
|
||||||
|
if line.reverse:
|
||||||
|
indices = indices[::-1]
|
||||||
|
if offset > 0:
|
||||||
|
indices = (indices + offset) % total_leds
|
||||||
|
self._line_indices.append(indices)
|
||||||
|
led_start += line.led_count
|
||||||
|
|
||||||
|
# Skip arrays (same logic as PixelMapper)
|
||||||
|
skip_start = calibration.skip_leds_start
|
||||||
|
skip_end = calibration.skip_leds_end
|
||||||
|
self._skip_start = skip_start
|
||||||
|
self._skip_end = skip_end
|
||||||
|
self._active_count = max(0, total_leds - skip_start - skip_end)
|
||||||
|
if 0 < self._active_count < total_leds:
|
||||||
|
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
|
||||||
|
self._skip_x = np.arange(total_leds, dtype=np.float64)
|
||||||
|
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
|
||||||
|
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
|
||||||
|
else:
|
||||||
|
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
|
||||||
|
|
||||||
|
# Per-line edge cache (keyed by line index to avoid collision)
|
||||||
|
self._edge_cache: Dict[int, tuple] = {}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Initialized advanced pixel mapper with {total_leds} LEDs, "
|
||||||
|
f"{len(calibration.lines)} lines, {interpolation_mode} interpolation"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_edge_strip(
|
||||||
|
frame: np.ndarray, edge: str, border_width: int,
|
||||||
|
span_start: float, span_end: float,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Extract a border strip from a frame for the given edge and span."""
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
bw = min(border_width, h // 4, w // 4)
|
||||||
|
|
||||||
|
if edge == "top":
|
||||||
|
strip = frame[:bw, :, :]
|
||||||
|
elif edge == "bottom":
|
||||||
|
strip = frame[-bw:, :, :]
|
||||||
|
elif edge == "right":
|
||||||
|
strip = frame[:, -bw:, :]
|
||||||
|
else: # left
|
||||||
|
strip = frame[:, :bw, :]
|
||||||
|
|
||||||
|
# Apply span
|
||||||
|
if span_start > 0.0 or span_end < 1.0:
|
||||||
|
if edge in ("top", "bottom"):
|
||||||
|
total_w = strip.shape[1]
|
||||||
|
s, e = int(span_start * total_w), int(span_end * total_w)
|
||||||
|
strip = strip[:, s:e, :]
|
||||||
|
else:
|
||||||
|
total_h = strip.shape[0]
|
||||||
|
s, e = int(span_start * total_h), int(span_end * total_h)
|
||||||
|
strip = strip[s:e, :, :]
|
||||||
|
|
||||||
|
return strip
|
||||||
|
|
||||||
|
def _map_edge_average(
|
||||||
|
self, edge_pixels: np.ndarray, edge_name: str, led_count: int,
|
||||||
|
cache_key: int,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Vectorized average-color mapping (same algo as PixelMapper)."""
|
||||||
|
if edge_name in ("top", "bottom"):
|
||||||
|
axis = 0
|
||||||
|
edge_len = edge_pixels.shape[1]
|
||||||
|
else:
|
||||||
|
axis = 1
|
||||||
|
edge_len = edge_pixels.shape[0]
|
||||||
|
|
||||||
|
cache = self._edge_cache.get(cache_key)
|
||||||
|
if cache is None or cache[0] != edge_len:
|
||||||
|
step = edge_len / led_count
|
||||||
|
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||||
|
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||||
|
np.minimum(boundaries, edge_len, out=boundaries)
|
||||||
|
starts = boundaries[:-1]
|
||||||
|
ends = boundaries[1:]
|
||||||
|
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
|
||||||
|
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
|
||||||
|
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
|
||||||
|
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
|
||||||
|
self._edge_cache[cache_key] = cache
|
||||||
|
|
||||||
|
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
|
||||||
|
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||||
|
cumsum_buf[0] = 0
|
||||||
|
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||||
|
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
|
||||||
|
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
def _map_edge_fallback(
|
||||||
|
self, edge_pixels: np.ndarray, edge_name: str, led_count: int,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Per-LED color mapping for median/dominant modes."""
|
||||||
|
if edge_name in ("top", "bottom"):
|
||||||
|
edge_len = edge_pixels.shape[1]
|
||||||
|
else:
|
||||||
|
edge_len = edge_pixels.shape[0]
|
||||||
|
|
||||||
|
step = edge_len / led_count
|
||||||
|
result = np.empty((led_count, 3), dtype=np.uint8)
|
||||||
|
for i in range(led_count):
|
||||||
|
start = int(i * step)
|
||||||
|
end = max(start + 1, int((i + 1) * step))
|
||||||
|
end = min(end, edge_len)
|
||||||
|
if edge_name in ("top", "bottom"):
|
||||||
|
segment = edge_pixels[:, start:end, :]
|
||||||
|
else:
|
||||||
|
segment = edge_pixels[start:end, :, :]
|
||||||
|
result[i] = self._calc_color(segment)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def map_lines_to_leds(self, frames: Dict[str, np.ndarray]) -> np.ndarray:
|
||||||
|
"""Map multi-source frames to LED colors using calibration lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frames: Dict mapping picture_source_id to captured frame (H, W, 3) uint8
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
numpy array of shape (total_leds, 3), dtype uint8
|
||||||
|
"""
|
||||||
|
led_array = self._led_buf
|
||||||
|
led_array[:] = 0
|
||||||
|
|
||||||
|
for i, line in enumerate(self.calibration.lines):
|
||||||
|
frame = frames.get(line.picture_source_id)
|
||||||
|
if frame is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
edge_pixels = self._extract_edge_strip(
|
||||||
|
frame, line.edge, line.border_width,
|
||||||
|
line.span_start, line.span_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._use_fast_avg:
|
||||||
|
colors = self._map_edge_average(
|
||||||
|
edge_pixels, line.edge, line.led_count, cache_key=i,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
colors = self._map_edge_fallback(
|
||||||
|
edge_pixels, line.edge, line.led_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
led_array[self._line_indices[i]] = colors
|
||||||
|
|
||||||
|
# Phase 3: Physical skip (same as PixelMapper)
|
||||||
|
if self._skip_src is not None:
|
||||||
|
np.copyto(self._skip_float, led_array, casting='unsafe')
|
||||||
|
for ch in range(3):
|
||||||
|
self._skip_resampled[:, ch] = np.round(
|
||||||
|
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
|
||||||
|
).astype(np.uint8)
|
||||||
|
led_array[:] = 0
|
||||||
|
end_idx = self._total_leds - self._skip_end
|
||||||
|
led_array[self._skip_start:end_idx] = self._skip_resampled
|
||||||
|
elif self._active_count <= 0:
|
||||||
|
led_array[:] = 0
|
||||||
|
|
||||||
|
return led_array
|
||||||
|
|
||||||
|
|
||||||
|
def create_pixel_mapper(
|
||||||
|
calibration: CalibrationConfig,
|
||||||
|
interpolation_mode: str = "average",
|
||||||
|
):
|
||||||
|
"""Factory: create the right mapper for the calibration mode."""
|
||||||
|
if calibration.mode == "advanced":
|
||||||
|
return AdvancedPixelMapper(calibration, interpolation_mode)
|
||||||
|
return PixelMapper(calibration, interpolation_mode)
|
||||||
|
|
||||||
|
|
||||||
def create_default_calibration(led_count: int) -> CalibrationConfig:
|
def create_default_calibration(led_count: int) -> CalibrationConfig:
|
||||||
"""Create a default calibration for a rectangular screen.
|
"""Create a default calibration for a rectangular screen.
|
||||||
|
|
||||||
@@ -448,7 +724,35 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
|||||||
ValueError: If data is invalid
|
ValueError: If data is invalid
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
mode = data.get("mode", "simple")
|
||||||
|
|
||||||
|
if mode == "advanced":
|
||||||
|
lines_data = data.get("lines", [])
|
||||||
|
lines = [
|
||||||
|
CalibrationLine(
|
||||||
|
picture_source_id=ld["picture_source_id"],
|
||||||
|
edge=ld["edge"],
|
||||||
|
led_count=ld["led_count"],
|
||||||
|
span_start=ld.get("span_start", 0.0),
|
||||||
|
span_end=ld.get("span_end", 1.0),
|
||||||
|
reverse=ld.get("reverse", False),
|
||||||
|
border_width=ld.get("border_width", 10),
|
||||||
|
)
|
||||||
|
for ld in lines_data
|
||||||
|
]
|
||||||
|
config = CalibrationConfig(
|
||||||
|
mode="advanced",
|
||||||
|
lines=lines,
|
||||||
|
offset=data.get("offset", 0),
|
||||||
|
skip_leds_start=data.get("skip_leds_start", 0),
|
||||||
|
skip_leds_end=data.get("skip_leds_end", 0),
|
||||||
|
)
|
||||||
|
config.validate()
|
||||||
|
return config
|
||||||
|
|
||||||
|
# Simple mode (backward compat — missing "mode" key defaults here)
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
|
mode="simple",
|
||||||
layout=data["layout"],
|
layout=data["layout"],
|
||||||
start_position=data["start_position"],
|
start_position=data["start_position"],
|
||||||
offset=data.get("offset", 0),
|
offset=data.get("offset", 0),
|
||||||
@@ -489,7 +793,33 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary representation
|
Dictionary representation
|
||||||
"""
|
"""
|
||||||
|
if config.mode == "advanced":
|
||||||
|
result: dict = {
|
||||||
|
"mode": "advanced",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"picture_source_id": line.picture_source_id,
|
||||||
|
"edge": line.edge,
|
||||||
|
"led_count": line.led_count,
|
||||||
|
"span_start": line.span_start,
|
||||||
|
"span_end": line.span_end,
|
||||||
|
"reverse": line.reverse,
|
||||||
|
"border_width": line.border_width,
|
||||||
|
}
|
||||||
|
for line in config.lines
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if config.offset != 0:
|
||||||
|
result["offset"] = config.offset
|
||||||
|
if config.skip_leds_start > 0:
|
||||||
|
result["skip_leds_start"] = config.skip_leds_start
|
||||||
|
if config.skip_leds_end > 0:
|
||||||
|
result["skip_leds_end"] = config.skip_leds_end
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Simple mode
|
||||||
result = {
|
result = {
|
||||||
|
"mode": "simple",
|
||||||
"layout": config.layout,
|
"layout": config.layout,
|
||||||
"start_position": config.start_position,
|
"start_position": config.start_position,
|
||||||
"offset": config.offset,
|
"offset": config.offset,
|
||||||
|
|||||||
@@ -1,68 +1,27 @@
|
|||||||
"""Pixel processing utilities for color correction and manipulation."""
|
"""Pixel processing utilities for color correction and manipulation."""
|
||||||
|
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple, Union
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
ColorList = Union[List[Tuple[int, int, int]], np.ndarray]
|
||||||
|
|
||||||
def apply_color_correction(
|
|
||||||
colors: List[Tuple[int, int, int]],
|
|
||||||
gamma: float = 2.2,
|
|
||||||
saturation: float = 1.0,
|
|
||||||
brightness: float = 1.0,
|
|
||||||
) -> List[Tuple[int, int, int]]:
|
|
||||||
"""Apply color correction to LED colors.
|
|
||||||
|
|
||||||
Args:
|
def _as_array(colors: ColorList) -> np.ndarray:
|
||||||
colors: List of (R, G, B) tuples
|
"""Convert list-of-tuples to (N,3) uint8 array, or pass through if already ndarray."""
|
||||||
gamma: Gamma correction factor (default 2.2)
|
if isinstance(colors, np.ndarray):
|
||||||
saturation: Saturation multiplier (0.0-2.0)
|
|
||||||
brightness: Brightness multiplier (0.0-1.0)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Corrected list of (R, G, B) tuples
|
|
||||||
"""
|
|
||||||
if not colors:
|
|
||||||
return colors
|
return colors
|
||||||
|
return np.array(colors, dtype=np.uint8)
|
||||||
# Convert to numpy array for efficient processing
|
|
||||||
colors_array = np.array(colors, dtype=np.float32) / 255.0
|
|
||||||
|
|
||||||
# Apply brightness
|
|
||||||
if brightness != 1.0:
|
|
||||||
colors_array *= brightness
|
|
||||||
|
|
||||||
# Apply saturation
|
|
||||||
if saturation != 1.0:
|
|
||||||
# Convert RGB to HSV-like saturation adjustment
|
|
||||||
# Calculate luminance (grayscale)
|
|
||||||
luminance = np.dot(colors_array, [0.299, 0.587, 0.114])
|
|
||||||
luminance = luminance[:, np.newaxis] # Reshape for broadcasting
|
|
||||||
|
|
||||||
# Blend between grayscale and color based on saturation
|
|
||||||
colors_array = luminance + (colors_array - luminance) * saturation
|
|
||||||
|
|
||||||
# Apply gamma correction
|
|
||||||
if gamma != 1.0:
|
|
||||||
colors_array = np.power(colors_array, 1.0 / gamma)
|
|
||||||
|
|
||||||
# Clamp to valid range and convert back to integers
|
|
||||||
colors_array = np.clip(colors_array * 255.0, 0, 255).astype(np.uint8)
|
|
||||||
|
|
||||||
# Convert back to list of tuples
|
|
||||||
corrected_colors = [tuple(color) for color in colors_array]
|
|
||||||
|
|
||||||
return corrected_colors
|
|
||||||
|
|
||||||
|
|
||||||
def smooth_colors(
|
def smooth_colors(
|
||||||
current_colors: List[Tuple[int, int, int]],
|
current_colors: ColorList,
|
||||||
previous_colors: List[Tuple[int, int, int]],
|
previous_colors: ColorList,
|
||||||
smoothing_factor: float = 0.5,
|
smoothing_factor: float = 0.5,
|
||||||
) -> List[Tuple[int, int, int]]:
|
) -> np.ndarray:
|
||||||
"""Smooth color transitions between frames.
|
"""Smooth color transitions between frames.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -71,96 +30,71 @@ def smooth_colors(
|
|||||||
smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing)
|
smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Smoothed colors
|
Smoothed colors as (N,3) uint8 ndarray
|
||||||
"""
|
"""
|
||||||
if not current_colors or not previous_colors:
|
if not len(current_colors) or not len(previous_colors):
|
||||||
return current_colors
|
return _as_array(current_colors)
|
||||||
|
|
||||||
if len(current_colors) != len(previous_colors):
|
if len(current_colors) != len(previous_colors):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Color count mismatch: current={len(current_colors)}, "
|
f"Color count mismatch: current={len(current_colors)}, "
|
||||||
f"previous={len(previous_colors)}. Skipping smoothing."
|
f"previous={len(previous_colors)}. Skipping smoothing."
|
||||||
)
|
)
|
||||||
return current_colors
|
return _as_array(current_colors)
|
||||||
|
|
||||||
if smoothing_factor <= 0:
|
if smoothing_factor <= 0:
|
||||||
return current_colors
|
return _as_array(current_colors)
|
||||||
if smoothing_factor >= 1:
|
if smoothing_factor >= 1:
|
||||||
return previous_colors
|
return _as_array(previous_colors)
|
||||||
|
|
||||||
# Convert to numpy arrays
|
current = np.asarray(current_colors, dtype=np.float32)
|
||||||
current = np.array(current_colors, dtype=np.float32)
|
previous = np.asarray(previous_colors, dtype=np.float32)
|
||||||
previous = np.array(previous_colors, dtype=np.float32)
|
|
||||||
|
|
||||||
# Blend between current and previous
|
|
||||||
smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor
|
smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor
|
||||||
|
return np.clip(smoothed, 0, 255).astype(np.uint8)
|
||||||
# Convert back to integers
|
|
||||||
smoothed = np.clip(smoothed, 0, 255).astype(np.uint8)
|
|
||||||
|
|
||||||
return [tuple(color) for color in smoothed]
|
|
||||||
|
|
||||||
|
|
||||||
def adjust_brightness_global(
|
def adjust_brightness_global(
|
||||||
colors: List[Tuple[int, int, int]],
|
colors: ColorList,
|
||||||
target_brightness: int,
|
target_brightness: int,
|
||||||
) -> List[Tuple[int, int, int]]:
|
) -> np.ndarray:
|
||||||
"""Adjust colors to achieve target global brightness.
|
"""Adjust colors to achieve target global brightness.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
colors: List of (R, G, B) tuples
|
colors: List of (R, G, B) tuples or (N,3) ndarray
|
||||||
target_brightness: Target brightness (0-255)
|
target_brightness: Target brightness (0-255)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Adjusted colors
|
Adjusted colors as (N,3) uint8 ndarray
|
||||||
"""
|
"""
|
||||||
if not colors or target_brightness == 255:
|
arr = _as_array(colors)
|
||||||
return colors
|
if not len(arr) or target_brightness == 255:
|
||||||
|
return arr
|
||||||
|
|
||||||
# Calculate scaling factor
|
|
||||||
scale = target_brightness / 255.0
|
scale = target_brightness / 255.0
|
||||||
|
return (arr.astype(np.float32) * scale).astype(np.uint8)
|
||||||
# Scale all colors
|
|
||||||
scaled = [
|
|
||||||
(
|
|
||||||
int(r * scale),
|
|
||||||
int(g * scale),
|
|
||||||
int(b * scale),
|
|
||||||
)
|
|
||||||
for r, g, b in colors
|
|
||||||
]
|
|
||||||
|
|
||||||
return scaled
|
|
||||||
|
|
||||||
|
|
||||||
def limit_brightness(
|
def limit_brightness(
|
||||||
colors: List[Tuple[int, int, int]],
|
colors: ColorList,
|
||||||
max_brightness: int = 255,
|
max_brightness: int = 255,
|
||||||
) -> List[Tuple[int, int, int]]:
|
) -> np.ndarray:
|
||||||
"""Limit maximum brightness of any color channel.
|
"""Limit maximum brightness of any color channel.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
colors: List of (R, G, B) tuples
|
colors: List of (R, G, B) tuples or (N,3) ndarray
|
||||||
max_brightness: Maximum allowed brightness (0-255)
|
max_brightness: Maximum allowed brightness (0-255)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Limited colors
|
Limited colors as (N,3) uint8 ndarray
|
||||||
"""
|
"""
|
||||||
if not colors or max_brightness == 255:
|
arr = _as_array(colors)
|
||||||
return colors
|
if not len(arr) or max_brightness == 255:
|
||||||
|
return arr
|
||||||
|
|
||||||
limited = []
|
arr_f = arr.astype(np.float32)
|
||||||
for r, g, b in colors:
|
max_vals = np.max(arr_f, axis=1)
|
||||||
# Find max channel value
|
need_scale = max_vals > max_brightness
|
||||||
max_val = max(r, g, b)
|
if need_scale.any():
|
||||||
|
scales = np.where(need_scale, max_brightness / np.maximum(max_vals, 1.0), 1.0)
|
||||||
if max_val > max_brightness:
|
arr_f *= scales[:, np.newaxis]
|
||||||
# Scale down proportionally
|
return arr_f.astype(np.uint8)
|
||||||
scale = max_brightness / max_val
|
|
||||||
r = int(r * scale)
|
|
||||||
g = int(g * scale)
|
|
||||||
b = int(b * scale)
|
|
||||||
|
|
||||||
limited.append((r, g, b))
|
|
||||||
|
|
||||||
return limited
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from typing import Dict, List
|
|||||||
|
|
||||||
import mss
|
import mss
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from wled_controller.utils import get_logger, get_monitor_names, get_monitor_refresh_rates
|
from wled_controller.utils import get_logger, get_monitor_names, get_monitor_refresh_rates
|
||||||
|
|
||||||
@@ -122,9 +121,10 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
|
|||||||
# Capture screenshot
|
# Capture screenshot
|
||||||
screenshot = sct.grab(monitor)
|
screenshot = sct.grab(monitor)
|
||||||
|
|
||||||
# Convert to numpy array (RGB)
|
# Direct bytes→numpy (skips PIL intermediate object)
|
||||||
img = Image.frombytes("RGB", screenshot.size, screenshot.rgb)
|
img_array = np.frombuffer(
|
||||||
img_array = np.array(img)
|
screenshot.rgb, dtype=np.uint8,
|
||||||
|
).reshape(screenshot.height, screenshot.width, 3)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Captured display {display_index}: {monitor['width']}x{monitor['height']}"
|
f"Captured display {display_index}: {monitor['width']}x{monitor['height']}"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user