Compare commits
99 Commits
73562cd525
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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/
|
||||
.venv
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
150
CLAUDE.md
150
CLAUDE.md
@@ -1,5 +1,31 @@
|
||||
# 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
|
||||
|
||||
**🚨 NEVER CREATE COMMITS WITHOUT EXPLICIT USER APPROVAL 🚨**
|
||||
@@ -70,7 +96,13 @@
|
||||
|
||||
**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
|
||||
|
||||
@@ -97,118 +129,22 @@ This is a monorepo containing:
|
||||
For detailed server-specific instructions (restart policy, testing, etc.), see:
|
||||
- `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
|
||||
<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>
|
||||
```
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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`).
|
||||
|
||||
## 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`)
|
||||
**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.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
|
||||
121
TODO.md
121
TODO.md
@@ -4,9 +4,6 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
||||
|
||||
## Processing Pipeline
|
||||
|
||||
- [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color
|
||||
- Complexity: medium — doesn't fit the PP filter model (operates on extracted LED colors, not images); needs a new param on calibration/color-strip-source config + PixelMapper changes
|
||||
- Impact: high — smooths out single-LED noise, visually cleaner ambilight on sparse strips
|
||||
- [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut
|
||||
- Complexity: large — requires a new transition layer concept in ProcessorManager; must blend two live streams simultaneously during switch, coordinating start/stop timing
|
||||
- Impact: medium — polishes profile switching UX but ambient lighting rarely switches sources frequently
|
||||
@@ -17,21 +14,6 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
||||
- Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI
|
||||
- Impact: medium — opens stage/theatrical use case, niche but differentiating
|
||||
|
||||
## Automation & Integration
|
||||
|
||||
- [ ] `P2` **Webhook/MQTT trigger** — Let external systems activate profiles without HA integration
|
||||
- Complexity: low-medium — webhook: simple FastAPI endpoint calling SceneActivator; MQTT: add `asyncio-mqtt` dependency + subscription loop
|
||||
- Impact: high — key integration point for home automation users without Home Assistant
|
||||
|
||||
## Multi-Display
|
||||
|
||||
- [ ] `P2` **Investigate multimonitor support** — Research and plan support for multi-monitor setups
|
||||
- Complexity: research — audit DXGI/MSS capture engine's display enumeration; test with 2+ monitors; identify gaps in calibration UI (per-display config)
|
||||
- Impact: high — many users have multi-monitor setups; prerequisite for multi-display unification
|
||||
- [ ] `P3` **Multi-display unification** — Treat 2-3 monitors as single virtual display for seamless ambilight
|
||||
- Complexity: large — virtual display abstraction stitching multiple captures; edge-matching calibration between monitors; significant UI changes
|
||||
- Impact: high — flagship feature for multi-monitor users, but depends on investigation results
|
||||
|
||||
## Capture Engines
|
||||
|
||||
- [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices
|
||||
@@ -41,16 +23,107 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
||||
## Code Health
|
||||
|
||||
- [ ] `P1` **"Start All" targets button** — "Stop All" exists but "Start All" is missing
|
||||
- [ ] `P2` **Manual backup trigger endpoint** — `POST /system/auto-backup/trigger` (~5 lines)
|
||||
- [x] `P2` **Manual backup trigger endpoint** — `POST /system/auto-backup/trigger` (~5 lines)
|
||||
- [ ] `P2` **Scene snapshot should capture device brightness** — `software_brightness` not saved/restored
|
||||
- [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard** — `metrics.last_error` is already populated
|
||||
- [ ] `P3` **Home Assistant MQTT discovery** — publish auto-discovery payloads; MQTT infra already exists
|
||||
- [ ] `P3` **CSS source import/export** — share individual sources without full config backup
|
||||
|
||||
## Backend Review Fixes (2026-03-14)
|
||||
|
||||
### Performance
|
||||
- [x] **P1** PIL blocking in async handlers → `asyncio.to_thread`
|
||||
- [x] **P2** `subprocess.run` blocking event loop → `asyncio.create_subprocess_exec`
|
||||
- [x] **P3** Audio enum blocking async → `asyncio.to_thread`
|
||||
- [x] **P4** Display enum blocking async → `asyncio.to_thread`
|
||||
- [x] **P5** `colorsys` scalar loop in hot path → vectorize numpy
|
||||
- [x] **P6** `MappedStream` per-frame allocation → double-buffer
|
||||
- [x] **P7** Audio/effect per-frame temp allocs → pre-allocate
|
||||
- [x] **P8** Blocking `httpx.get` in stream init → documented (callers use to_thread)
|
||||
- [x] **P9** No-cache middleware runs on all requests → scope to static
|
||||
- [x] **P10** Sync file I/O in async handlers (stores) → documented as accepted risk (< 5ms)
|
||||
- [x] **P11** `frame_time` float division every loop iter → cache field
|
||||
- [x] **P12** `_check_name_unique` O(N) + no lock → add threading.Lock
|
||||
- [x] **P13** Imports inside 1-Hz metrics loop → move to module level
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [x] **Q1** `DeviceStore` not using `BaseJsonStore`
|
||||
- [x] **Q2** `ColorStripStore` 275-line god methods → factory dispatch
|
||||
- [x] **Q3** Layer violation: core imports from routes → extract to utility
|
||||
- [x] **Q4** 20+ field-by-field update in Device/routes → dataclass + generic update
|
||||
- [x] **Q5** WebSocket auth copy-pasted 9x → extract helper
|
||||
- [x] **Q6** `set_device_brightness` bypasses store → use update_device
|
||||
- [x] **Q7** DI via 16+ module globals → registry pattern
|
||||
- [x] **Q8** `_css_to_response` 30+ getattr → polymorphic to_response
|
||||
- [x] **Q9** Private attribute access across modules → expose as properties
|
||||
- [x] **Q10** `ColorStripSource.to_dict()` emits ~25 nulls → per-subclass override
|
||||
- [x] **Q11** `DeviceStore.get_device` returns None vs raises → raise ValueError
|
||||
- [x] **Q12** `list_all_tags` fragile method-name probing → use get_all()
|
||||
- [x] **Q13** Route create/update pass 30 individual fields → **kwargs
|
||||
|
||||
## UX
|
||||
|
||||
- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default
|
||||
- [ ] `P1` **Review new CSS types (Daylight & Candlelight)** — End-to-end review: create via UI, assign to targets, verify LED rendering, check edge cases (0 candles, extreme latitude, real-time toggle)
|
||||
- [ ] `P1` **Daylight brightness value source** — New value source type that reports a 0–255 brightness level based on daylight cycle time (real-time or simulated), reusing the daylight LUT logic
|
||||
- [ ] `P1` **Tags input: move under name, remove hint/title** — Move the tags chip input directly below the name field in all entity editor modals; remove the hint toggle and section title for a cleaner layout
|
||||
- [ ] `P1` **IconSelect grid overflow & scroll jump** — Expandable icon grid sometimes renders outside the visible viewport; opening it causes the modal/page to jump scroll
|
||||
- [x] `P1` **Daylight brightness value source** — New value source type that reports a 0–255 brightness level based on daylight cycle time (real-time or simulated), reusing the daylight LUT logic
|
||||
- [x] `P1` **Tags input: move under name, remove hint/title** — Move the tags chip input directly below the name field in all entity editor modals; remove the hint toggle and section title for a cleaner layout
|
||||
|
||||
## WebUI Review (2026-03-16)
|
||||
|
||||
### Critical (Safety & Correctness)
|
||||
- [x] `P1` **"Stop All" buttons need confirmation** — dashboard, LED targets, KC targets
|
||||
- [x] `P1` **`turnOffDevice()` needs confirmation**
|
||||
- [x] `P1` **Confirm dialog i18n** — added data-i18n to title/buttons
|
||||
- [x] `P1` **Duplicate `id="tutorial-overlay"`** — renamed to calibration-tutorial-overlay
|
||||
- [x] `P1` **Define missing CSS variables** — --radius, --text-primary, --hover-bg, --input-bg
|
||||
- [x] `P1` **Toast z-index conflict** — toast now 3000
|
||||
|
||||
### UX Consistency
|
||||
- [x] `P1` **Test modals backdrop-close** — added setupBackdropClose
|
||||
- [x] `P1` **Devices clone** — added cloneDevice with full field prefill
|
||||
- [x] `P1` **Sync clocks in command palette** — added to _responseKeys + _buildItems
|
||||
- [x] `P2` **Hardcoded accent colors** — 20+ replacements using color-mix() and CSS vars
|
||||
- [x] `P2` **Duplicate `.badge` definition** — removed dead code from components.css
|
||||
- [x] `P2` **Calibration elements keyboard-accessible** — changed div to button
|
||||
- [x] `P2` **Color-picker swatch aria-labels** — added aria-label with hex value
|
||||
- [x] `P2` **Pattern canvas mobile scroll** — added min-width: 0 override in mobile.css
|
||||
- [x] `P2` **Graph editor mobile bottom clipping** — adjusted height in mobile.css
|
||||
|
||||
### Low Priority Polish
|
||||
- [x] `P3` **Empty-state illustrations/onboarding** — CardSection emptyKey with per-entity messages
|
||||
- [x] `P3` **api-key-modal submit title i18n**
|
||||
- [x] `P3` **Settings modal close labeled "Cancel" → "Close"**
|
||||
- [x] `P3` **Inconsistent px vs rem font sizes** — 21 conversions across streams/modal/cards CSS
|
||||
- [x] `P3` **scroll-behavior: smooth** — added with reduced-motion override
|
||||
- [x] `P3` **Reduce !important usage** — scoped .cs-filter selectors
|
||||
- [x] `P3` **@media print styles** — theme reset + hide nav
|
||||
- [x] `P3` **:focus-visible on interactive elements** — added 4 missing selectors
|
||||
- [x] `P3` **iOS Safari modal scroll-position jump** — already implemented in ui.js lockBody/unlockBody
|
||||
|
||||
### New Features
|
||||
- [x] `P1` **Command palette actions** — start/stop targets, activate scenes, enable/disable automations
|
||||
- [x] `P1` **Bulk start/stop API** — POST /output-targets/bulk/start and /bulk/stop
|
||||
- [x] `P1` **OS notification history viewer** — modal with app name, timestamp, fired/filtered badges
|
||||
- [x] `P1` **Scene "used by" reference count** — badge on card with automation count
|
||||
- [x] `P1` **Clock elapsed time on cards** — shows formatted elapsed time
|
||||
- [x] `P1` **Device "last seen" timestamp** — relative time with full ISO in title
|
||||
- [x] `P2` **Audio device refresh in modal** — refresh button next to device dropdown
|
||||
- [x] `P2` **Composite layer reorder** — drag handles with pointer-based reorder
|
||||
- [x] `P2` **MQTT settings panel** — config form with enabled/host/port/auth/topic, JSON persistence
|
||||
- [x] `P2` **Log viewer** — WebSocket broadcaster with ring buffer, level-filtered UI in settings
|
||||
- [x] `P2` **Animated value source waveform preview** — canvas drawing of sine/triangle/sawtooth/square
|
||||
- [x] `P2` **Gradient custom preset save** — localStorage-backed custom presets with save/delete
|
||||
- [x] `P2` **API key management UI** — read-only display of key labels with masked values
|
||||
- [x] `P2` **Backup metadata** — file size, auto/manual badge
|
||||
- [x] `P2` **Server restart button** — in settings with confirm dialog + restart overlay
|
||||
- [x] `P2` **Partial config export/import** — per-store export/import with merge option
|
||||
- [x] `P3` **Audio spectrum visualizer** — already fully implemented
|
||||
- [ ] `P3` **Hue bridge pairing flow** — requires physical Hue bridge hardware
|
||||
- [x] `P3` **Runtime log-level adjustment** — GET/PUT endpoints + settings dropdown
|
||||
- [x] `P3` **Progressive disclosure in target editor** — advanced section collapsed by default
|
||||
|
||||
### CSS Architecture
|
||||
- [x] `P1` **Define missing CSS variables** — --radius, --text-primary, --hover-bg, --input-bg
|
||||
- [x] `P2` **Define radius scale** — --radius-sm/md/lg/pill tokens, migrated key selectors
|
||||
- [x] `P2` **Scope generic input selector** — .cs-filter boosted specificity, 7 !important removed
|
||||
- [x] `P2` **Consolidate duplicate toggle switch** — filter-list uses settings-toggle
|
||||
- [x] `P2` **Replace hardcoded accent colors** — 20+ values → CSS vars with color-mix()
|
||||
|
||||
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
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -29,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
Platform.SENSOR,
|
||||
Platform.NUMBER,
|
||||
@@ -111,15 +114,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
}
|
||||
|
||||
# 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 []
|
||||
)
|
||||
initial_scene_ids = set(
|
||||
known_scene_ids = set(
|
||||
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
|
||||
)
|
||||
|
||||
def _on_coordinator_update() -> None:
|
||||
"""Manage WS connections and detect target list changes."""
|
||||
nonlocal known_target_ids, known_scene_ids
|
||||
|
||||
if not coordinator.data:
|
||||
return
|
||||
|
||||
@@ -131,8 +136,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
state = target_data.get("state") or {}
|
||||
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
|
||||
if state.get("processing"):
|
||||
if target_id not in ws_manager._connections:
|
||||
hass.async_create_task(ws_manager.start_listening(target_id))
|
||||
else:
|
||||
if target_id in ws_manager._connections:
|
||||
hass.async_create_task(ws_manager.stop_listening(target_id))
|
||||
|
||||
# Reload if target or scene list changed
|
||||
@@ -140,7 +147,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
current_scene_ids = set(
|
||||
p["id"] for p in coordinator.data.get("scene_presets", [])
|
||||
)
|
||||
if current_ids != initial_target_ids or current_scene_ids != initial_scene_ids:
|
||||
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.config_entries.async_reload(entry.entry_id)
|
||||
@@ -148,6 +157,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
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)
|
||||
|
||||
return True
|
||||
@@ -163,5 +201,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
if unload_ok:
|
||||
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
|
||||
|
||||
@@ -65,10 +65,9 @@ class SceneActivateButton(CoordinatorEntity, ButtonEntity):
|
||||
"""Return if entity is available."""
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
return any(
|
||||
p["id"] == self._preset_id
|
||||
for p in self.coordinator.data.get("scene_presets", [])
|
||||
)
|
||||
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."""
|
||||
|
||||
@@ -37,7 +37,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
self.api_key = api_key
|
||||
self.server_version = "unknown"
|
||||
self._auth_headers = {"Authorization": f"Bearer {api_key}"}
|
||||
self._pattern_cache: dict[str, list[dict]] = {}
|
||||
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -85,7 +85,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
kc_settings = target.get("key_colors_settings") or {}
|
||||
template_id = kc_settings.get("pattern_template_id", "")
|
||||
if template_id:
|
||||
result["rectangles"] = await self._get_rectangles(
|
||||
result["rectangles"] = await self._fetch_rectangles(
|
||||
template_id
|
||||
)
|
||||
else:
|
||||
@@ -136,7 +136,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/health",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -150,7 +150,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/output-targets",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -161,7 +161,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
@@ -171,27 +171,22 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def _get_rectangles(self, template_id: str) -> list[dict]:
|
||||
"""Get rectangles for a pattern template, using cache."""
|
||||
if template_id in self._pattern_cache:
|
||||
return self._pattern_cache[template_id]
|
||||
|
||||
async def _fetch_rectangles(self, template_id: str) -> list[dict]:
|
||||
"""Fetch rectangles for a pattern template (no cache — always fresh)."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/pattern-templates/{template_id}",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
rectangles = data.get("rectangles", [])
|
||||
self._pattern_cache[template_id] = rectangles
|
||||
return rectangles
|
||||
return data.get("rectangles", [])
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch pattern template %s: %s", template_id, err
|
||||
@@ -204,7 +199,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -213,18 +208,16 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.warning("Failed to fetch devices: %s", err)
|
||||
return {}
|
||||
|
||||
devices_data: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for device in devices:
|
||||
# Fetch brightness for all capable devices in parallel
|
||||
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
|
||||
device_id = device["id"]
|
||||
entry: dict[str, Any] = {"info": device, "brightness": None}
|
||||
|
||||
if "brightness_control" in (device.get("capabilities") or []):
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
bri_data = await resp.json()
|
||||
@@ -234,7 +227,19 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
"Failed to fetch brightness for device %s: %s",
|
||||
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
|
||||
|
||||
return devices_data
|
||||
@@ -245,7 +250,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"brightness": brightness},
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -262,7 +267,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/color",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"color": color},
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -280,7 +285,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"key_colors_settings": {"brightness": brightness_float}},
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -297,7 +302,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/color-strip-sources",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -312,7 +317,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/value-sources",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -327,7 +332,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/scene-presets",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -336,12 +341,44 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
_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=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -352,13 +389,29 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
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 a output target's fields."""
|
||||
"""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=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -374,7 +427,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status == 409:
|
||||
_LOGGER.debug("Target %s already processing", target_id)
|
||||
@@ -392,7 +445,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status == 409:
|
||||
_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]
|
||||
@@ -96,7 +96,7 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
|
||||
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(option)
|
||||
source_id = self._name_to_id_map().get(option)
|
||||
if source_id is None:
|
||||
_LOGGER.error("CSS source not found: %s", option)
|
||||
return
|
||||
@@ -104,12 +104,9 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
|
||||
self._target_id, color_strip_source_id=source_id
|
||||
)
|
||||
|
||||
def _name_to_id(self, name: str) -> str | None:
|
||||
def _name_to_id_map(self) -> dict[str, str]:
|
||||
sources = (self.coordinator.data or {}).get("css_sources") or []
|
||||
for s in sources:
|
||||
if s["name"] == name:
|
||||
return s["id"]
|
||||
return None
|
||||
return {s["name"]: s["id"] for s in sources}
|
||||
|
||||
|
||||
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
|
||||
@@ -167,17 +164,14 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
|
||||
if option == NONE_OPTION:
|
||||
source_id = ""
|
||||
else:
|
||||
source_id = self._name_to_id(option)
|
||||
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
|
||||
)
|
||||
|
||||
def _name_to_id(self, name: str) -> str | None:
|
||||
sources = (self.coordinator.data or {}).get("value_sources") or []
|
||||
for s in sources:
|
||||
if s["name"] == name:
|
||||
return s["id"]
|
||||
return None
|
||||
|
||||
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:
|
||||
@@ -31,6 +31,11 @@
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Light"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Processing"
|
||||
@@ -66,5 +71,21 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Light"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Processing"
|
||||
@@ -58,9 +63,12 @@
|
||||
"name": "Brightness"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"light": {
|
||||
"name": "Light"
|
||||
"select": {
|
||||
"color_strip_source": {
|
||||
"name": "Color Strip Source"
|
||||
},
|
||||
"brightness_source": {
|
||||
"name": "Brightness Source"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Подсветка"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Обработка"
|
||||
@@ -58,9 +63,12 @@
|
||||
"name": "Яркость"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"light": {
|
||||
"name": "Подсветка"
|
||||
"select": {
|
||||
"color_strip_source": {
|
||||
"name": "Источник цветовой полосы"
|
||||
},
|
||||
"brightness_source": {
|
||||
"name": "Источник яркости"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ netstat -an | grep 8080
|
||||
- **Permission errors**: Ensure file permissions allow Python to execute
|
||||
|
||||
#### 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
|
||||
- Documentation files (`*.md`)
|
||||
- Configuration files in `config/` if server supports hot-reload (check implementation)
|
||||
|
||||
@@ -18,7 +18,7 @@ RUN apt-get update && apt-get install -y \
|
||||
COPY pyproject.toml .
|
||||
COPY src/ ./src/
|
||||
COPY config/ ./config/
|
||||
RUN pip install --no-cache-dir .
|
||||
RUN pip install --no-cache-dir ".[notifications]"
|
||||
|
||||
# Create directories for data and logs
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ dev = [
|
||||
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)
|
||||
perf = [
|
||||
"dxcam>=0.0.5; sys_platform == 'win32'",
|
||||
|
||||
@@ -18,6 +18,7 @@ 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.include_router(system_router)
|
||||
@@ -36,5 +37,6 @@ 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"]
|
||||
|
||||
@@ -75,3 +75,16 @@ def verify_api_key(
|
||||
# Dependency for protected routes
|
||||
# Returns the label/identifier of the authenticated client
|
||||
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.storage import DeviceStore
|
||||
@@ -14,147 +20,101 @@ 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)
|
||||
_auto_backup_engine: AutoBackupEngine | None = None
|
||||
_device_store: DeviceStore | None = None
|
||||
_template_store: TemplateStore | None = None
|
||||
_pp_template_store: PostprocessingTemplateStore | None = None
|
||||
_pattern_template_store: PatternTemplateStore | None = None
|
||||
_picture_source_store: PictureSourceStore | None = None
|
||||
_output_target_store: OutputTargetStore | None = None
|
||||
_color_strip_store: ColorStripStore | None = None
|
||||
_audio_source_store: AudioSourceStore | None = None
|
||||
_audio_template_store: AudioTemplateStore | None = None
|
||||
_value_source_store: ValueSourceStore | None = None
|
||||
_processor_manager: ProcessorManager | None = None
|
||||
_automation_store: AutomationStore | None = None
|
||||
_scene_preset_store: ScenePresetStore | None = None
|
||||
_automation_engine: AutomationEngine | None = None
|
||||
_sync_clock_store: SyncClockStore | None = None
|
||||
_sync_clock_manager: SyncClockManager | None = None
|
||||
T = TypeVar("T")
|
||||
|
||||
# Central dependency registry — keyed by type or string label
|
||||
_deps: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _get(key: str, label: str) -> Any:
|
||||
"""Get a dependency by key, raising RuntimeError if not initialized."""
|
||||
dep = _deps.get(key)
|
||||
if dep is None:
|
||||
raise RuntimeError(f"{label} not initialized")
|
||||
return dep
|
||||
|
||||
|
||||
# ── Typed getters (unchanged signatures for FastAPI Depends()) ──────────
|
||||
|
||||
|
||||
def get_device_store() -> DeviceStore:
|
||||
"""Get device store dependency."""
|
||||
if _device_store is None:
|
||||
raise RuntimeError("Device store not initialized")
|
||||
return _device_store
|
||||
return _get("device_store", "Device store")
|
||||
|
||||
|
||||
def get_template_store() -> TemplateStore:
|
||||
"""Get template store dependency."""
|
||||
if _template_store is None:
|
||||
raise RuntimeError("Template store not initialized")
|
||||
return _template_store
|
||||
return _get("template_store", "Template store")
|
||||
|
||||
|
||||
def get_pp_template_store() -> PostprocessingTemplateStore:
|
||||
"""Get postprocessing template store dependency."""
|
||||
if _pp_template_store is None:
|
||||
raise RuntimeError("Postprocessing template store not initialized")
|
||||
return _pp_template_store
|
||||
return _get("pp_template_store", "Postprocessing template store")
|
||||
|
||||
|
||||
def get_pattern_template_store() -> PatternTemplateStore:
|
||||
"""Get pattern template store dependency."""
|
||||
if _pattern_template_store is None:
|
||||
raise RuntimeError("Pattern template store not initialized")
|
||||
return _pattern_template_store
|
||||
return _get("pattern_template_store", "Pattern template store")
|
||||
|
||||
|
||||
def get_picture_source_store() -> PictureSourceStore:
|
||||
"""Get picture source store dependency."""
|
||||
if _picture_source_store is None:
|
||||
raise RuntimeError("Picture source store not initialized")
|
||||
return _picture_source_store
|
||||
return _get("picture_source_store", "Picture source store")
|
||||
|
||||
|
||||
def get_output_target_store() -> OutputTargetStore:
|
||||
"""Get output target store dependency."""
|
||||
if _output_target_store is None:
|
||||
raise RuntimeError("Picture target store not initialized")
|
||||
return _output_target_store
|
||||
return _get("output_target_store", "Output target store")
|
||||
|
||||
|
||||
def get_color_strip_store() -> ColorStripStore:
|
||||
"""Get color strip store dependency."""
|
||||
if _color_strip_store is None:
|
||||
raise RuntimeError("Color strip store not initialized")
|
||||
return _color_strip_store
|
||||
return _get("color_strip_store", "Color strip store")
|
||||
|
||||
|
||||
def get_audio_source_store() -> AudioSourceStore:
|
||||
"""Get audio source store dependency."""
|
||||
if _audio_source_store is None:
|
||||
raise RuntimeError("Audio source store not initialized")
|
||||
return _audio_source_store
|
||||
return _get("audio_source_store", "Audio source store")
|
||||
|
||||
|
||||
def get_audio_template_store() -> AudioTemplateStore:
|
||||
"""Get audio template store dependency."""
|
||||
if _audio_template_store is None:
|
||||
raise RuntimeError("Audio template store not initialized")
|
||||
return _audio_template_store
|
||||
return _get("audio_template_store", "Audio template store")
|
||||
|
||||
|
||||
def get_value_source_store() -> ValueSourceStore:
|
||||
"""Get value source store dependency."""
|
||||
if _value_source_store is None:
|
||||
raise RuntimeError("Value source store not initialized")
|
||||
return _value_source_store
|
||||
return _get("value_source_store", "Value source store")
|
||||
|
||||
|
||||
def get_processor_manager() -> ProcessorManager:
|
||||
"""Get processor manager dependency."""
|
||||
if _processor_manager is None:
|
||||
raise RuntimeError("Processor manager not initialized")
|
||||
return _processor_manager
|
||||
return _get("processor_manager", "Processor manager")
|
||||
|
||||
|
||||
def get_automation_store() -> AutomationStore:
|
||||
"""Get automation store dependency."""
|
||||
if _automation_store is None:
|
||||
raise RuntimeError("Automation store not initialized")
|
||||
return _automation_store
|
||||
return _get("automation_store", "Automation store")
|
||||
|
||||
|
||||
def get_scene_preset_store() -> ScenePresetStore:
|
||||
"""Get scene preset store dependency."""
|
||||
if _scene_preset_store is None:
|
||||
raise RuntimeError("Scene preset store not initialized")
|
||||
return _scene_preset_store
|
||||
return _get("scene_preset_store", "Scene preset store")
|
||||
|
||||
|
||||
def get_automation_engine() -> AutomationEngine:
|
||||
"""Get automation engine dependency."""
|
||||
if _automation_engine is None:
|
||||
raise RuntimeError("Automation engine not initialized")
|
||||
return _automation_engine
|
||||
return _get("automation_engine", "Automation engine")
|
||||
|
||||
|
||||
def get_auto_backup_engine() -> AutoBackupEngine:
|
||||
"""Get auto-backup engine dependency."""
|
||||
if _auto_backup_engine is None:
|
||||
raise RuntimeError("Auto-backup engine not initialized")
|
||||
return _auto_backup_engine
|
||||
return _get("auto_backup_engine", "Auto-backup engine")
|
||||
|
||||
|
||||
def get_sync_clock_store() -> SyncClockStore:
|
||||
"""Get sync clock store dependency."""
|
||||
if _sync_clock_store is None:
|
||||
raise RuntimeError("Sync clock store not initialized")
|
||||
return _sync_clock_store
|
||||
return _get("sync_clock_store", "Sync clock store")
|
||||
|
||||
|
||||
def get_sync_clock_manager() -> SyncClockManager:
|
||||
"""Get sync clock manager dependency."""
|
||||
if _sync_clock_manager is None:
|
||||
raise RuntimeError("Sync clock manager not initialized")
|
||||
return _sync_clock_manager
|
||||
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:
|
||||
@@ -165,8 +125,9 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
action: "created", "updated", or "deleted"
|
||||
entity_id: The entity's unique ID
|
||||
"""
|
||||
if _processor_manager is not None:
|
||||
_processor_manager.fire_event({
|
||||
pm = _deps.get("processor_manager")
|
||||
if pm is not None:
|
||||
pm.fire_event({
|
||||
"type": "entity_changed",
|
||||
"entity_type": entity_type,
|
||||
"action": action,
|
||||
@@ -174,6 +135,9 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
})
|
||||
|
||||
|
||||
# ── Initialization ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def init_dependencies(
|
||||
device_store: DeviceStore,
|
||||
template_store: TemplateStore,
|
||||
@@ -192,27 +156,26 @@ def init_dependencies(
|
||||
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."""
|
||||
global _device_store, _template_store, _processor_manager
|
||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _output_target_store
|
||||
global _color_strip_store, _audio_source_store, _audio_template_store
|
||||
global _value_source_store, _automation_store, _scene_preset_store, _automation_engine, _auto_backup_engine
|
||||
global _sync_clock_store, _sync_clock_manager
|
||||
_device_store = device_store
|
||||
_template_store = template_store
|
||||
_processor_manager = processor_manager
|
||||
_pp_template_store = pp_template_store
|
||||
_pattern_template_store = pattern_template_store
|
||||
_picture_source_store = picture_source_store
|
||||
_output_target_store = output_target_store
|
||||
_color_strip_store = color_strip_store
|
||||
_audio_source_store = audio_source_store
|
||||
_audio_template_store = audio_template_store
|
||||
_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
|
||||
_deps.update({
|
||||
"device_store": device_store,
|
||||
"template_store": template_store,
|
||||
"processor_manager": processor_manager,
|
||||
"pp_template_store": pp_template_store,
|
||||
"pattern_template_store": pattern_template_store,
|
||||
"picture_source_store": picture_source_store,
|
||||
"output_target_store": output_target_store,
|
||||
"color_strip_store": color_strip_store,
|
||||
"audio_source_store": audio_source_store,
|
||||
"audio_template_store": audio_template_store,
|
||||
"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,
|
||||
})
|
||||
|
||||
@@ -26,13 +26,12 @@ PREVIEW_JPEG_QUALITY = 70
|
||||
|
||||
|
||||
def authenticate_ws_token(token: str) -> bool:
|
||||
"""Check a WebSocket query-param token against configured API keys."""
|
||||
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):
|
||||
return True
|
||||
return False
|
||||
"""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:
|
||||
@@ -44,6 +43,19 @@ def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
|
||||
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()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Audio device routes: enumerate available audio devices."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
@@ -17,8 +19,12 @@ async def list_audio_devices(_auth: AuthRequired):
|
||||
filter by the selected audio template's engine type.
|
||||
"""
|
||||
try:
|
||||
devices = AudioCaptureManager.enumerate_devices()
|
||||
by_engine = AudioCaptureManager.enumerate_devices_by_engine()
|
||||
devices, by_engine = await asyncio.to_thread(
|
||||
lambda: (
|
||||
AudioCaptureManager.enumerate_devices(),
|
||||
AudioCaptureManager.enumerate_devices_by_engine(),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"devices": devices,
|
||||
"count": len(devices),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -21,11 +20,11 @@ from wled_controller.api.schemas.audio_sources import (
|
||||
AudioSourceResponse,
|
||||
AudioSourceUpdate,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
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__)
|
||||
|
||||
@@ -44,7 +43,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
audio_source_id=getattr(source, "audio_source_id", None),
|
||||
channel=getattr(source, "channel", None),
|
||||
description=source.description,
|
||||
tags=getattr(source, 'tags', []),
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -87,6 +86,9 @@ async def create_audio_source(
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -127,6 +129,9 @@ async def update_audio_source(
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -150,6 +155,9 @@ async def delete_audio_source(
|
||||
|
||||
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))
|
||||
|
||||
@@ -169,16 +177,8 @@ async def test_audio_source_ws(
|
||||
(ref-counted — shares with running targets), and streams AudioAnalysis
|
||||
snapshots as JSON at ~20 Hz.
|
||||
"""
|
||||
# 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:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -17,11 +15,11 @@ from wled_controller.api.schemas.audio_templates import (
|
||||
AudioTemplateResponse,
|
||||
AudioTemplateUpdate,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
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__)
|
||||
|
||||
@@ -41,7 +39,7 @@ async def list_audio_templates(
|
||||
responses = [
|
||||
AudioTemplateResponse(
|
||||
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
|
||||
engine_config=t.engine_config, tags=t.tags,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at, description=t.description,
|
||||
)
|
||||
@@ -69,10 +67,13 @@ async def create_audio_template(
|
||||
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=getattr(template, 'tags', []),
|
||||
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:
|
||||
@@ -93,7 +94,7 @@ async def get_audio_template(
|
||||
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=getattr(t, 'tags', []),
|
||||
engine_config=t.engine_config, tags=t.tags,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at, description=t.description,
|
||||
)
|
||||
@@ -116,10 +117,13 @@ async def update_audio_template(
|
||||
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=getattr(t, 'tags', []),
|
||||
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:
|
||||
@@ -140,6 +144,9 @@ async def delete_audio_template(
|
||||
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:
|
||||
@@ -189,16 +196,8 @@ async def test_audio_template_ws(
|
||||
Auth via ?token=<api_key>. Device specified via ?device_index=N&is_loopback=0|1.
|
||||
Streams AudioAnalysis snapshots as JSON at ~20 Hz.
|
||||
"""
|
||||
# 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:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from wled_controller.storage.automation import (
|
||||
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()
|
||||
@@ -89,7 +90,12 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
|
||||
webhook_url = None
|
||||
for c in automation.conditions:
|
||||
if isinstance(c, WebhookCondition) and c.token:
|
||||
if request:
|
||||
# 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}"
|
||||
@@ -108,7 +114,7 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
|
||||
is_active=state["is_active"],
|
||||
last_activated_at=state.get("last_activated_at"),
|
||||
last_deactivated_at=state.get("last_deactivated_at"),
|
||||
tags=getattr(automation, 'tags', []),
|
||||
tags=automation.tags,
|
||||
created_at=automation.created_at,
|
||||
updated_at=automation.updated_at,
|
||||
)
|
||||
@@ -158,6 +164,9 @@ async def create_automation(
|
||||
|
||||
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))
|
||||
|
||||
@@ -244,6 +253,9 @@ async def update_automation(
|
||||
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))
|
||||
|
||||
|
||||
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}")
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Color strip source routes: CRUD, calibration test, and API input push."""
|
||||
"""Color strip source routes: CRUD, calibration test, preview, and API input push."""
|
||||
|
||||
import secrets
|
||||
import asyncio
|
||||
import io as _io
|
||||
import json as _json
|
||||
import time as _time
|
||||
import uuid as _uuid
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
@@ -32,28 +36,36 @@ from wled_controller.core.capture.calibration import (
|
||||
)
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, NotificationColorStripSource, PictureColorStripSource
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource, ProcessedColorStripSource
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.config import get_config
|
||||
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
|
||||
"""Convert a ColorStripSource to a ColorStripSourceResponse."""
|
||||
calibration = None
|
||||
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) and source.calibration:
|
||||
calibration = CalibrationSchema(**calibration_to_dict(source.calibration))
|
||||
"""Convert a ColorStripSource to a ColorStripSourceResponse.
|
||||
|
||||
# Convert raw stop dicts to ColorStop schema objects for gradient sources
|
||||
Uses the source's to_dict() for type-specific fields, then applies
|
||||
schema conversions for calibration and gradient stops.
|
||||
"""
|
||||
from wled_controller.api.schemas.color_strip_sources import ColorStop as ColorStopSchema
|
||||
raw_stops = getattr(source, "stops", None)
|
||||
|
||||
d = source.to_dict()
|
||||
|
||||
# Convert calibration dict → schema object
|
||||
calibration = None
|
||||
raw_cal = d.pop("calibration", None)
|
||||
if raw_cal and isinstance(raw_cal, dict):
|
||||
calibration = CalibrationSchema(**raw_cal)
|
||||
|
||||
# Convert stop dicts → schema objects
|
||||
raw_stops = d.pop("stops", None)
|
||||
stops = None
|
||||
if raw_stops is not None:
|
||||
try:
|
||||
@@ -61,51 +73,20 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
||||
except Exception:
|
||||
stops = None
|
||||
|
||||
# Remove serialized timestamp strings — use actual datetime objects
|
||||
d.pop("created_at", None)
|
||||
d.pop("updated_at", None)
|
||||
|
||||
# Filter to only keys accepted by the schema (to_dict may include extra
|
||||
# fields like 'fps' that aren't in the response model)
|
||||
valid_fields = ColorStripSourceResponse.model_fields
|
||||
filtered = {k: v for k, v in d.items() if k in valid_fields}
|
||||
|
||||
return ColorStripSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
source_type=source.source_type,
|
||||
picture_source_id=getattr(source, "picture_source_id", None),
|
||||
brightness=getattr(source, "brightness", None),
|
||||
saturation=getattr(source, "saturation", None),
|
||||
gamma=getattr(source, "gamma", None),
|
||||
smoothing=getattr(source, "smoothing", None),
|
||||
interpolation_mode=getattr(source, "interpolation_mode", None),
|
||||
led_count=getattr(source, "led_count", 0),
|
||||
**filtered,
|
||||
calibration=calibration,
|
||||
color=getattr(source, "color", None),
|
||||
stops=stops,
|
||||
colors=getattr(source, "colors", None),
|
||||
effect_type=getattr(source, "effect_type", None),
|
||||
palette=getattr(source, "palette", None),
|
||||
intensity=getattr(source, "intensity", None),
|
||||
scale=getattr(source, "scale", None),
|
||||
mirror=getattr(source, "mirror", None),
|
||||
description=source.description,
|
||||
clock_id=source.clock_id,
|
||||
frame_interpolation=getattr(source, "frame_interpolation", None),
|
||||
animation=getattr(source, "animation", None),
|
||||
layers=getattr(source, "layers", None),
|
||||
zones=getattr(source, "zones", None),
|
||||
visualization_mode=getattr(source, "visualization_mode", None),
|
||||
audio_source_id=getattr(source, "audio_source_id", None),
|
||||
sensitivity=getattr(source, "sensitivity", None),
|
||||
color_peak=getattr(source, "color_peak", None),
|
||||
fallback_color=getattr(source, "fallback_color", None),
|
||||
timeout=getattr(source, "timeout", None),
|
||||
notification_effect=getattr(source, "notification_effect", None),
|
||||
duration_ms=getattr(source, "duration_ms", None),
|
||||
default_color=getattr(source, "default_color", None),
|
||||
app_colors=getattr(source, "app_colors", None),
|
||||
app_filter_mode=getattr(source, "app_filter_mode", None),
|
||||
app_filter_list=getattr(source, "app_filter_list", None),
|
||||
os_listener=getattr(source, "os_listener", None),
|
||||
speed=getattr(source, "speed", None),
|
||||
use_real_time=getattr(source, "use_real_time", None),
|
||||
latitude=getattr(source, "latitude", None),
|
||||
num_candles=getattr(source, "num_candles", None),
|
||||
overlay_active=overlay_active,
|
||||
tags=getattr(source, 'tags', []),
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -140,6 +121,27 @@ async def list_color_strip_sources(
|
||||
return ColorStripSourceListResponse(sources=responses, count=len(responses))
|
||||
|
||||
|
||||
def _extract_css_kwargs(data) -> dict:
|
||||
"""Extract store-compatible kwargs from a Pydantic CSS create/update schema.
|
||||
|
||||
Converts nested Pydantic models (calibration, stops, layers, zones,
|
||||
animation) to plain dicts/lists that the store expects.
|
||||
"""
|
||||
kwargs = data.model_dump(exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"})
|
||||
# Remove fields that don't map to store kwargs
|
||||
kwargs.pop("source_type", None)
|
||||
|
||||
if data.calibration is not None:
|
||||
kwargs["calibration"] = calibration_from_dict(data.calibration.model_dump())
|
||||
else:
|
||||
kwargs["calibration"] = None
|
||||
kwargs["stops"] = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
||||
kwargs["layers"] = [l.model_dump() for l in data.layers] if data.layers is not None else None
|
||||
kwargs["zones"] = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
||||
kwargs["animation"] = data.animation.model_dump() if data.animation else None
|
||||
return kwargs
|
||||
|
||||
|
||||
@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201)
|
||||
async def create_color_strip_source(
|
||||
data: ColorStripSourceCreate,
|
||||
@@ -148,63 +150,15 @@ async def create_color_strip_source(
|
||||
):
|
||||
"""Create a new color strip source."""
|
||||
try:
|
||||
calibration = None
|
||||
if data.calibration is not None:
|
||||
calibration = calibration_from_dict(data.calibration.model_dump())
|
||||
|
||||
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
||||
|
||||
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
|
||||
|
||||
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
||||
|
||||
source = store.create_source(
|
||||
name=data.name,
|
||||
source_type=data.source_type,
|
||||
picture_source_id=data.picture_source_id,
|
||||
brightness=data.brightness,
|
||||
saturation=data.saturation,
|
||||
gamma=data.gamma,
|
||||
smoothing=data.smoothing,
|
||||
interpolation_mode=data.interpolation_mode,
|
||||
led_count=data.led_count,
|
||||
calibration=calibration,
|
||||
color=data.color,
|
||||
stops=stops,
|
||||
description=data.description,
|
||||
frame_interpolation=data.frame_interpolation,
|
||||
animation=data.animation.model_dump() if data.animation else None,
|
||||
colors=data.colors,
|
||||
effect_type=data.effect_type,
|
||||
palette=data.palette,
|
||||
intensity=data.intensity,
|
||||
scale=data.scale,
|
||||
mirror=data.mirror,
|
||||
layers=layers,
|
||||
zones=zones,
|
||||
visualization_mode=data.visualization_mode,
|
||||
audio_source_id=data.audio_source_id,
|
||||
sensitivity=data.sensitivity,
|
||||
color_peak=data.color_peak,
|
||||
fallback_color=data.fallback_color,
|
||||
timeout=data.timeout,
|
||||
clock_id=data.clock_id,
|
||||
notification_effect=data.notification_effect,
|
||||
duration_ms=data.duration_ms,
|
||||
default_color=data.default_color,
|
||||
app_colors=data.app_colors,
|
||||
app_filter_mode=data.app_filter_mode,
|
||||
app_filter_list=data.app_filter_list,
|
||||
os_listener=data.os_listener,
|
||||
speed=data.speed,
|
||||
use_real_time=data.use_real_time,
|
||||
latitude=data.latitude,
|
||||
num_candles=data.num_candles,
|
||||
tags=data.tags,
|
||||
)
|
||||
kwargs = _extract_css_kwargs(data)
|
||||
source = store.create_source(source_type=data.source_type, **kwargs)
|
||||
fire_entity_event("color_strip_source", "created", source.id)
|
||||
return _css_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))
|
||||
except Exception as e:
|
||||
@@ -237,60 +191,8 @@ async def update_color_strip_source(
|
||||
):
|
||||
"""Update a color strip source and hot-reload any running streams."""
|
||||
try:
|
||||
calibration = None
|
||||
if data.calibration is not None:
|
||||
calibration = calibration_from_dict(data.calibration.model_dump())
|
||||
|
||||
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
||||
|
||||
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
|
||||
|
||||
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
||||
|
||||
source = store.update_source(
|
||||
source_id=source_id,
|
||||
name=data.name,
|
||||
picture_source_id=data.picture_source_id,
|
||||
brightness=data.brightness,
|
||||
saturation=data.saturation,
|
||||
gamma=data.gamma,
|
||||
smoothing=data.smoothing,
|
||||
interpolation_mode=data.interpolation_mode,
|
||||
led_count=data.led_count,
|
||||
calibration=calibration,
|
||||
color=data.color,
|
||||
stops=stops,
|
||||
description=data.description,
|
||||
frame_interpolation=data.frame_interpolation,
|
||||
animation=data.animation.model_dump() if data.animation else None,
|
||||
colors=data.colors,
|
||||
effect_type=data.effect_type,
|
||||
palette=data.palette,
|
||||
intensity=data.intensity,
|
||||
scale=data.scale,
|
||||
mirror=data.mirror,
|
||||
layers=layers,
|
||||
zones=zones,
|
||||
visualization_mode=data.visualization_mode,
|
||||
audio_source_id=data.audio_source_id,
|
||||
sensitivity=data.sensitivity,
|
||||
color_peak=data.color_peak,
|
||||
fallback_color=data.fallback_color,
|
||||
timeout=data.timeout,
|
||||
clock_id=data.clock_id,
|
||||
notification_effect=data.notification_effect,
|
||||
duration_ms=data.duration_ms,
|
||||
default_color=data.default_color,
|
||||
app_colors=data.app_colors,
|
||||
app_filter_mode=data.app_filter_mode,
|
||||
app_filter_list=data.app_filter_list,
|
||||
os_listener=data.os_listener,
|
||||
speed=data.speed,
|
||||
use_real_time=data.use_real_time,
|
||||
latitude=data.latitude,
|
||||
num_candles=data.num_candles,
|
||||
tags=data.tags,
|
||||
)
|
||||
kwargs = _extract_css_kwargs(data)
|
||||
source = store.update_source(source_id=source_id, **kwargs)
|
||||
|
||||
# Hot-reload running stream (no restart needed for in-place param changes)
|
||||
try:
|
||||
@@ -341,6 +243,14 @@ async def delete_color_strip_source(
|
||||
detail=f"Color strip source is used as a zone in mapped source(s): {names}. "
|
||||
"Remove it from the mapped source(s) first.",
|
||||
)
|
||||
processed_names = store.get_processed_referencing(source_id)
|
||||
if processed_names:
|
||||
names = ", ".join(processed_names)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Color strip source is used as input in processed source(s): {names}. "
|
||||
"Delete or reassign the processed source(s) first.",
|
||||
)
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("color_strip_source", "deleted", source_id)
|
||||
except HTTPException:
|
||||
@@ -505,7 +415,8 @@ async def push_colors(
|
||||
):
|
||||
"""Push raw LED colors to an api_input color strip source.
|
||||
|
||||
The colors are forwarded to all running stream instances for this source.
|
||||
Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based).
|
||||
The payload is forwarded to all running stream instances for this source.
|
||||
"""
|
||||
try:
|
||||
source = store.get_source(source_id)
|
||||
@@ -515,15 +426,27 @@ async def push_colors(
|
||||
if not isinstance(source, ApiInputColorStripSource):
|
||||
raise HTTPException(status_code=400, detail="Source is not an api_input type")
|
||||
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
|
||||
if body.segments is not None:
|
||||
# Segment-based path
|
||||
seg_dicts = [s.model_dump() for s in body.segments]
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_segments"):
|
||||
stream.push_segments(seg_dicts)
|
||||
return {
|
||||
"status": "ok",
|
||||
"streams_updated": len(streams),
|
||||
"segments_applied": len(body.segments),
|
||||
}
|
||||
else:
|
||||
# Legacy flat colors path
|
||||
colors_array = np.array(body.colors, dtype=np.uint8)
|
||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
|
||||
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
stream.push_colors(colors_array)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"streams_updated": len(streams),
|
||||
@@ -570,6 +493,199 @@ async def notify_source(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-sources/os-notifications/history", tags=["Color Strip Sources"])
|
||||
async def os_notification_history(_auth: AuthRequired):
|
||||
"""Return recent OS notification capture history (newest first)."""
|
||||
from wled_controller.core.processing.os_notification_listener import get_os_notification_listener
|
||||
listener = get_os_notification_listener()
|
||||
if listener is None:
|
||||
return {"available": False, "history": []}
|
||||
return {
|
||||
"available": listener.available,
|
||||
"history": listener.recent_history,
|
||||
}
|
||||
|
||||
|
||||
# ── Transient Preview WebSocket ────────────────────────────────────────
|
||||
|
||||
_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight"}
|
||||
|
||||
|
||||
@router.websocket("/api/v1/color-strip-sources/preview/ws")
|
||||
async def preview_color_strip_ws(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(""),
|
||||
led_count: int = Query(100),
|
||||
fps: int = Query(20),
|
||||
):
|
||||
"""Transient preview WebSocket — stream frames for an ad-hoc source config.
|
||||
|
||||
Auth via ``?token=<api_key>&led_count=100&fps=20``.
|
||||
|
||||
After accepting, waits for a text message containing the full source config
|
||||
JSON (must include ``source_type``). Responds with a JSON metadata message,
|
||||
then streams binary RGB frames at the requested FPS.
|
||||
|
||||
Subsequent text messages are treated as config updates: if the source_type
|
||||
changed the old stream is replaced; otherwise ``update_source()`` is used.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
led_count = max(1, min(1000, led_count))
|
||||
fps = max(1, min(60, fps))
|
||||
frame_interval = 1.0 / fps
|
||||
|
||||
stream = None
|
||||
clock_id = None
|
||||
current_source_type = None
|
||||
|
||||
# Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_sync_clock_manager():
|
||||
"""Return the SyncClockManager if available."""
|
||||
try:
|
||||
mgr = get_processor_manager()
|
||||
return getattr(mgr, "_sync_clock_manager", None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _build_source(config: dict):
|
||||
"""Build a ColorStripSource from a raw config dict, injecting synthetic id/name."""
|
||||
from wled_controller.storage.color_strip_source import ColorStripSource
|
||||
config.setdefault("id", "__preview__")
|
||||
config.setdefault("name", "__preview__")
|
||||
return ColorStripSource.from_dict(config)
|
||||
|
||||
def _create_stream(source):
|
||||
"""Instantiate and start the appropriate stream class for *source*."""
|
||||
from wled_controller.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
|
||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||
if not stream_cls:
|
||||
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
|
||||
s = stream_cls(source)
|
||||
if hasattr(s, "configure"):
|
||||
s.configure(led_count)
|
||||
# Inject sync clock if requested
|
||||
cid = getattr(source, "clock_id", None)
|
||||
if cid and hasattr(s, "set_clock"):
|
||||
scm = _get_sync_clock_manager()
|
||||
if scm:
|
||||
try:
|
||||
clock_rt = scm.acquire(cid)
|
||||
s.set_clock(clock_rt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Preview: could not acquire clock {cid}: {e}")
|
||||
cid = None
|
||||
else:
|
||||
cid = None
|
||||
else:
|
||||
cid = None
|
||||
s.start()
|
||||
return s, cid
|
||||
|
||||
def _stop_stream(s, cid):
|
||||
"""Stop a stream and release its clock."""
|
||||
try:
|
||||
s.stop()
|
||||
except Exception:
|
||||
pass
|
||||
if cid:
|
||||
scm = _get_sync_clock_manager()
|
||||
if scm:
|
||||
try:
|
||||
scm.release(cid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _send_meta(source_type: str):
|
||||
meta = {"type": "meta", "led_count": led_count, "source_type": source_type}
|
||||
await websocket.send_text(_json.dumps(meta))
|
||||
|
||||
# Wait for initial config ────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
initial_text = await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
config = _json.loads(initial_text)
|
||||
source_type = config.get("source_type")
|
||||
if source_type not in _PREVIEW_ALLOWED_TYPES:
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
|
||||
await websocket.close(code=4003, reason="Invalid source_type")
|
||||
return
|
||||
source = _build_source(config)
|
||||
stream, clock_id = _create_stream(source)
|
||||
current_source_type = source_type
|
||||
except Exception as e:
|
||||
logger.error(f"Preview WS: bad initial config: {e}")
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
await _send_meta(current_source_type)
|
||||
logger.info(f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}")
|
||||
|
||||
# Frame loop ─────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Non-blocking check for incoming config updates
|
||||
try:
|
||||
msg = await asyncio.wait_for(websocket.receive_text(), timeout=frame_interval)
|
||||
except asyncio.TimeoutError:
|
||||
msg = None
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
if msg is not None:
|
||||
try:
|
||||
new_config = _json.loads(msg)
|
||||
new_type = new_config.get("source_type")
|
||||
if new_type not in _PREVIEW_ALLOWED_TYPES:
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
|
||||
continue
|
||||
new_source = _build_source(new_config)
|
||||
if new_type != current_source_type:
|
||||
# Source type changed — recreate stream
|
||||
_stop_stream(stream, clock_id)
|
||||
stream, clock_id = _create_stream(new_source)
|
||||
current_source_type = new_type
|
||||
else:
|
||||
stream.update_source(new_source)
|
||||
if hasattr(stream, "configure"):
|
||||
stream.configure(led_count)
|
||||
await _send_meta(current_source_type)
|
||||
except Exception as e:
|
||||
logger.warning(f"Preview WS: bad config update: {e}")
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
|
||||
|
||||
# Send frame
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
else:
|
||||
# Stream hasn't produced a frame yet — send black
|
||||
await websocket.send_bytes(b'\x00' * led_count * 3)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Preview WS error: {e}")
|
||||
finally:
|
||||
if stream is not None:
|
||||
_stop_stream(stream, clock_id)
|
||||
logger.info("Preview WS disconnected")
|
||||
|
||||
|
||||
@router.websocket("/api/v1/color-strip-sources/{source_id}/ws")
|
||||
async def css_api_input_ws(
|
||||
websocket: WebSocket,
|
||||
@@ -581,16 +697,8 @@ async def css_api_input_ws(
|
||||
Auth via ?token=<api_key>. Accepts JSON frames ({"colors": [[R,G,B], ...]})
|
||||
or binary frames (raw RGBRGB... bytes, 3 bytes per LED).
|
||||
"""
|
||||
# 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:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -618,18 +726,41 @@ async def css_api_input_ws(
|
||||
break
|
||||
|
||||
if "text" in message:
|
||||
# JSON frame: {"colors": [[R,G,B], ...]}
|
||||
# JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
|
||||
import json
|
||||
try:
|
||||
data = json.loads(message["text"])
|
||||
raw_colors = data.get("colors", [])
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
await websocket.send_json({"error": str(e)})
|
||||
continue
|
||||
|
||||
if "segments" in data:
|
||||
# Segment-based path — validate and push
|
||||
try:
|
||||
from wled_controller.api.schemas.color_strip_sources import SegmentPayload
|
||||
seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
|
||||
except Exception as e:
|
||||
await websocket.send_json({"error": f"Invalid segment: {e}"})
|
||||
continue
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_segments"):
|
||||
stream.push_segments(seg_dicts)
|
||||
continue
|
||||
|
||||
elif "colors" in data:
|
||||
try:
|
||||
raw_colors = data["colors"]
|
||||
colors_array = np.array(raw_colors, dtype=np.uint8)
|
||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
|
||||
continue
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||
except (ValueError, TypeError) as e:
|
||||
await websocket.send_json({"error": str(e)})
|
||||
continue
|
||||
else:
|
||||
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
|
||||
continue
|
||||
|
||||
elif "bytes" in message:
|
||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
|
||||
@@ -642,7 +773,7 @@ async def css_api_input_ws(
|
||||
else:
|
||||
continue
|
||||
|
||||
# Push to all running streams
|
||||
# Push to all running streams (colors_array path only reaches here)
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
@@ -654,3 +785,210 @@ async def css_api_input_ws(
|
||||
logger.error(f"API input WebSocket error for source {source_id}: {e}")
|
||||
finally:
|
||||
logger.info(f"API input WebSocket disconnected for source {source_id}")
|
||||
|
||||
|
||||
# ── Test / Preview WebSocket ──────────────────────────────────────────
|
||||
|
||||
@router.websocket("/api/v1/color-strip-sources/{source_id}/test/ws")
|
||||
async def test_color_strip_ws(
|
||||
websocket: WebSocket,
|
||||
source_id: str,
|
||||
token: str = Query(""),
|
||||
led_count: int = Query(100),
|
||||
fps: int = Query(20),
|
||||
):
|
||||
"""WebSocket for real-time CSS source preview. Auth via ``?token=<api_key>``.
|
||||
|
||||
First message is JSON metadata (source_type, led_count, calibration segments).
|
||||
Subsequent messages are binary RGB frames (``led_count * 3`` bytes).
|
||||
"""
|
||||
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: ColorStripStore = get_color_strip_store()
|
||||
try:
|
||||
source = store.get_source(source_id)
|
||||
except ValueError as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
# Acquire stream – unique consumer ID per WS to avoid release races
|
||||
manager: ProcessorManager = get_processor_manager()
|
||||
csm = manager.color_strip_stream_manager
|
||||
consumer_id = f"__test_{_uuid.uuid4().hex[:8]}__"
|
||||
try:
|
||||
stream = csm.acquire(source_id, consumer_id)
|
||||
except Exception as e:
|
||||
logger.error(f"CSS test: failed to acquire stream for {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))
|
||||
|
||||
# Clamp FPS to sane range
|
||||
fps = max(1, min(60, fps))
|
||||
_frame_interval = 1.0 / fps
|
||||
|
||||
await websocket.accept()
|
||||
logger.info(f"CSS test WebSocket connected for {source_id} (fps={fps})")
|
||||
|
||||
try:
|
||||
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
||||
|
||||
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
|
||||
is_api_input = isinstance(stream, ApiInputColorStripStream)
|
||||
_last_push_gen = 0 # track api_input push generation to skip unchanged frames
|
||||
|
||||
# Send metadata as first message
|
||||
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
|
||||
is_composite = isinstance(source, CompositeColorStripSource)
|
||||
meta: dict = {
|
||||
"type": "meta",
|
||||
"source_type": source.source_type,
|
||||
"source_name": source.name,
|
||||
"led_count": stream.led_count,
|
||||
}
|
||||
if is_picture and stream.calibration:
|
||||
cal = stream.calibration
|
||||
total = cal.get_total_leds()
|
||||
offset = cal.offset % total if total > 0 else 0
|
||||
edges = []
|
||||
for seg in cal.segments:
|
||||
# Compute output indices matching PixelMapper logic
|
||||
indices = list(range(seg.led_start, seg.led_start + seg.led_count))
|
||||
if seg.reverse:
|
||||
indices = indices[::-1]
|
||||
if offset > 0:
|
||||
indices = [(idx + offset) % total for idx in indices]
|
||||
edges.append({"edge": seg.edge, "indices": indices})
|
||||
meta["edges"] = edges
|
||||
meta["border_width"] = cal.border_width
|
||||
if is_composite and hasattr(source, "layers"):
|
||||
# Send layer info for composite preview
|
||||
enabled_layers = [l for l in source.layers if l.get("enabled", True)]
|
||||
layer_infos = [] # [{name, id, is_notification, has_brightness, ...}, ...]
|
||||
for layer in enabled_layers:
|
||||
info = {"id": layer["source_id"], "name": layer.get("source_id", "?"),
|
||||
"is_notification": False, "has_brightness": bool(layer.get("brightness_source_id"))}
|
||||
try:
|
||||
layer_src = store.get_source(layer["source_id"])
|
||||
info["name"] = layer_src.name
|
||||
info["is_notification"] = isinstance(layer_src, NotificationColorStripSource)
|
||||
if isinstance(layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
info["is_picture"] = True
|
||||
if hasattr(layer_src, "calibration") and layer_src.calibration:
|
||||
info["calibration_led_count"] = layer_src.calibration.get_total_leds()
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
layer_infos.append(info)
|
||||
meta["layers"] = [li["name"] for li in layer_infos]
|
||||
meta["layer_infos"] = layer_infos
|
||||
await websocket.send_text(_json.dumps(meta))
|
||||
|
||||
# For picture sources, grab the live stream for frame preview
|
||||
_frame_live = None
|
||||
if is_picture and hasattr(stream, 'live_stream'):
|
||||
_frame_live = stream.live_stream
|
||||
_last_aux_time = 0.0
|
||||
_AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS
|
||||
_frame_dims_sent = False # send frame dimensions once with first JPEG
|
||||
|
||||
# Stream binary RGB frames at ~20 Hz
|
||||
while True:
|
||||
# For composite sources, send per-layer data like target preview does
|
||||
if is_composite and isinstance(stream, CompositeColorStripStream):
|
||||
layer_colors = stream.get_layer_colors()
|
||||
composite_colors = stream.get_latest_colors()
|
||||
if composite_colors is not None and layer_colors and len(layer_colors) > 1:
|
||||
led_count = composite_colors.shape[0]
|
||||
rgb_size = led_count * 3
|
||||
# Wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layer0_rgb...] ... [composite_rgb]
|
||||
header = bytes([0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF])
|
||||
parts = [header]
|
||||
for lc in layer_colors:
|
||||
if lc is not None and lc.shape[0] == led_count:
|
||||
parts.append(lc.tobytes())
|
||||
else:
|
||||
parts.append(b'\x00' * rgb_size)
|
||||
parts.append(composite_colors.tobytes())
|
||||
await websocket.send_bytes(b''.join(parts))
|
||||
elif composite_colors is not None:
|
||||
await websocket.send_bytes(composite_colors.tobytes())
|
||||
else:
|
||||
# For api_input: only send when new data was pushed
|
||||
if is_api_input:
|
||||
gen = stream.push_generation
|
||||
if gen != _last_push_gen:
|
||||
_last_push_gen = gen
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
else:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
|
||||
# Periodically send auxiliary data (frame preview, brightness)
|
||||
now = _time.monotonic()
|
||||
if now - _last_aux_time >= _AUX_INTERVAL:
|
||||
_last_aux_time = now
|
||||
|
||||
# Send brightness values for composite layers
|
||||
if is_composite and isinstance(stream, CompositeColorStripStream):
|
||||
try:
|
||||
bri_values = stream.get_layer_brightness()
|
||||
if any(v is not None for v in bri_values):
|
||||
bri_msg = {"type": "brightness", "values": [
|
||||
round(v * 100) if v is not None else None for v in bri_values
|
||||
]}
|
||||
await websocket.send_text(_json.dumps(bri_msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send JPEG frame preview for picture sources
|
||||
if _frame_live:
|
||||
try:
|
||||
frame = _frame_live.get_latest_frame()
|
||||
if frame is not None and frame.image is not None:
|
||||
from PIL import Image as _PIL_Image
|
||||
img = frame.image
|
||||
# Ensure 3-channel RGB (some engines may produce BGRA)
|
||||
if img.ndim == 3 and img.shape[2] == 4:
|
||||
img = img[:, :, :3]
|
||||
h, w = img.shape[:2]
|
||||
# Send frame dimensions once so client can compute border overlay
|
||||
if not _frame_dims_sent:
|
||||
_frame_dims_sent = True
|
||||
await websocket.send_text(_json.dumps({
|
||||
"type": "frame_dims",
|
||||
"width": w,
|
||||
"height": h,
|
||||
}))
|
||||
# Downscale for bandwidth
|
||||
scale = min(960 / w, 540 / h, 1.0)
|
||||
if scale < 1.0:
|
||||
new_w = max(1, int(w * scale))
|
||||
new_h = max(1, int(h * scale))
|
||||
pil = _PIL_Image.fromarray(img).resize((new_w, new_h), _PIL_Image.LANCZOS)
|
||||
else:
|
||||
pil = _PIL_Image.fromarray(img)
|
||||
buf = _io.BytesIO()
|
||||
pil.save(buf, format='JPEG', quality=70)
|
||||
# Wire format: [0xFD] [jpeg_bytes]
|
||||
await websocket.send_bytes(b'\xfd' + buf.getvalue())
|
||||
except Exception as e:
|
||||
logger.warning(f"JPEG frame preview error: {e}")
|
||||
|
||||
await asyncio.sleep(_frame_interval)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"CSS test WebSocket error for {source_id}: {e}")
|
||||
finally:
|
||||
csm.release(source_id, consumer_id)
|
||||
logger.info(f"CSS test WebSocket disconnected for {source_id}")
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Device routes: CRUD, health state, brightness, power, calibration, WS stream."""
|
||||
|
||||
import secrets
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -32,6 +30,7 @@ from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -53,7 +52,19 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
rgbw=device.rgbw,
|
||||
zone_mode=device.zone_mode,
|
||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||
tags=getattr(device, 'tags', []),
|
||||
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,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
@@ -129,12 +140,23 @@ async def create_device(
|
||||
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":
|
||||
store.update_device(device_id=device.id, url=f"ws://{device.id}")
|
||||
device = store.get_device(device.id)
|
||||
device = store.update_device(device.id, url=f"ws://{device.id}")
|
||||
|
||||
# Register in processor manager for health monitoring
|
||||
manager.add_device(
|
||||
@@ -285,9 +307,10 @@ async def get_device(
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Get device details by ID."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
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)
|
||||
|
||||
|
||||
@@ -313,6 +336,18 @@ async def update_device(
|
||||
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
|
||||
@@ -394,9 +429,10 @@ async def get_device_state(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get device health/connection state."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
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:
|
||||
state = manager.get_device_health_dict(device_id)
|
||||
@@ -406,6 +442,27 @@ async def get_device_state(
|
||||
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 =====
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
||||
@@ -421,9 +478,10 @@ async def get_device_brightness(
|
||||
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)
|
||||
if not device:
|
||||
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):
|
||||
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -456,9 +514,10 @@ async def set_device_brightness(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Set brightness on the device."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
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):
|
||||
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -472,10 +531,7 @@ async def set_device_brightness(
|
||||
await provider.set_brightness(device.url, bri)
|
||||
except NotImplementedError:
|
||||
# Provider has no hardware brightness; use software brightness
|
||||
device.software_brightness = bri
|
||||
from datetime import datetime, timezone
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
store.save()
|
||||
store.update_device(device_id=device_id, software_brightness=bri)
|
||||
ds = manager.find_device_state(device_id)
|
||||
if ds:
|
||||
ds.software_brightness = bri
|
||||
@@ -501,9 +557,10 @@ async def get_device_power(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get current power state from the device."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
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):
|
||||
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -530,9 +587,10 @@ async def set_device_power(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Turn device on or off."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
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):
|
||||
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -572,23 +630,15 @@ async def device_ws_stream(
|
||||
Wire format: [brightness_byte][R G B R G B ...]
|
||||
Auth via ?token=<api_key>.
|
||||
"""
|
||||
from wled_controller.config import get_config
|
||||
|
||||
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:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
store = get_device_store()
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
except ValueError:
|
||||
await websocket.close(code=4004, reason="Device not found")
|
||||
return
|
||||
if device.device_type != "ws":
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
@@ -23,6 +22,8 @@ from wled_controller.api.dependencies import (
|
||||
get_template_store,
|
||||
)
|
||||
from wled_controller.api.schemas.output_targets import (
|
||||
BulkTargetRequest,
|
||||
BulkTargetResponse,
|
||||
ExtractedColorResponse,
|
||||
KCTestRectangleResponse,
|
||||
KCTestResponse,
|
||||
@@ -35,7 +36,6 @@ from wled_controller.api.schemas.output_targets import (
|
||||
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
|
||||
@@ -59,6 +59,7 @@ from wled_controller.storage.key_colors_output_target import (
|
||||
)
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -98,7 +99,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
target_type=target.target_type,
|
||||
device_id=target.device_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
brightness_value_source_id=target.brightness_value_source_id,
|
||||
brightness_value_source_id=target.brightness_value_source_id or "",
|
||||
fps=target.fps,
|
||||
keepalive_interval=target.keepalive_interval,
|
||||
state_check_interval=target.state_check_interval,
|
||||
@@ -106,7 +107,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
adaptive_fps=target.adaptive_fps,
|
||||
protocol=target.protocol,
|
||||
description=target.description,
|
||||
tags=getattr(target, 'tags', []),
|
||||
tags=target.tags,
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
@@ -119,7 +120,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
picture_source_id=target.picture_source_id,
|
||||
key_colors_settings=_kc_settings_to_schema(target.settings),
|
||||
description=target.description,
|
||||
tags=getattr(target, 'tags', []),
|
||||
tags=target.tags,
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
@@ -130,7 +131,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
name=target.name,
|
||||
target_type=target.target_type,
|
||||
description=target.description,
|
||||
tags=getattr(target, 'tags', []),
|
||||
tags=target.tags,
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
@@ -151,8 +152,9 @@ async def create_target(
|
||||
try:
|
||||
# Validate device exists if provided
|
||||
if data.device_id:
|
||||
device = device_store.get_device(data.device_id)
|
||||
if not device:
|
||||
try:
|
||||
device_store.get_device(data.device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
|
||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||
@@ -187,6 +189,9 @@ async def create_target(
|
||||
|
||||
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:
|
||||
@@ -250,8 +255,9 @@ async def update_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:
|
||||
try:
|
||||
device_store.get_device(data.device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
|
||||
# Build KC settings with partial-update support: only apply fields that were
|
||||
@@ -315,12 +321,18 @@ async def update_target(
|
||||
data.adaptive_fps is not None or
|
||||
data.key_colors_settings is not None),
|
||||
css_changed=data.color_strip_source_id is not None,
|
||||
device_changed=data.device_id is not None,
|
||||
brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed),
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Device change requires async stop → swap → start cycle
|
||||
if data.device_id is not None:
|
||||
try:
|
||||
await manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
fire_entity_event("output_target", "updated", target_id)
|
||||
return _target_to_response(target)
|
||||
|
||||
@@ -367,6 +379,64 @@ async def delete_target(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
|
||||
|
||||
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
|
||||
async def bulk_start_processing(
|
||||
body: BulkTargetRequest,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Start processing for multiple output targets. Returns lists of started IDs and per-ID errors."""
|
||||
started: list[str] = []
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for target_id in body.ids:
|
||||
try:
|
||||
target_store.get_target(target_id)
|
||||
await manager.start_processing(target_id)
|
||||
started.append(target_id)
|
||||
logger.info(f"Bulk start: started processing for target {target_id}")
|
||||
except ValueError as e:
|
||||
errors[target_id] = str(e)
|
||||
except RuntimeError as e:
|
||||
msg = str(e)
|
||||
for t in target_store.get_all_targets():
|
||||
if t.id in msg:
|
||||
msg = msg.replace(t.id, f"'{t.name}'")
|
||||
errors[target_id] = msg
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk start: failed to start target {target_id}: {e}")
|
||||
errors[target_id] = str(e)
|
||||
|
||||
return BulkTargetResponse(started=started, errors=errors)
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"])
|
||||
async def bulk_stop_processing(
|
||||
body: BulkTargetRequest,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
|
||||
stopped: list[str] = []
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for target_id in body.ids:
|
||||
try:
|
||||
await manager.stop_processing(target_id)
|
||||
stopped.append(target_id)
|
||||
logger.info(f"Bulk stop: stopped processing for target {target_id}")
|
||||
except ValueError as e:
|
||||
errors[target_id] = str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk stop: failed to stop target {target_id}: {e}")
|
||||
errors[target_id] = str(e)
|
||||
|
||||
return BulkTargetResponse(stopped=stopped, errors=errors)
|
||||
|
||||
|
||||
# ===== PROCESSING CONTROL ENDPOINTS =====
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
|
||||
@@ -389,7 +459,12 @@ async def start_processing(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
# Resolve target IDs to human-readable names in error messages
|
||||
msg = str(e)
|
||||
for t in target_store.get_all_targets():
|
||||
if t.id in msg:
|
||||
msg = msg.replace(t.id, f"'{t.name}'")
|
||||
raise HTTPException(status_code=409, detail=msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start processing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -527,6 +602,9 @@ async def test_kc_target(
|
||||
|
||||
try:
|
||||
chain = source_store.resolve_stream_chain(target.picture_source_id)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -664,6 +742,9 @@ async def test_kc_target(
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
@@ -679,6 +760,231 @@ async def test_kc_target(
|
||||
logger.error(f"Error cleaning up test stream: {e}")
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/test/ws")
|
||||
async def test_kc_target_ws(
|
||||
websocket: WebSocket,
|
||||
target_id: str,
|
||||
token: str = Query(""),
|
||||
fps: int = Query(3),
|
||||
preview_width: int = Query(480),
|
||||
):
|
||||
"""WebSocket for real-time KC target test preview. Auth via ?token=<api_key>.
|
||||
|
||||
Streams JSON frames: {"type": "frame", "image": "data:image/jpeg;base64,...",
|
||||
"rectangles": [...], "pattern_template_name": "...", "interpolation_mode": "..."}
|
||||
"""
|
||||
import json as _json
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Load stores
|
||||
target_store_inst: OutputTargetStore = get_output_target_store()
|
||||
source_store_inst: PictureSourceStore = get_picture_source_store()
|
||||
template_store_inst: TemplateStore = get_template_store()
|
||||
pattern_store_inst: PatternTemplateStore = get_pattern_template_store()
|
||||
processor_manager_inst: ProcessorManager = get_processor_manager()
|
||||
device_store_inst: DeviceStore = get_device_store()
|
||||
pp_template_store_inst = get_pp_template_store()
|
||||
|
||||
# Validate target
|
||||
try:
|
||||
target = target_store_inst.get_target(target_id)
|
||||
except ValueError as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
if not isinstance(target, KeyColorsOutputTarget):
|
||||
await websocket.close(code=4003, reason="Target is not a key_colors target")
|
||||
return
|
||||
|
||||
settings = target.settings
|
||||
|
||||
if not settings.pattern_template_id:
|
||||
await websocket.close(code=4003, reason="No pattern template configured")
|
||||
return
|
||||
|
||||
try:
|
||||
pattern_tmpl = pattern_store_inst.get_template(settings.pattern_template_id)
|
||||
except ValueError:
|
||||
await websocket.close(code=4003, reason=f"Pattern template not found: {settings.pattern_template_id}")
|
||||
return
|
||||
|
||||
rectangles = pattern_tmpl.rectangles
|
||||
if not rectangles:
|
||||
await websocket.close(code=4003, reason="Pattern template has no rectangles")
|
||||
return
|
||||
|
||||
if not target.picture_source_id:
|
||||
await websocket.close(code=4003, reason="No picture source configured")
|
||||
return
|
||||
|
||||
try:
|
||||
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
|
||||
except ValueError as e:
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
raw_stream = chain["raw_stream"]
|
||||
|
||||
# For screen capture sources, check display lock
|
||||
if isinstance(raw_stream, ScreenCapturePictureSource):
|
||||
display_index = raw_stream.display_index
|
||||
locked_device_id = processor_manager_inst.get_display_lock_info(display_index)
|
||||
if locked_device_id:
|
||||
try:
|
||||
device = device_store_inst.get_device(locked_device_id)
|
||||
device_name = device.name
|
||||
except Exception:
|
||||
device_name = locked_device_id
|
||||
await websocket.close(
|
||||
code=4003,
|
||||
reason=f"Display {display_index} is captured by '{device_name}'. Stop processing first.",
|
||||
)
|
||||
return
|
||||
|
||||
fps = max(1, min(30, fps))
|
||||
preview_width = max(120, min(1920, preview_width))
|
||||
frame_interval = 1.0 / fps
|
||||
|
||||
calc_fns = {
|
||||
"average": calculate_average_color,
|
||||
"median": calculate_median_color,
|
||||
"dominant": calculate_dominant_color,
|
||||
}
|
||||
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
|
||||
|
||||
await websocket.accept()
|
||||
logger.info(f"KC test WS connected for {target_id} (fps={fps})")
|
||||
|
||||
# Use the shared LiveStreamManager so we share the capture stream with
|
||||
# running LED targets instead of creating a competing DXGI duplicator.
|
||||
live_stream_mgr = processor_manager_inst._live_stream_manager
|
||||
live_stream = None
|
||||
|
||||
try:
|
||||
live_stream = await asyncio.to_thread(
|
||||
live_stream_mgr.acquire, target.picture_source_id
|
||||
)
|
||||
logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}")
|
||||
|
||||
prev_frame_ref = None
|
||||
|
||||
while True:
|
||||
loop_start = time.monotonic()
|
||||
|
||||
try:
|
||||
capture = await asyncio.to_thread(live_stream.get_latest_frame)
|
||||
|
||||
if capture is None or capture.image is None:
|
||||
await asyncio.sleep(frame_interval)
|
||||
continue
|
||||
|
||||
# Skip if same frame object (no new capture yet)
|
||||
if capture is prev_frame_ref:
|
||||
await asyncio.sleep(frame_interval * 0.5)
|
||||
continue
|
||||
prev_frame_ref = capture
|
||||
|
||||
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None
|
||||
if pil_image is None:
|
||||
await asyncio.sleep(frame_interval)
|
||||
continue
|
||||
|
||||
# Apply postprocessing (if the source chain has PP templates)
|
||||
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
|
||||
pp_template_ids = chain.get("postprocessing_template_ids", [])
|
||||
if pp_template_ids and pp_template_store_inst:
|
||||
img_array = np.array(pil_image)
|
||||
image_pool = ImagePool()
|
||||
for pp_id in pp_template_ids:
|
||||
try:
|
||||
pp_template = pp_template_store_inst.get_template(pp_id)
|
||||
except ValueError:
|
||||
continue
|
||||
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
|
||||
for fi in flat_filters:
|
||||
try:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(img_array, image_pool)
|
||||
if result is not None:
|
||||
img_array = result
|
||||
except ValueError:
|
||||
pass
|
||||
pil_image = Image.fromarray(img_array)
|
||||
|
||||
# Extract colors
|
||||
img_array = np.array(pil_image)
|
||||
h, w = img_array.shape[:2]
|
||||
|
||||
result_rects = []
|
||||
for rect in rectangles:
|
||||
px_x = max(0, int(rect.x * w))
|
||||
px_y = max(0, int(rect.y * h))
|
||||
px_w = max(1, int(rect.width * w))
|
||||
px_h = max(1, int(rect.height * h))
|
||||
px_x = min(px_x, w - 1)
|
||||
px_y = min(px_y, h - 1)
|
||||
px_w = min(px_w, w - px_x)
|
||||
px_h = min(px_h, h - px_y)
|
||||
|
||||
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
|
||||
r, g, b = calc_fn(sub_img)
|
||||
|
||||
result_rects.append({
|
||||
"name": rect.name,
|
||||
"x": rect.x,
|
||||
"y": rect.y,
|
||||
"width": rect.width,
|
||||
"height": rect.height,
|
||||
"color": {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"},
|
||||
})
|
||||
|
||||
# Encode frame as JPEG
|
||||
if preview_width and pil_image.width > preview_width:
|
||||
ratio = preview_width / pil_image.width
|
||||
thumb = pil_image.resize((preview_width, int(pil_image.height * ratio)), Image.LANCZOS)
|
||||
else:
|
||||
thumb = pil_image
|
||||
buf = io.BytesIO()
|
||||
thumb.save(buf, format="JPEG", quality=85)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
await websocket.send_text(_json.dumps({
|
||||
"type": "frame",
|
||||
"image": f"data:image/jpeg;base64,{b64}",
|
||||
"rectangles": result_rects,
|
||||
"pattern_template_name": pattern_tmpl.name,
|
||||
"interpolation_mode": settings.interpolation_mode,
|
||||
}))
|
||||
|
||||
except (WebSocketDisconnect, Exception) as inner_e:
|
||||
if isinstance(inner_e, WebSocketDisconnect):
|
||||
raise
|
||||
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
|
||||
|
||||
elapsed = time.monotonic() - loop_start
|
||||
sleep_time = frame_interval - elapsed
|
||||
if sleep_time > 0:
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"KC test WS disconnected for {target_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
|
||||
finally:
|
||||
if live_stream is not None:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
live_stream_mgr.release, target.picture_source_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"KC test WS closed for {target_id}")
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/ws")
|
||||
async def target_colors_ws(
|
||||
websocket: WebSocket,
|
||||
@@ -686,16 +992,8 @@ async def target_colors_ws(
|
||||
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:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -726,15 +1024,8 @@ async def led_preview_ws(
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. 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:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -766,15 +1057,8 @@ async def events_ws(
|
||||
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:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from wled_controller.storage.key_colors_output_target import KeyColorRectangle
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -37,7 +38,7 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=getattr(t, 'tags', []),
|
||||
tags=t.tags,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,6 +77,9 @@ async def create_pattern_template(
|
||||
)
|
||||
fire_entity_event("pattern_template", "created", template.id)
|
||||
return _pat_template_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:
|
||||
@@ -121,6 +125,9 @@ async def update_pattern_template(
|
||||
)
|
||||
fire_entity_event("pattern_template", "updated", template_id)
|
||||
return _pat_template_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:
|
||||
@@ -149,6 +156,9 @@ async def delete_pattern_template(
|
||||
fire_entity_event("pattern_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:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Picture source routes."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
@@ -38,8 +39,9 @@ from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
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.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -61,7 +63,15 @@ def _stream_to_response(s) -> PictureSourceResponse:
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
description=s.description,
|
||||
tags=getattr(s, 'tags', []),
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@@ -97,23 +107,26 @@ async def validate_image(
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(response.content))
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
|
||||
pil_image = Image.open(path)
|
||||
img_bytes = path
|
||||
|
||||
def _process_image(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
width, height = pil_image.size
|
||||
|
||||
# Create thumbnail preview (max 320px wide)
|
||||
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
|
||||
|
||||
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
|
||||
|
||||
return ImageValidateResponse(
|
||||
valid=True, width=width, height=height, preview=preview
|
||||
@@ -140,18 +153,22 @@ async def get_full_image(
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(response.content))
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
pil_image = Image.open(path)
|
||||
img_bytes = path
|
||||
|
||||
def _encode_full(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
pil_image.save(buf, format="JPEG", quality=90)
|
||||
buf.seek(0)
|
||||
return Response(content=buf.getvalue(), media_type="image/jpeg")
|
||||
return buf.getvalue()
|
||||
|
||||
jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes)
|
||||
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -199,11 +216,22 @@ async def create_picture_source(
|
||||
image_source=data.image_source,
|
||||
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)
|
||||
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:
|
||||
@@ -245,9 +273,20 @@ async def update_picture_source(
|
||||
image_source=data.image_source,
|
||||
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)
|
||||
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:
|
||||
@@ -277,6 +316,9 @@ async def delete_picture_source(
|
||||
fire_entity_event("picture_source", "deleted", stream_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:
|
||||
@@ -284,6 +326,52 @@ async def delete_picture_source(
|
||||
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"])
|
||||
async def test_picture_source(
|
||||
stream_id: str,
|
||||
@@ -305,6 +393,9 @@ async def test_picture_source(
|
||||
# Resolve stream chain
|
||||
try:
|
||||
chain = store.resolve_stream_chain(stream_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))
|
||||
|
||||
@@ -326,7 +417,7 @@ async def test_picture_source(
|
||||
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")
|
||||
pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB"))
|
||||
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
@@ -393,48 +484,50 @@ async def test_picture_source(
|
||||
else:
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
|
||||
# Create thumbnail
|
||||
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
|
||||
# Create thumbnail + encode (CPU-bound — run in thread)
|
||||
pp_template_ids = chain["postprocessing_template_ids"]
|
||||
flat_filters = None
|
||||
if pp_template_ids:
|
||||
try:
|
||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
|
||||
if flat_filters:
|
||||
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):
|
||||
arr = np.array(img)
|
||||
for fi in flat_filters:
|
||||
for fi in 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)
|
||||
thumb = apply_filters(thumb)
|
||||
pil_img = apply_filters(pil_img)
|
||||
|
||||
thumbnail = apply_filters(thumbnail)
|
||||
pil_image = apply_filters(pil_image)
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||
|
||||
# Encode thumbnail
|
||||
img_buffer = io.BytesIO()
|
||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||
img_buffer.seek(0)
|
||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
thumb.save(img_buffer, format='JPEG', quality=85)
|
||||
thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
# Encode full-resolution image
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
pil_img.save(full_buffer, format='JPEG', quality=90)
|
||||
full_b64 = base64.b64encode(full_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}"
|
||||
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
@@ -461,6 +554,9 @@ async def test_picture_source(
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
@@ -520,6 +616,86 @@ async def test_picture_source_ws(
|
||||
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
|
||||
|
||||
@@ -36,6 +36,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -51,7 +52,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=getattr(t, 'tags', []),
|
||||
tags=t.tags,
|
||||
)
|
||||
|
||||
|
||||
@@ -61,13 +62,9 @@ async def list_pp_templates(
|
||||
store: PostprocessingTemplateStore = Depends(get_pp_template_store),
|
||||
):
|
||||
"""List all postprocessing templates."""
|
||||
try:
|
||||
templates = store.get_all_templates()
|
||||
responses = [_pp_template_to_response(t) for t in templates]
|
||||
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)
|
||||
@@ -87,6 +84,9 @@ async def create_pp_template(
|
||||
)
|
||||
fire_entity_event("pp_template", "created", template.id)
|
||||
return _pp_template_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:
|
||||
@@ -127,6 +127,9 @@ async def update_pp_template(
|
||||
)
|
||||
fire_entity_event("pp_template", "updated", template_id)
|
||||
return _pp_template_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:
|
||||
@@ -156,6 +159,9 @@ async def delete_pp_template(
|
||||
fire_entity_event("pp_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:
|
||||
@@ -184,6 +190,9 @@ async def test_pp_template(
|
||||
# Resolve source stream chain to get the raw stream
|
||||
try:
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -327,6 +336,9 @@ async def test_pp_template(
|
||||
|
||||
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:
|
||||
|
||||
@@ -28,6 +28,7 @@ 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()
|
||||
@@ -46,7 +47,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
|
||||
"fps": t.fps,
|
||||
} for t in preset.targets],
|
||||
order=preset.order,
|
||||
tags=getattr(preset, 'tags', []),
|
||||
tags=preset.tags,
|
||||
created_at=preset.created_at,
|
||||
updated_at=preset.updated_at,
|
||||
)
|
||||
@@ -85,6 +86,9 @@ async def create_scene_preset(
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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__)
|
||||
|
||||
@@ -34,7 +35,7 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
|
||||
name=clock.name,
|
||||
speed=rt.speed if rt else clock.speed,
|
||||
description=clock.description,
|
||||
tags=getattr(clock, 'tags', []),
|
||||
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,
|
||||
@@ -73,6 +74,9 @@ async def create_sync_clock(
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -114,6 +118,9 @@ async def update_sync_clock(
|
||||
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))
|
||||
|
||||
@@ -137,6 +144,9 @@ async def delete_sync_clock(
|
||||
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))
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -12,7 +13,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -42,8 +43,14 @@ from wled_controller.api.schemas.system import (
|
||||
BackupListResponse,
|
||||
DisplayInfo,
|
||||
DisplayListResponse,
|
||||
ExternalUrlRequest,
|
||||
ExternalUrlResponse,
|
||||
GpuInfo,
|
||||
HealthResponse,
|
||||
LogLevelRequest,
|
||||
LogLevelResponse,
|
||||
MQTTSettingsRequest,
|
||||
MQTTSettingsResponse,
|
||||
PerformanceResponse,
|
||||
ProcessListResponse,
|
||||
RestoreResponse,
|
||||
@@ -59,19 +66,9 @@ logger = get_logger(__name__)
|
||||
# Prime psutil CPU counter (first call always returns 0.0)
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
# Try to initialize NVIDIA GPU monitoring
|
||||
_nvml_available = False
|
||||
try:
|
||||
import pynvml as _pynvml_mod # nvidia-ml-py (the pynvml wrapper is deprecated)
|
||||
|
||||
_pynvml_mod.nvmlInit()
|
||||
_nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0)
|
||||
_nvml_available = True
|
||||
_nvml = _pynvml_mod
|
||||
logger.info(f"NVIDIA GPU monitoring enabled: {_nvml.nvmlDeviceGetName(_nvml_handle)}")
|
||||
except Exception:
|
||||
_nvml = None
|
||||
logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)")
|
||||
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
||||
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
|
||||
def _get_cpu_name() -> str | None:
|
||||
@@ -100,8 +97,8 @@ def _get_cpu_name() -> str | None:
|
||||
.decode()
|
||||
.strip()
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("CPU name detection failed: %s", e)
|
||||
return platform.processor() or None
|
||||
|
||||
|
||||
@@ -156,20 +153,12 @@ async def list_all_tags(_: AuthRequired):
|
||||
store = getter()
|
||||
except RuntimeError:
|
||||
continue
|
||||
# Each store has a different "get all" method name
|
||||
items = None
|
||||
for method_name in (
|
||||
"get_all_devices", "get_all_targets", "get_all_sources",
|
||||
"get_all_streams", "get_all_clocks", "get_all_automations",
|
||||
"get_all_presets", "get_all_templates",
|
||||
):
|
||||
fn = getattr(store, method_name, None)
|
||||
if fn is not None:
|
||||
items = fn()
|
||||
break
|
||||
# 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(getattr(item, 'tags', []))
|
||||
all_tags.update(item.tags)
|
||||
return {"tags": sorted(all_tags)}
|
||||
|
||||
|
||||
@@ -191,9 +180,9 @@ async def get_displays(
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
|
||||
engine_cls = EngineRegistry.get_engine(engine_type)
|
||||
display_dataclasses = engine_cls.get_available_displays()
|
||||
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
|
||||
else:
|
||||
display_dataclasses = get_available_displays()
|
||||
display_dataclasses = await asyncio.to_thread(get_available_displays)
|
||||
|
||||
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
|
||||
displays = [
|
||||
@@ -217,6 +206,10 @@ async def get_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:
|
||||
@@ -277,8 +270,8 @@ def get_system_performance(_: AuthRequired):
|
||||
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
|
||||
temperature_c=float(temp),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("NVML query failed: %s", e)
|
||||
|
||||
return PerformanceResponse(
|
||||
cpu_name=_cpu_name,
|
||||
@@ -321,6 +314,7 @@ STORE_MAP = {
|
||||
"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",
|
||||
}
|
||||
@@ -349,6 +343,125 @@ def _schedule_restart() -> None:
|
||||
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."""
|
||||
@@ -384,6 +497,13 @@ def backup_config(_: AuthRequired):
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
@@ -489,6 +609,16 @@ async def update_auto_backup_settings(
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
@@ -540,6 +670,217 @@ async def delete_saved_backup(
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -569,11 +910,13 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Connecting ADB device: {address}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[adb, "connect", address],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb, "connect", address,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
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")
|
||||
@@ -582,7 +925,7 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
|
||||
status_code=500,
|
||||
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="ADB connect timed out")
|
||||
|
||||
|
||||
@@ -596,12 +939,45 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Disconnecting ADB device: {address}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[adb, "disconnect", address],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb, "disconnect", address,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
return {"status": "disconnected", "message": result.stdout.strip()}
|
||||
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 subprocess.TimeoutExpired:
|
||||
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)
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_cspt_store,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
get_template_store,
|
||||
@@ -40,6 +41,7 @@ from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -63,7 +65,7 @@ async def list_templates(
|
||||
name=t.name,
|
||||
engine_type=t.engine_type,
|
||||
engine_config=t.engine_config,
|
||||
tags=getattr(t, 'tags', []),
|
||||
tags=t.tags,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
@@ -103,12 +105,16 @@ async def create_template(
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
tags=getattr(template, 'tags', []),
|
||||
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:
|
||||
@@ -133,7 +139,7 @@ async def get_template(
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
tags=getattr(template, 'tags', []),
|
||||
tags=template.tags,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
@@ -164,12 +170,16 @@ async def update_template(
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
tags=getattr(template, 'tags', []),
|
||||
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:
|
||||
@@ -209,6 +219,9 @@ async def delete_template(
|
||||
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTP exceptions as-is
|
||||
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:
|
||||
@@ -358,6 +371,10 @@ def test_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 RuntimeError as e:
|
||||
@@ -479,3 +496,48 @@ async def list_filter_types(
|
||||
options_schema=opt_schemas,
|
||||
))
|
||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||
|
||||
|
||||
@router.get("/api/v1/strip-filters", response_model=FilterTypeListResponse, tags=["Filters"])
|
||||
async def list_strip_filter_types(
|
||||
_auth: AuthRequired,
|
||||
cspt_store=Depends(get_cspt_store),
|
||||
):
|
||||
"""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))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Value source routes: CRUD for value sources."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
@@ -13,7 +12,6 @@ from wled_controller.api.dependencies import (
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.api.schemas.value_sources import (
|
||||
ValueSourceCreate,
|
||||
ValueSourceListResponse,
|
||||
@@ -25,6 +23,7 @@ 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__)
|
||||
|
||||
@@ -51,6 +50,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
|
||||
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,
|
||||
@@ -99,10 +100,15 @@ async def create_value_source(
|
||||
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))
|
||||
|
||||
@@ -148,12 +154,17 @@ async def update_value_source(
|
||||
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))
|
||||
|
||||
@@ -178,6 +189,9 @@ async def delete_value_source(
|
||||
|
||||
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))
|
||||
|
||||
@@ -196,16 +210,8 @@ async def test_value_source_ws(
|
||||
Acquires a ValueStream for the given source, polls get_value() at ~20 Hz,
|
||||
and streams {value: float} JSON to the client.
|
||||
"""
|
||||
# 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:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from wled_controller.api.schemas.devices import Calibration
|
||||
|
||||
@@ -31,9 +31,11 @@ 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")
|
||||
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):
|
||||
@@ -49,12 +51,9 @@ 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"] = Field(default="picture", description="Source type")
|
||||
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)")
|
||||
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0)
|
||||
gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0)
|
||||
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)")
|
||||
@@ -82,7 +81,6 @@ class ColorStripSourceCreate(BaseModel):
|
||||
# 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)
|
||||
frame_interpolation: bool = Field(default=False, description="Blend between consecutive captured frames for smoother output")
|
||||
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)")
|
||||
@@ -101,6 +99,9 @@ class ColorStripSourceCreate(BaseModel):
|
||||
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")
|
||||
@@ -112,9 +113,6 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
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")
|
||||
brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0)
|
||||
gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0)
|
||||
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")
|
||||
@@ -142,7 +140,6 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
# 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)
|
||||
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
|
||||
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)")
|
||||
@@ -161,6 +158,9 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
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
|
||||
@@ -174,9 +174,6 @@ class ColorStripSourceResponse(BaseModel):
|
||||
source_type: str = Field(description="Source type")
|
||||
# picture-type fields
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
brightness: Optional[float] = Field(None, description="Brightness multiplier")
|
||||
saturation: Optional[float] = Field(None, description="Saturation")
|
||||
gamma: Optional[float] = Field(None, description="Gamma correction")
|
||||
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")
|
||||
@@ -204,7 +201,6 @@ class ColorStripSourceResponse(BaseModel):
|
||||
# shared
|
||||
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
|
||||
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)")
|
||||
@@ -223,6 +219,9 @@ class ColorStripSourceResponse(BaseModel):
|
||||
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")
|
||||
@@ -238,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
|
||||
count: int = Field(description="Number of sources")
|
||||
|
||||
|
||||
class ColorPushRequest(BaseModel):
|
||||
"""Request to push raw LED colors to an api_input source."""
|
||||
class SegmentPayload(BaseModel):
|
||||
"""A single segment for segment-based LED color updates."""
|
||||
|
||||
colors: List[List[int]] = Field(description="LED color array [[R,G,B], ...] (0-255 each)")
|
||||
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):
|
||||
|
||||
@@ -19,6 +19,25 @@ class DeviceCreate(BaseModel):
|
||||
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):
|
||||
@@ -34,6 +53,19 @@ class DeviceUpdate(BaseModel):
|
||||
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 CalibrationLineSchema(BaseModel):
|
||||
@@ -128,6 +160,19 @@ class DeviceResponse(BaseModel):
|
||||
zone_mode: str = Field(default="combined", description="OpenRGB zone mode: combined or separate")
|
||||
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
||||
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")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -177,6 +177,20 @@ class TargetMetricsResponse(BaseModel):
|
||||
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):
|
||||
"""A rectangle with its extracted color from a KC test."""
|
||||
|
||||
|
||||
@@ -10,15 +10,23 @@ class PictureSourceCreate(BaseModel):
|
||||
"""Request to create a picture source."""
|
||||
|
||||
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)
|
||||
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=1, 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)")
|
||||
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)")
|
||||
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):
|
||||
@@ -27,12 +35,20 @@ class PictureSourceUpdate(BaseModel):
|
||||
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)
|
||||
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=1, 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)")
|
||||
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)")
|
||||
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):
|
||||
@@ -40,7 +56,7 @@ class PictureSourceResponse(BaseModel):
|
||||
|
||||
id: str = Field(description="Stream ID")
|
||||
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")
|
||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS")
|
||||
@@ -51,6 +67,14 @@ class PictureSourceResponse(BaseModel):
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
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):
|
||||
|
||||
@@ -115,3 +115,57 @@ class BackupListResponse(BaseModel):
|
||||
|
||||
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)")
|
||||
|
||||
@@ -10,12 +10,12 @@ 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"] = Field(description="Source type")
|
||||
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="Cycles per minute (1.0-120.0)", ge=1.0, le=120.0)
|
||||
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
|
||||
@@ -28,6 +28,9 @@ class ValueSourceCreate(BaseModel):
|
||||
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")
|
||||
|
||||
@@ -40,7 +43,7 @@ class ValueSourceUpdate(BaseModel):
|
||||
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="Cycles per minute (1.0-120.0)", ge=1.0, le=120.0)
|
||||
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
|
||||
@@ -53,6 +56,9 @@ class ValueSourceUpdate(BaseModel):
|
||||
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
|
||||
|
||||
@@ -76,6 +82,8 @@ class ValueSourceResponse(BaseModel):
|
||||
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")
|
||||
|
||||
@@ -39,6 +39,7 @@ class StorageConfig(BaseSettings):
|
||||
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"
|
||||
|
||||
|
||||
@@ -92,7 +93,7 @@ class Config(BaseSettings):
|
||||
if not config_path.exists():
|
||||
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)
|
||||
|
||||
return cls(**config_data)
|
||||
|
||||
@@ -398,8 +398,8 @@ class AutomationEngine:
|
||||
"automation_id": automation_id,
|
||||
"action": action,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("Automation action failed: %s", e, exc_info=True)
|
||||
|
||||
# ===== Public query methods (used by API) =====
|
||||
|
||||
|
||||
@@ -211,6 +211,14 @@ class AutoBackupEngine:
|
||||
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():
|
||||
|
||||
@@ -10,8 +10,9 @@ Prerequisites (optional dependency):
|
||||
|
||||
import platform
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -26,6 +27,13 @@ from wled_controller.utils import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
|
||||
|
||||
# Process-wide registry of cv2 camera indices currently held open.
|
||||
# Prevents _enumerate_cameras from probing an in-use camera (which can
|
||||
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
|
||||
# from opening the same physical camera concurrently.
|
||||
_active_cv2_indices: Set[int] = set()
|
||||
_camera_lock = threading.Lock()
|
||||
_CV2_BACKENDS = {
|
||||
"auto": None,
|
||||
"dshow": 700, # cv2.CAP_DSHOW
|
||||
@@ -103,7 +111,29 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
|
||||
cameras: List[Dict[str, Any]] = []
|
||||
sequential_idx = 0
|
||||
|
||||
with _camera_lock:
|
||||
active = set(_active_cv2_indices)
|
||||
|
||||
for i in range(max_probe):
|
||||
if i in active:
|
||||
# Camera already held open — use cached metadata if available,
|
||||
# otherwise add a placeholder so display_index mapping stays stable.
|
||||
if _camera_cache is not None:
|
||||
prev = [c for c in _camera_cache if c["cv2_index"] == i]
|
||||
if prev:
|
||||
cameras.append(prev[0])
|
||||
sequential_idx += 1
|
||||
continue
|
||||
cameras.append({
|
||||
"cv2_index": i,
|
||||
"name": friendly_names.get(sequential_idx, f"Camera {sequential_idx}"),
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"fps": 30.0,
|
||||
})
|
||||
sequential_idx += 1
|
||||
continue
|
||||
|
||||
if backend_id is not None:
|
||||
cap = cv2.VideoCapture(i, backend_id)
|
||||
else:
|
||||
@@ -149,6 +179,7 @@ class CameraCaptureStream(CaptureStream):
|
||||
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||
super().__init__(display_index, config)
|
||||
self._cap = None
|
||||
self._cv2_index: Optional[int] = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
@@ -173,6 +204,16 @@ class CameraCaptureStream(CaptureStream):
|
||||
camera = cameras[self.display_index]
|
||||
cv2_index = camera["cv2_index"]
|
||||
|
||||
# Prevent concurrent opens of the same physical camera (crashes DSHOW)
|
||||
with _camera_lock:
|
||||
if cv2_index in _active_cv2_indices:
|
||||
raise RuntimeError(
|
||||
f"Camera {self.display_index} (cv2 index {cv2_index}) "
|
||||
f"is already in use by another stream"
|
||||
)
|
||||
_active_cv2_indices.add(cv2_index)
|
||||
|
||||
try:
|
||||
# Open the camera
|
||||
backend_id = _cv2_backend_id(backend_name)
|
||||
if backend_id is not None:
|
||||
@@ -185,6 +226,12 @@ class CameraCaptureStream(CaptureStream):
|
||||
f"Failed to open camera {self.display_index} "
|
||||
f"(cv2 index {cv2_index})"
|
||||
)
|
||||
except Exception:
|
||||
with _camera_lock:
|
||||
_active_cv2_indices.discard(cv2_index)
|
||||
raise
|
||||
|
||||
self._cv2_index = cv2_index
|
||||
|
||||
# Apply optional resolution override
|
||||
res_w = self.config.get("resolution_width", 0)
|
||||
@@ -198,6 +245,9 @@ class CameraCaptureStream(CaptureStream):
|
||||
if not ret or frame is None:
|
||||
self._cap.release()
|
||||
self._cap = None
|
||||
with _camera_lock:
|
||||
_active_cv2_indices.discard(cv2_index)
|
||||
self._cv2_index = None
|
||||
raise RuntimeError(
|
||||
f"Camera {self.display_index} opened but test read failed"
|
||||
)
|
||||
@@ -234,6 +284,10 @@ class CameraCaptureStream(CaptureStream):
|
||||
if self._cap is not None:
|
||||
self._cap.release()
|
||||
self._cap = None
|
||||
if self._cv2_index is not None:
|
||||
with _camera_lock:
|
||||
_active_cv2_indices.discard(self._cv2_index)
|
||||
self._cv2_index = None
|
||||
self._initialized = False
|
||||
logger.info(f"Camera capture stream cleaned up (display={self.display_index})")
|
||||
|
||||
|
||||
@@ -168,9 +168,7 @@ class AdalightClient(LEDClient):
|
||||
else:
|
||||
arr = np.array(pixels, dtype=np.uint16)
|
||||
|
||||
if brightness < 255:
|
||||
arr = arr * brightness // 255
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
np.clip(arr, 0, 255, out=arr)
|
||||
rgb_bytes = arr.astype(np.uint8).tobytes()
|
||||
return self._header + rgb_bytes
|
||||
|
||||
@@ -13,10 +13,8 @@ class AdalightDeviceProvider(SerialDeviceProvider):
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
from wled_controller.core.devices.adalight_client import AdalightClient
|
||||
|
||||
led_count = kwargs.pop("led_count", 0)
|
||||
baud_rate = kwargs.pop("baud_rate", None)
|
||||
kwargs.pop("use_ddp", None) # Not applicable for serial
|
||||
kwargs.pop("send_latency_ms", None)
|
||||
kwargs.pop("rgbw", None)
|
||||
return AdalightClient(url, led_count=led_count, baud_rate=baud_rate)
|
||||
return AdalightClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
baud_rate=kwargs.get("baud_rate"),
|
||||
)
|
||||
|
||||
@@ -40,9 +40,7 @@ class AmbiLEDClient(AdalightClient):
|
||||
else:
|
||||
arr = np.array(pixels, dtype=np.uint16)
|
||||
|
||||
if brightness < 255:
|
||||
arr = arr * brightness // 255
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
# Clamp to 0–250: values >250 are command bytes in AmbiLED protocol
|
||||
np.clip(arr, 0, 250, out=arr)
|
||||
rgb_bytes = arr.astype(np.uint8).tobytes()
|
||||
|
||||
@@ -13,10 +13,8 @@ class AmbiLEDDeviceProvider(SerialDeviceProvider):
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
from wled_controller.core.devices.ambiled_client import AmbiLEDClient
|
||||
|
||||
led_count = kwargs.pop("led_count", 0)
|
||||
baud_rate = kwargs.pop("baud_rate", None)
|
||||
kwargs.pop("use_ddp", None)
|
||||
kwargs.pop("send_latency_ms", None)
|
||||
kwargs.pop("rgbw", None)
|
||||
return AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate)
|
||||
return AmbiLEDClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
baud_rate=kwargs.get("baud_rate"),
|
||||
)
|
||||
|
||||
226
server/src/wled_controller/core/devices/chroma_client.py
Normal file
226
server/src/wled_controller/core/devices/chroma_client.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Razer Chroma SDK LED client — controls Razer RGB peripherals via REST API."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Chroma SDK REST API base
|
||||
CHROMA_SDK_URL = "http://localhost:54235/razer/rest"
|
||||
|
||||
# Device type → (endpoint suffix, max LEDs)
|
||||
CHROMA_DEVICES = {
|
||||
"keyboard": ("keyboard", 132), # 22 columns × 6 rows
|
||||
"mouse": ("mouse", 30),
|
||||
"mousepad": ("mousepad", 15),
|
||||
"headset": ("headset", 5), # left + right + 3 zones
|
||||
"chromalink": ("chromalink", 5),
|
||||
"keypad": ("keypad", 20), # 5×4 grid
|
||||
}
|
||||
|
||||
|
||||
class ChromaClient(LEDClient):
|
||||
"""LED client that controls Razer peripherals via the Chroma SDK REST API.
|
||||
|
||||
The Chroma SDK exposes a local REST API. Workflow:
|
||||
1. POST /razer/rest to init a session → get session URL
|
||||
2. PUT effects to {session_url}/{device_type}
|
||||
3. DELETE session on close
|
||||
|
||||
Uses CUSTOM effect type for per-LED RGB control.
|
||||
"""
|
||||
|
||||
HEARTBEAT_INTERVAL = 10 # seconds — SDK kills session after 15s idle
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "",
|
||||
led_count: int = 0,
|
||||
chroma_device_type: str = "chromalink",
|
||||
**kwargs,
|
||||
):
|
||||
self._base_url = url or CHROMA_SDK_URL
|
||||
self._led_count = led_count
|
||||
self._chroma_device_type = chroma_device_type
|
||||
self._session_url: Optional[str] = None
|
||||
self._connected = False
|
||||
self._heartbeat_task: Optional[asyncio.Task] = None
|
||||
self._http_client = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
import httpx
|
||||
|
||||
self._http_client = httpx.AsyncClient(timeout=5.0)
|
||||
|
||||
# Initialize Chroma SDK session
|
||||
init_payload = {
|
||||
"title": "WLED Screen Controller",
|
||||
"description": "LED pixel streaming from WLED Screen Controller",
|
||||
"author": {"name": "WLED-SC", "contact": "https://github.com"},
|
||||
"device_supported": [self._chroma_device_type],
|
||||
"category": "application",
|
||||
}
|
||||
|
||||
try:
|
||||
resp = await self._http_client.post(self._base_url, json=init_payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._session_url = data.get("uri") or data.get("sessionid")
|
||||
if not self._session_url:
|
||||
raise RuntimeError(f"Chroma SDK init returned no session URL: {data}")
|
||||
except Exception as e:
|
||||
logger.error("Chroma SDK init failed: %s", e)
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
raise
|
||||
|
||||
self._connected = True
|
||||
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
logger.info(
|
||||
"Chroma client connected: device=%s session=%s leds=%d",
|
||||
self._chroma_device_type, self._session_url, self._led_count,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
"""Keep the Chroma SDK session alive."""
|
||||
while self._connected and self._session_url:
|
||||
try:
|
||||
await asyncio.sleep(self.HEARTBEAT_INTERVAL)
|
||||
if self._http_client and self._session_url:
|
||||
await self._http_client.put(
|
||||
f"{self._session_url}/heartbeat",
|
||||
json={},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("Chroma heartbeat error: %s", e)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._connected = False
|
||||
if self._heartbeat_task:
|
||||
self._heartbeat_task.cancel()
|
||||
try:
|
||||
await self._heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._heartbeat_task = None
|
||||
|
||||
if self._http_client and self._session_url:
|
||||
try:
|
||||
await self._http_client.delete(self._session_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
self._session_url = None
|
||||
logger.info("Chroma client closed")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._session_url is not None
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
if not self.is_connected or not self._http_client:
|
||||
return False
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_arr = pixels
|
||||
else:
|
||||
pixel_arr = np.array(pixels, dtype=np.uint8)
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
device_info = CHROMA_DEVICES.get(self._chroma_device_type)
|
||||
if not device_info:
|
||||
return False
|
||||
|
||||
endpoint, max_leds = device_info
|
||||
n = min(len(pixel_arr), max_leds, self._led_count or max_leds)
|
||||
|
||||
# Chroma uses BGR packed as 0x00BBGGRR integers
|
||||
colors = []
|
||||
for i in range(n):
|
||||
r, g, b = int(pixel_arr[i][0]), int(pixel_arr[i][1]), int(pixel_arr[i][2])
|
||||
colors.append(r | (g << 8) | (b << 16))
|
||||
|
||||
# Pad to max_leds if needed
|
||||
while len(colors) < max_leds:
|
||||
colors.append(0)
|
||||
|
||||
# Build effect payload based on device type
|
||||
if self._chroma_device_type == "keyboard":
|
||||
# Keyboard uses 2D array: 6 rows × 22 columns
|
||||
grid = []
|
||||
idx = 0
|
||||
for row in range(6):
|
||||
row_colors = []
|
||||
for col in range(22):
|
||||
row_colors.append(colors[idx] if idx < len(colors) else 0)
|
||||
idx += 1
|
||||
grid.append(row_colors)
|
||||
effect = {"effect": "CHROMA_CUSTOM", "param": grid}
|
||||
elif self._chroma_device_type == "keypad":
|
||||
# Keypad uses 2D array: 4 rows × 5 columns
|
||||
grid = []
|
||||
idx = 0
|
||||
for row in range(4):
|
||||
row_colors = []
|
||||
for col in range(5):
|
||||
row_colors.append(colors[idx] if idx < len(colors) else 0)
|
||||
idx += 1
|
||||
grid.append(row_colors)
|
||||
effect = {"effect": "CHROMA_CUSTOM", "param": grid}
|
||||
else:
|
||||
# 1D devices: mouse, mousepad, headset, chromalink
|
||||
effect = {"effect": "CHROMA_CUSTOM", "param": colors[:max_leds]}
|
||||
|
||||
try:
|
||||
url = f"{self._session_url}/{endpoint}"
|
||||
resp = await self._http_client.put(url, json=effect)
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error("Chroma send failed: %s", e)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check if Chroma SDK is running."""
|
||||
base = url or CHROMA_SDK_URL
|
||||
try:
|
||||
resp = await http_client.get(base, timeout=3.0)
|
||||
if resp.status_code < 500:
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
latency_ms=0.0,
|
||||
device_name="Razer Chroma SDK",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
error="Chroma SDK not responding (is Razer Synapse running?)",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
104
server/src/wled_controller/core/devices/chroma_provider.py
Normal file
104
server/src/wled_controller/core/devices/chroma_provider.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Razer Chroma SDK device provider — control Razer RGB peripherals."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.core.devices.chroma_client import (
|
||||
ChromaClient,
|
||||
CHROMA_DEVICES,
|
||||
CHROMA_SDK_URL,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ChromaDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for Razer Chroma SDK RGB peripherals.
|
||||
|
||||
URL format: chroma://device_type (e.g. chroma://keyboard, chroma://chromalink)
|
||||
Requires Razer Synapse with Chroma SDK enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "chroma"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {"manual_led_count", "health_check"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
chroma_device_type = _parse_chroma_url(url)
|
||||
return ChromaClient(
|
||||
url=CHROMA_SDK_URL,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
chroma_device_type=chroma_device_type,
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await ChromaClient.check_health(CHROMA_SDK_URL, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate Chroma SDK is reachable."""
|
||||
chroma_type = _parse_chroma_url(url)
|
||||
if chroma_type not in CHROMA_DEVICES:
|
||||
raise ValueError(
|
||||
f"Unknown Chroma device type '{chroma_type}'. "
|
||||
f"Supported: {', '.join(CHROMA_DEVICES.keys())}"
|
||||
)
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
resp = await client.get(CHROMA_SDK_URL)
|
||||
if resp.status_code >= 500:
|
||||
raise ValueError("Chroma SDK returned server error")
|
||||
except httpx.ConnectError:
|
||||
raise ValueError(
|
||||
"Cannot connect to Chroma SDK. "
|
||||
"Ensure Razer Synapse is running with Chroma SDK enabled."
|
||||
)
|
||||
|
||||
_, max_leds = CHROMA_DEVICES[chroma_type]
|
||||
return {"led_count": max_leds}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""Discover available Chroma device types if SDK is running."""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(CHROMA_SDK_URL)
|
||||
if resp.status_code >= 500:
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# SDK is running — offer all device types
|
||||
results = []
|
||||
for dev_type, (_, max_leds) in CHROMA_DEVICES.items():
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"Razer {dev_type.title()}",
|
||||
url=f"chroma://{dev_type}",
|
||||
device_type="chroma",
|
||||
ip="127.0.0.1",
|
||||
mac="",
|
||||
led_count=max_leds,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def _parse_chroma_url(url: str) -> str:
|
||||
"""Parse 'chroma://device_type' → device_type string."""
|
||||
return url.replace("chroma://", "").strip().lower()
|
||||
245
server/src/wled_controller/core/devices/dmx_client.py
Normal file
245
server/src/wled_controller/core/devices/dmx_client.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Art-Net / sACN (E1.31) DMX client for stage lighting and LED controllers."""
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
import uuid
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import LEDClient, DeviceHealth
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Art-Net constants
|
||||
ARTNET_PORT = 6454
|
||||
ARTNET_HEADER = b"Art-Net\x00"
|
||||
ARTNET_OPCODE_DMX = 0x5000
|
||||
ARTNET_PROTOCOL_VERSION = 14
|
||||
|
||||
# sACN / E1.31 constants
|
||||
SACN_PORT = 5568
|
||||
ACN_PACKET_IDENTIFIER = b"\x00\x10\x00\x00\x41\x53\x43\x2d\x45\x31\x2e\x31\x37\x00\x00\x00"
|
||||
SACN_VECTOR_ROOT = 0x00000004
|
||||
SACN_VECTOR_FRAMING = 0x00000002
|
||||
SACN_VECTOR_DMP = 0x02
|
||||
|
||||
# DMX512 limits
|
||||
DMX_CHANNELS_PER_UNIVERSE = 512
|
||||
DMX_PIXELS_PER_UNIVERSE = 170 # floor(512 / 3)
|
||||
|
||||
|
||||
class DMXClient(LEDClient):
|
||||
"""UDP client for Art-Net and sACN (E1.31) DMX protocols.
|
||||
|
||||
Supports sending RGB pixel data across multiple DMX universes.
|
||||
Both protocols are UDP fire-and-forget, similar to DDP.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: Optional[int] = None,
|
||||
led_count: int = 1,
|
||||
protocol: str = "artnet",
|
||||
start_universe: int = 0,
|
||||
start_channel: int = 1,
|
||||
**kwargs,
|
||||
):
|
||||
self.host = host
|
||||
self.protocol = protocol.lower()
|
||||
self.port = port or (ARTNET_PORT if self.protocol == "artnet" else SACN_PORT)
|
||||
self.led_count = led_count
|
||||
self.start_universe = start_universe
|
||||
self.start_channel = max(1, min(512, start_channel)) # clamp 1-512
|
||||
self._transport = None
|
||||
self._protocol_obj = None
|
||||
self._sequence = 0
|
||||
# sACN requires a stable 16-byte CID (Component Identifier)
|
||||
self._sacn_cid = uuid.uuid4().bytes
|
||||
self._sacn_source_name = b"WLED Controller\x00" + b"\x00" * 48 # 64 bytes padded
|
||||
|
||||
# Pre-compute universe mapping
|
||||
self._universe_map = self._compute_universe_map()
|
||||
|
||||
def _compute_universe_map(self) -> List[Tuple[int, int, int]]:
|
||||
"""Pre-compute which channels go to which universe.
|
||||
|
||||
Returns list of (universe, channel_offset, num_channels) tuples.
|
||||
channel_offset is 0-based index into the flat RGB byte array.
|
||||
"""
|
||||
total_channels = self.led_count * 3
|
||||
start_ch_0 = self.start_channel - 1 # convert to 0-based
|
||||
mapping = []
|
||||
byte_offset = 0
|
||||
|
||||
universe = self.start_universe
|
||||
ch_in_universe = start_ch_0
|
||||
|
||||
while byte_offset < total_channels:
|
||||
available = DMX_CHANNELS_PER_UNIVERSE - ch_in_universe
|
||||
needed = total_channels - byte_offset
|
||||
count = min(available, needed)
|
||||
mapping.append((universe, ch_in_universe, count, byte_offset))
|
||||
byte_offset += count
|
||||
universe += 1
|
||||
ch_in_universe = 0 # subsequent universes start at channel 0
|
||||
|
||||
return mapping
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._transport is not None
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
return True
|
||||
|
||||
async def connect(self) -> bool:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._transport, self._protocol_obj = await loop.create_datagram_endpoint(
|
||||
asyncio.DatagramProtocol,
|
||||
remote_addr=(self.host, self.port),
|
||||
)
|
||||
num_universes = len(self._universe_map)
|
||||
logger.info(
|
||||
f"DMX/{self.protocol} client connected to {self.host}:{self.port} "
|
||||
f"({self.led_count} LEDs across {num_universes} universe(s), "
|
||||
f"starting at universe {self.start_universe} channel {self.start_channel})"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect DMX client: {e}")
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
self._protocol_obj = None
|
||||
logger.debug(f"Closed DMX/{self.protocol} connection to {self.host}:{self.port}")
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
if not self._transport:
|
||||
raise RuntimeError("DMX client not connected")
|
||||
self.send_pixels_fast(pixels, brightness)
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
if not self._transport:
|
||||
raise RuntimeError("DMX client not connected")
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_bytes = pixels.tobytes()
|
||||
else:
|
||||
pixel_bytes = np.array(pixels, dtype=np.uint8).tobytes()
|
||||
|
||||
self._sequence = (self._sequence + 1) % 256
|
||||
|
||||
for universe, ch_offset, num_channels, byte_offset in self._universe_map:
|
||||
# Build a full 512-channel DMX frame (zero-padded)
|
||||
dmx_data = bytearray(DMX_CHANNELS_PER_UNIVERSE)
|
||||
chunk = pixel_bytes[byte_offset:byte_offset + num_channels]
|
||||
dmx_data[ch_offset:ch_offset + len(chunk)] = chunk
|
||||
|
||||
if self.protocol == "sacn":
|
||||
packet = self._build_sacn_packet(universe, bytes(dmx_data), self._sequence)
|
||||
else:
|
||||
packet = self._build_artnet_packet(universe, bytes(dmx_data), self._sequence)
|
||||
|
||||
self._transport.sendto(packet)
|
||||
|
||||
def _build_artnet_packet(self, universe: int, dmx_data: bytes, sequence: int) -> bytes:
|
||||
"""Build an Art-Net DMX (OpDmx / 0x5000) packet.
|
||||
|
||||
Art-Net packet structure:
|
||||
- 8 bytes: "Art-Net\\0" header
|
||||
- 2 bytes: OpCode (0x5000 little-endian)
|
||||
- 2 bytes: Protocol version (14, big-endian)
|
||||
- 1 byte: Sequence number
|
||||
- 1 byte: Physical port (0)
|
||||
- 2 bytes: Universe (little-endian, 15-bit: subnet+universe)
|
||||
- 2 bytes: Data length (big-endian, must be even, 2-512)
|
||||
- N bytes: DMX channel data
|
||||
"""
|
||||
data_len = len(dmx_data)
|
||||
# Art-Net requires even data length
|
||||
if data_len % 2 != 0:
|
||||
dmx_data = dmx_data + b"\x00"
|
||||
data_len += 1
|
||||
|
||||
packet = bytearray()
|
||||
packet.extend(ARTNET_HEADER) # 8 bytes: "Art-Net\0"
|
||||
packet.extend(struct.pack("<H", ARTNET_OPCODE_DMX)) # 2 bytes: OpCode LE
|
||||
packet.extend(struct.pack(">H", ARTNET_PROTOCOL_VERSION)) # 2 bytes: version BE
|
||||
packet.append(sequence & 0xFF) # 1 byte: sequence
|
||||
packet.append(0) # 1 byte: physical
|
||||
packet.extend(struct.pack("<H", universe & 0x7FFF)) # 2 bytes: universe LE
|
||||
packet.extend(struct.pack(">H", data_len)) # 2 bytes: length BE
|
||||
packet.extend(dmx_data) # N bytes: data
|
||||
return bytes(packet)
|
||||
|
||||
def _build_sacn_packet(
|
||||
self, universe: int, dmx_data: bytes, sequence: int, priority: int = 100,
|
||||
) -> bytes:
|
||||
"""Build an sACN / E1.31 data packet.
|
||||
|
||||
Structure:
|
||||
- Root layer (38 bytes)
|
||||
- Framing layer (77 bytes)
|
||||
- DMP layer (10 bytes + 1 start code + DMX data)
|
||||
"""
|
||||
slot_count = len(dmx_data) + 1 # +1 for DMX start code (0x00)
|
||||
dmp_len = 10 + slot_count # DMP layer
|
||||
framing_len = 77 + dmp_len # framing layer
|
||||
root_len = 22 + framing_len # root layer (after preamble)
|
||||
|
||||
packet = bytearray()
|
||||
|
||||
# ── Root Layer ──
|
||||
packet.extend(struct.pack(">H", 0x0010)) # preamble size
|
||||
packet.extend(struct.pack(">H", 0x0000)) # post-amble size
|
||||
packet.extend(ACN_PACKET_IDENTIFIER) # 12 bytes ACN packet ID
|
||||
# Flags + length (high 4 bits = 0x7, low 12 = root_len)
|
||||
packet.extend(struct.pack(">H", 0x7000 | (root_len & 0x0FFF)))
|
||||
packet.extend(struct.pack(">I", SACN_VECTOR_ROOT)) # vector
|
||||
packet.extend(self._sacn_cid) # 16 bytes CID
|
||||
|
||||
# ── Framing Layer ──
|
||||
packet.extend(struct.pack(">H", 0x7000 | (framing_len & 0x0FFF)))
|
||||
packet.extend(struct.pack(">I", SACN_VECTOR_FRAMING)) # vector
|
||||
packet.extend(self._sacn_source_name[:64]) # 64 bytes source name
|
||||
packet.append(priority & 0xFF) # priority
|
||||
packet.extend(struct.pack(">H", 0)) # sync address (0 = none)
|
||||
packet.append(sequence & 0xFF) # sequence
|
||||
packet.append(0) # options (0 = normal)
|
||||
packet.extend(struct.pack(">H", universe)) # universe
|
||||
|
||||
# ── DMP Layer ──
|
||||
packet.extend(struct.pack(">H", 0x7000 | (dmp_len & 0x0FFF)))
|
||||
packet.append(SACN_VECTOR_DMP) # vector
|
||||
packet.append(0xA1) # address type & data type
|
||||
packet.extend(struct.pack(">H", 0)) # first property address
|
||||
packet.extend(struct.pack(">H", 1)) # address increment
|
||||
packet.extend(struct.pack(">H", slot_count)) # property value count
|
||||
packet.append(0x00) # DMX start code
|
||||
packet.extend(dmx_data) # DMX channel data
|
||||
|
||||
return bytes(packet)
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
82
server/src/wled_controller/core/devices/dmx_provider.py
Normal file
82
server/src/wled_controller/core/devices/dmx_provider.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""DMX device provider — Art-Net / sACN (E1.31) factory, validation, health."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.core.devices.dmx_client import DMXClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def parse_dmx_url(url: str) -> dict:
|
||||
"""Parse a DMX URL like 'artnet://192.168.1.50' or 'sacn://192.168.1.50'.
|
||||
|
||||
Returns dict with 'host', 'port', 'protocol'.
|
||||
Also accepts plain IP addresses (defaults to artnet).
|
||||
"""
|
||||
url = url.strip()
|
||||
if "://" not in url:
|
||||
url = f"artnet://{url}"
|
||||
|
||||
parsed = urlparse(url)
|
||||
protocol = parsed.scheme.lower()
|
||||
if protocol not in ("artnet", "sacn"):
|
||||
protocol = "artnet"
|
||||
|
||||
host = parsed.hostname or "127.0.0.1"
|
||||
port = parsed.port # None = use protocol default
|
||||
|
||||
return {"host": host, "port": port, "protocol": protocol}
|
||||
|
||||
|
||||
class DMXDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for Art-Net and sACN (E1.31) DMX devices."""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "dmx"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {"manual_led_count"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
parsed = parse_dmx_url(url)
|
||||
return DMXClient(
|
||||
host=parsed["host"],
|
||||
port=parsed["port"],
|
||||
led_count=kwargs.get("led_count", 1),
|
||||
protocol=kwargs.get("dmx_protocol", parsed["protocol"]),
|
||||
start_universe=kwargs.get("dmx_start_universe", 0),
|
||||
start_channel=kwargs.get("dmx_start_channel", 1),
|
||||
)
|
||||
|
||||
async def check_health(
|
||||
self, url: str, http_client, prev_health=None,
|
||||
) -> DeviceHealth:
|
||||
# DMX is UDP fire-and-forget — no reliable health probe.
|
||||
# Report as always online (same pattern as WS/Mock providers).
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
latency_ms=0.0,
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate DMX device URL."""
|
||||
parsed = parse_dmx_url(url)
|
||||
if not parsed["host"]:
|
||||
raise ValueError("DMX device requires a valid IP address or hostname")
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
# No auto-discovery for DMX devices
|
||||
return []
|
||||
163
server/src/wled_controller/core/devices/espnow_client.py
Normal file
163
server/src/wled_controller/core/devices/espnow_client.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""ESP-NOW LED client — sends pixel data via serial to an ESP32 gateway which forwards over ESP-NOW."""
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Gateway serial protocol constants
|
||||
FRAME_START = 0xEE
|
||||
FRAME_END = 0xEF
|
||||
|
||||
|
||||
def _mac_str_to_bytes(mac: str) -> bytes:
|
||||
"""Convert 'AA:BB:CC:DD:EE:FF' to 6 raw bytes."""
|
||||
parts = mac.replace("-", ":").split(":")
|
||||
if len(parts) != 6:
|
||||
raise ValueError(f"Invalid MAC address: {mac}")
|
||||
return bytes(int(p, 16) for p in parts)
|
||||
|
||||
|
||||
def _build_frame(peer_mac: bytes, pixels: bytes, brightness: int) -> bytes:
|
||||
"""Build a serial frame for the ESP-NOW gateway.
|
||||
|
||||
Wire format:
|
||||
[0xEE][MAC 6B][LED_COUNT 2B LE][BRIGHTNESS 1B][RGB...][CHECKSUM 1B][0xEF]
|
||||
|
||||
Checksum is XOR of all bytes between START and END (exclusive).
|
||||
"""
|
||||
led_count = len(pixels) // 3
|
||||
header = struct.pack("<B6sHB", FRAME_START, peer_mac, led_count, brightness)
|
||||
payload = header[1:] + pixels # everything after START for checksum
|
||||
checksum = 0
|
||||
for b in payload:
|
||||
checksum ^= b
|
||||
return header + pixels + bytes([checksum, FRAME_END])
|
||||
|
||||
|
||||
class ESPNowClient(LEDClient):
|
||||
"""LED client that sends pixel data to an ESP32 ESP-NOW gateway over serial.
|
||||
|
||||
The gateway ESP32 receives serial frames and forwards LED data via
|
||||
ESP-NOW to a peer ESP32 which drives the LED strip.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "",
|
||||
led_count: int = 0,
|
||||
baud_rate: int = 921600,
|
||||
espnow_peer_mac: str = "FF:FF:FF:FF:FF:FF",
|
||||
espnow_channel: int = 1,
|
||||
**kwargs,
|
||||
):
|
||||
self._port = url
|
||||
self._led_count = led_count
|
||||
self._baud_rate = baud_rate
|
||||
self._peer_mac = _mac_str_to_bytes(espnow_peer_mac)
|
||||
self._channel = espnow_channel
|
||||
self._serial = None
|
||||
self._connected = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self) -> bool:
|
||||
try:
|
||||
import serial as pyserial
|
||||
except ImportError:
|
||||
raise RuntimeError("pyserial is required for ESP-NOW devices: pip install pyserial")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
self._serial = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: pyserial.Serial(self._port, self._baud_rate, timeout=1),
|
||||
)
|
||||
self._connected = True
|
||||
logger.info(
|
||||
"ESP-NOW client connected: port=%s baud=%d peer=%s channel=%d leds=%d",
|
||||
self._port, self._baud_rate,
|
||||
":".join(f"{b:02X}" for b in self._peer_mac),
|
||||
self._channel, self._led_count,
|
||||
)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._serial and self._serial.is_open:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._serial.close)
|
||||
self._serial = None
|
||||
self._connected = False
|
||||
logger.info("ESP-NOW client closed: port=%s", self._port)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._serial is not None and self._serial.is_open
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
if not self.is_connected:
|
||||
return
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_bytes = pixels.astype(np.uint8).tobytes()
|
||||
else:
|
||||
pixel_bytes = bytes(c for rgb in pixels for c in rgb)
|
||||
|
||||
# Note: brightness already applied by processor loop; pass 255 to firmware
|
||||
frame = _build_frame(self._peer_mac, pixel_bytes, 255)
|
||||
try:
|
||||
self._serial.write(frame)
|
||||
except Exception as e:
|
||||
logger.warning("ESP-NOW send failed: %s", e)
|
||||
self._connected = False
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
if not self.is_connected:
|
||||
return False
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.send_pixels_fast(pixels, brightness),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("ESP-NOW async send failed: %s", e)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check if the serial port is available."""
|
||||
try:
|
||||
import serial as pyserial
|
||||
|
||||
s = pyserial.Serial(url, timeout=0.1)
|
||||
s.close()
|
||||
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
|
||||
except Exception as e:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
error=str(e),
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
87
server/src/wled_controller/core/devices/espnow_provider.py
Normal file
87
server/src/wled_controller/core/devices/espnow_provider.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""ESP-NOW device provider — ultra-low-latency LED control via ESP32 gateway."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.core.devices.espnow_client import ESPNowClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ESPNowDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for ESP-NOW LED devices via serial ESP32 gateway.
|
||||
|
||||
URL = serial port of the gateway ESP32 (e.g. COM3, /dev/ttyUSB0).
|
||||
Each device represents one remote ESP32 peer identified by MAC address.
|
||||
Multiple devices can share the same gateway (serial port).
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "espnow"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {"manual_led_count", "health_check"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
return ESPNowClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
baud_rate=kwargs.get("baud_rate", 921600),
|
||||
espnow_peer_mac=kwargs.get("espnow_peer_mac", "FF:FF:FF:FF:FF:FF"),
|
||||
espnow_channel=kwargs.get("espnow_channel", 1),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await ESPNowClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate serial port is accessible. LED count is manual."""
|
||||
try:
|
||||
import serial as pyserial
|
||||
|
||||
s = pyserial.Serial(url, timeout=0.5)
|
||||
s.close()
|
||||
except ImportError:
|
||||
raise ValueError("pyserial is required for ESP-NOW devices: pip install pyserial")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Cannot open serial port {url}: {e}")
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""Discover available serial ports that could be ESP32 gateways."""
|
||||
try:
|
||||
import serial.tools.list_ports
|
||||
|
||||
ports = serial.tools.list_ports.comports()
|
||||
results = []
|
||||
for port in ports:
|
||||
# Look for ESP32 USB descriptors
|
||||
desc = (port.description or "").lower()
|
||||
vid = port.vid or 0
|
||||
# Common ESP32 USB VIDs: Espressif (0x303A), Silicon Labs CP210x (0x10C4),
|
||||
# FTDI (0x0403), WCH CH340 (0x1A86)
|
||||
esp_vids = {0x303A, 0x10C4, 0x0403, 0x1A86}
|
||||
if vid in esp_vids or "cp210" in desc or "ch340" in desc or "esp" in desc:
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"ESP-NOW Gateway ({port.description})",
|
||||
url=port.device,
|
||||
device_type="espnow",
|
||||
ip="",
|
||||
mac="",
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
return results
|
||||
except ImportError:
|
||||
return []
|
||||
262
server/src/wled_controller/core/devices/gamesense_client.py
Normal file
262
server/src/wled_controller/core/devices/gamesense_client.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""SteelSeries GameSense LED client — controls SteelSeries RGB peripherals via REST API."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Game registration constants
|
||||
GAME_NAME = "WLED_SCREEN_CTRL"
|
||||
GAME_DISPLAY_NAME = "WLED Screen Controller"
|
||||
EVENT_NAME = "PIXEL_DATA"
|
||||
|
||||
|
||||
def _get_gamesense_address() -> Optional[str]:
|
||||
"""Discover the SteelSeries GameSense address from coreProps.json."""
|
||||
if platform.system() == "Windows":
|
||||
props_path = os.path.join(
|
||||
os.environ.get("PROGRAMDATA", r"C:\ProgramData"),
|
||||
"SteelSeries", "SteelSeries Engine 3", "coreProps.json",
|
||||
)
|
||||
elif platform.system() == "Darwin":
|
||||
props_path = os.path.expanduser(
|
||||
"~/Library/Application Support/SteelSeries Engine 3/coreProps.json"
|
||||
)
|
||||
else:
|
||||
# Linux — SteelSeries Engine not officially supported
|
||||
props_path = os.path.expanduser(
|
||||
"~/.config/SteelSeries Engine 3/coreProps.json"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(props_path, "r") as f:
|
||||
data = json.load(f)
|
||||
return data.get("address")
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
# GameSense device-zone mapping
|
||||
GAMESENSE_ZONES = {
|
||||
"keyboard": ("rgb-per-key-zones", "all"),
|
||||
"mouse": ("mouse", "wheel"),
|
||||
"headset": ("headset", "earcups"),
|
||||
"mousepad": ("mousepad", "all"),
|
||||
"indicator": ("indicator", "one"),
|
||||
}
|
||||
|
||||
|
||||
class GameSenseClient(LEDClient):
|
||||
"""LED client that controls SteelSeries peripherals via GameSense REST API.
|
||||
|
||||
GameSense uses a register-bind-send pattern:
|
||||
1. Register game + event
|
||||
2. Bind event to device zone with context-color handler
|
||||
3. Send color events with RGB data in frame payload
|
||||
|
||||
The API address is discovered from coreProps.json at runtime.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "",
|
||||
led_count: int = 0,
|
||||
gamesense_device_type: str = "keyboard",
|
||||
**kwargs,
|
||||
):
|
||||
self._address = url.replace("gamesense://", "").strip() if url else ""
|
||||
self._led_count = led_count
|
||||
self._gs_device_type = gamesense_device_type
|
||||
self._connected = False
|
||||
self._http_client = None
|
||||
self._base_url: Optional[str] = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
import httpx
|
||||
|
||||
# Discover GameSense address
|
||||
address = self._address or _get_gamesense_address()
|
||||
if not address:
|
||||
raise RuntimeError(
|
||||
"Cannot find SteelSeries Engine. "
|
||||
"Ensure SteelSeries GG/Engine is running."
|
||||
)
|
||||
|
||||
self._base_url = f"http://{address}"
|
||||
self._http_client = httpx.AsyncClient(timeout=5.0)
|
||||
|
||||
try:
|
||||
# Register game
|
||||
await self._http_client.post(
|
||||
f"{self._base_url}/game_metadata",
|
||||
json={
|
||||
"game": GAME_NAME,
|
||||
"game_display_name": GAME_DISPLAY_NAME,
|
||||
"developer": "WLED-SC",
|
||||
},
|
||||
)
|
||||
|
||||
# Register event
|
||||
await self._http_client.post(
|
||||
f"{self._base_url}/register_game_event",
|
||||
json={
|
||||
"game": GAME_NAME,
|
||||
"event": EVENT_NAME,
|
||||
"min_value": 0,
|
||||
"max_value": 100,
|
||||
"value_optional": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Bind event to device zone with context-color mode
|
||||
zone_info = GAMESENSE_ZONES.get(
|
||||
self._gs_device_type, ("rgb-per-key-zones", "all")
|
||||
)
|
||||
device_type, zone = zone_info
|
||||
|
||||
await self._http_client.post(
|
||||
f"{self._base_url}/bind_game_event",
|
||||
json={
|
||||
"game": GAME_NAME,
|
||||
"event": EVENT_NAME,
|
||||
"handlers": [
|
||||
{
|
||||
"device-type": device_type,
|
||||
"zone": zone,
|
||||
"mode": "context-color",
|
||||
"context-frame-key": "zone-color",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("GameSense setup failed: %s", e)
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
raise
|
||||
|
||||
self._connected = True
|
||||
logger.info(
|
||||
"GameSense client connected: device=%s address=%s leds=%d",
|
||||
self._gs_device_type, address, self._led_count,
|
||||
)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._http_client and self._base_url:
|
||||
try:
|
||||
# Remove game registration
|
||||
await self._http_client.post(
|
||||
f"{self._base_url}/remove_game",
|
||||
json={"game": GAME_NAME},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
self._connected = False
|
||||
self._base_url = None
|
||||
logger.info("GameSense client closed")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._http_client is not None
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
if not self.is_connected or not self._http_client:
|
||||
return False
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_arr = pixels
|
||||
else:
|
||||
pixel_arr = np.array(pixels, dtype=np.uint8)
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
|
||||
# Use average color for single-zone devices, or first N for multi-zone
|
||||
if len(pixel_arr) == 0:
|
||||
return True
|
||||
|
||||
# Compute average color for the zone
|
||||
avg = pixel_arr.mean(axis=0)
|
||||
r = int(avg[0])
|
||||
g = int(avg[1])
|
||||
b = int(avg[2])
|
||||
|
||||
event_data = {
|
||||
"game": GAME_NAME,
|
||||
"event": EVENT_NAME,
|
||||
"data": {
|
||||
"value": 100,
|
||||
"frame": {
|
||||
"zone-color": {
|
||||
"red": r,
|
||||
"green": g,
|
||||
"blue": b,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = await self._http_client.post(
|
||||
f"{self._base_url}/game_event",
|
||||
json=event_data,
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error("GameSense send failed: %s", e)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check if SteelSeries Engine is running."""
|
||||
address = url.replace("gamesense://", "").strip() if url else None
|
||||
if not address:
|
||||
address = _get_gamesense_address()
|
||||
|
||||
if not address:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
error="SteelSeries Engine not found (coreProps.json missing)",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await http_client.get(f"http://{address}", timeout=3.0)
|
||||
if resp.status_code < 500:
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
latency_ms=0.0,
|
||||
device_name="SteelSeries GameSense",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
error="SteelSeries Engine not responding",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
104
server/src/wled_controller/core/devices/gamesense_provider.py
Normal file
104
server/src/wled_controller/core/devices/gamesense_provider.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""SteelSeries GameSense device provider — control SteelSeries RGB peripherals."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.core.devices.gamesense_client import (
|
||||
GameSenseClient,
|
||||
GAMESENSE_ZONES,
|
||||
_get_gamesense_address,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GameSenseDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for SteelSeries GameSense RGB peripherals.
|
||||
|
||||
URL format: gamesense://address or gamesense://auto (auto-discover from coreProps.json)
|
||||
Requires SteelSeries GG / SteelSeries Engine 3.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "gamesense"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {"manual_led_count", "health_check"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
return GameSenseClient(
|
||||
url=url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
gamesense_device_type=kwargs.get("gamesense_device_type", "keyboard"),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await GameSenseClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate GameSense is reachable."""
|
||||
address = url.replace("gamesense://", "").strip()
|
||||
if not address or address == "auto":
|
||||
address = _get_gamesense_address()
|
||||
|
||||
if not address:
|
||||
raise ValueError(
|
||||
"Cannot find SteelSeries Engine. "
|
||||
"Ensure SteelSeries GG is running."
|
||||
)
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
resp = await client.get(f"http://{address}")
|
||||
if resp.status_code >= 500:
|
||||
raise ValueError("SteelSeries Engine returned server error")
|
||||
except httpx.ConnectError:
|
||||
raise ValueError(
|
||||
"Cannot connect to SteelSeries Engine at "
|
||||
f"{address}. Ensure it is running."
|
||||
)
|
||||
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""Discover SteelSeries Engine if running."""
|
||||
address = _get_gamesense_address()
|
||||
if not address:
|
||||
return []
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(f"http://{address}")
|
||||
if resp.status_code >= 500:
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# Engine is running — offer device types
|
||||
results = []
|
||||
for dev_type in GAMESENSE_ZONES:
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"SteelSeries {dev_type.title()}",
|
||||
url=f"gamesense://{address}",
|
||||
device_type="gamesense",
|
||||
ip=address.split(":")[0],
|
||||
mac="",
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
return results
|
||||
240
server/src/wled_controller/core/devices/hue_client.py
Normal file
240
server/src/wled_controller/core/devices/hue_client.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Philips Hue LED client — streams color data via the Hue Entertainment API."""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import struct
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Hue Entertainment API constants
|
||||
HUE_ENT_PORT = 2100
|
||||
PROTOCOL_NAME = b"HueStream"
|
||||
VERSION_MAJOR = 2
|
||||
VERSION_MINOR = 0
|
||||
COLOR_SPACE_RGB = 0x00
|
||||
# API version 2 header: "HueStream" + version(2B) + sequence(1B) + reserved(2B)
|
||||
# + color_space(1B) + reserved(1B) = 16 bytes
|
||||
HEADER_SIZE = 16
|
||||
|
||||
|
||||
def _build_entertainment_frame(
|
||||
lights: List[Tuple[int, int, int]],
|
||||
brightness: int = 255,
|
||||
sequence: int = 0,
|
||||
) -> bytes:
|
||||
"""Build a Hue Entertainment API v2 UDP frame.
|
||||
|
||||
Each light gets 7 bytes: [light_id(2B)][R(2B)][G(2B)][B(2B)]
|
||||
Colors are 16-bit (0-65535). We scale 8-bit RGB + brightness.
|
||||
"""
|
||||
# Header
|
||||
header = bytearray(HEADER_SIZE)
|
||||
header[0:9] = PROTOCOL_NAME
|
||||
header[9] = VERSION_MAJOR
|
||||
header[10] = VERSION_MINOR
|
||||
header[11] = sequence & 0xFF
|
||||
header[12] = 0x00 # reserved
|
||||
header[13] = 0x00 # reserved
|
||||
header[14] = COLOR_SPACE_RGB
|
||||
header[15] = 0x00 # reserved
|
||||
|
||||
# Light data
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
data = bytearray()
|
||||
for idx, (r, g, b) in enumerate(lights):
|
||||
light_id = idx # 0-based light index in entertainment group
|
||||
r16 = int(r * 257) # scale 0-255 to 0-65535
|
||||
g16 = int(g * 257)
|
||||
b16 = int(b * 257)
|
||||
data += struct.pack(">BHHH", light_id, r16, g16, b16)
|
||||
|
||||
return bytes(header) + bytes(data)
|
||||
|
||||
|
||||
class HueClient(LEDClient):
|
||||
"""LED client for Philips Hue Entertainment API streaming.
|
||||
|
||||
Uses UDP (optionally DTLS) to stream color data at ~25 fps to a Hue
|
||||
entertainment group. Each light in the group is treated as one "LED".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "",
|
||||
led_count: int = 0,
|
||||
hue_username: str = "",
|
||||
hue_client_key: str = "",
|
||||
hue_entertainment_group_id: str = "",
|
||||
**kwargs,
|
||||
):
|
||||
self._bridge_ip = url.replace("hue://", "").rstrip("/")
|
||||
self._led_count = led_count
|
||||
self._username = hue_username
|
||||
self._client_key = hue_client_key
|
||||
self._group_id = hue_entertainment_group_id
|
||||
self._sock: Optional[socket.socket] = None
|
||||
self._connected = False
|
||||
self._sequence = 0
|
||||
self._dtls_sock = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Activate entertainment streaming via REST API
|
||||
await self._activate_streaming(True)
|
||||
|
||||
# Open UDP socket for entertainment streaming
|
||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._sock.setblocking(False)
|
||||
|
||||
# Try DTLS if dtls library is available
|
||||
try:
|
||||
from dtls import do_patch
|
||||
do_patch()
|
||||
import ssl
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
ctx.set_ciphers("PSK-AES128-GCM-SHA256")
|
||||
# PSK identity = username, key = client_key (hex decoded)
|
||||
psk = bytes.fromhex(self._client_key)
|
||||
ctx.set_psk_client_callback(lambda hint: (self._username.encode(), psk))
|
||||
self._dtls_sock = ctx.wrap_socket(self._sock, server_hostname=self._bridge_ip)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._dtls_sock.connect((self._bridge_ip, HUE_ENT_PORT)),
|
||||
)
|
||||
logger.info("Hue DTLS connection established to %s", self._bridge_ip)
|
||||
except (ImportError, Exception) as e:
|
||||
# Fall back to plain UDP (works for local testing / older bridges)
|
||||
logger.warning(
|
||||
"Hue DTLS not available (%s), falling back to plain UDP. "
|
||||
"Install 'dtls' package for encrypted streaming.",
|
||||
e,
|
||||
)
|
||||
self._dtls_sock = None
|
||||
|
||||
self._connected = True
|
||||
logger.info(
|
||||
"Hue client connected: bridge=%s group=%s lights=%d",
|
||||
self._bridge_ip, self._group_id, self._led_count,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _activate_streaming(self, active: bool) -> None:
|
||||
"""Activate/deactivate entertainment streaming via Hue REST API."""
|
||||
import httpx
|
||||
|
||||
url = f"https://{self._bridge_ip}/clip/v2/resource/entertainment_configuration/{self._group_id}"
|
||||
payload = {"action": "start" if active else "stop"}
|
||||
headers = {"hue-application-key": self._username}
|
||||
|
||||
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
|
||||
resp = await client.put(url, json=payload, headers=headers)
|
||||
if resp.status_code not in (200, 207):
|
||||
logger.warning("Hue streaming %s failed: %s", "start" if active else "stop", resp.text)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._connected:
|
||||
try:
|
||||
await self._activate_streaming(False)
|
||||
except Exception:
|
||||
pass
|
||||
if self._dtls_sock:
|
||||
try:
|
||||
self._dtls_sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
if self._sock:
|
||||
try:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._sock = None
|
||||
self._dtls_sock = None
|
||||
self._connected = False
|
||||
logger.info("Hue client closed: bridge=%s", self._bridge_ip)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
if not self._connected:
|
||||
return
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
light_colors = [tuple(pixels[i]) for i in range(min(len(pixels), self._led_count))]
|
||||
else:
|
||||
light_colors = pixels[: self._led_count]
|
||||
|
||||
frame = _build_entertainment_frame(light_colors, brightness, self._sequence)
|
||||
self._sequence = (self._sequence + 1) & 0xFF
|
||||
|
||||
try:
|
||||
if self._dtls_sock:
|
||||
self._dtls_sock.send(frame)
|
||||
elif self._sock:
|
||||
self._sock.sendto(frame, (self._bridge_ip, HUE_ENT_PORT))
|
||||
except Exception as e:
|
||||
logger.warning("Hue send failed: %s", e)
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.send_pixels_fast(pixels, brightness),
|
||||
)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check if the Hue bridge is reachable."""
|
||||
bridge_ip = url.replace("hue://", "").rstrip("/")
|
||||
try:
|
||||
import httpx
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
async with httpx.AsyncClient(verify=False, timeout=3.0) as client:
|
||||
resp = await client.get(f"https://{bridge_ip}/api/0/config")
|
||||
latency = (time.time() - start) * 1000
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
latency_ms=round(latency, 1),
|
||||
device_name=data.get("name", "Hue Bridge"),
|
||||
device_version=data.get("swversion"),
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
return DeviceHealth(online=False, error="Unexpected response", last_checked=datetime.now(timezone.utc))
|
||||
except Exception as e:
|
||||
return DeviceHealth(online=False, error=str(e), last_checked=datetime.now(timezone.utc))
|
||||
195
server/src/wled_controller/core/devices/hue_provider.py
Normal file
195
server/src/wled_controller/core/devices/hue_provider.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Philips Hue device provider — entertainment streaming to Hue lights."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Tuple
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.core.devices.hue_client import HueClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HueDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for Philips Hue Entertainment API streaming.
|
||||
|
||||
URL format: hue://<bridge-ip>
|
||||
Each device = one entertainment group on the bridge.
|
||||
LED count = number of lights in the entertainment group.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "hue"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {
|
||||
"manual_led_count",
|
||||
"brightness_control",
|
||||
"power_control",
|
||||
"health_check",
|
||||
"static_color",
|
||||
}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
return HueClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
hue_username=kwargs.get("hue_username", ""),
|
||||
hue_client_key=kwargs.get("hue_client_key", ""),
|
||||
hue_entertainment_group_id=kwargs.get("hue_entertainment_group_id", ""),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await HueClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate Hue bridge is reachable. LED count is manual (depends on entertainment group)."""
|
||||
bridge_ip = url.replace("hue://", "").rstrip("/")
|
||||
try:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
|
||||
resp = await client.get(f"https://{bridge_ip}/api/0/config")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if "bridgeid" in data or "name" in data:
|
||||
return {}
|
||||
raise ValueError(f"Device at {bridge_ip} does not appear to be a Hue bridge")
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError(f"Cannot reach Hue bridge at {bridge_ip}: {e}")
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""Discover Hue bridges via mDNS."""
|
||||
results = []
|
||||
try:
|
||||
from zeroconf import ServiceBrowser, Zeroconf
|
||||
|
||||
found = []
|
||||
|
||||
class Listener:
|
||||
def add_service(self, zc, type_, name):
|
||||
info = zc.get_service_info(type_, name)
|
||||
if info:
|
||||
found.append(info)
|
||||
|
||||
def remove_service(self, zc, type_, name):
|
||||
pass
|
||||
|
||||
def update_service(self, zc, type_, name):
|
||||
pass
|
||||
|
||||
zc = Zeroconf()
|
||||
try:
|
||||
ServiceBrowser(zc, "_hue._tcp.local.", Listener())
|
||||
await asyncio.sleep(min(timeout, 3.0))
|
||||
|
||||
for info in found:
|
||||
addresses = info.parsed_addresses()
|
||||
if not addresses:
|
||||
continue
|
||||
ip = addresses[0]
|
||||
name = info.server.rstrip(".")
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"Hue Bridge ({name})",
|
||||
url=f"hue://{ip}",
|
||||
device_type="hue",
|
||||
ip=ip,
|
||||
mac="",
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
zc.close()
|
||||
except ImportError:
|
||||
logger.debug("zeroconf not available for Hue discovery")
|
||||
return results
|
||||
|
||||
async def get_brightness(self, url: str) -> int:
|
||||
"""Get bridge group brightness (not per-light)."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def set_brightness(self, url: str, brightness: int) -> None:
|
||||
"""Software brightness — handled at send time."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
return True
|
||||
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
"""Turn all lights in group on/off via REST API."""
|
||||
bridge_ip = url.replace("hue://", "").rstrip("/")
|
||||
hue_username = kwargs.get("hue_username", "")
|
||||
group_id = kwargs.get("hue_entertainment_group_id", "")
|
||||
if not hue_username or not group_id:
|
||||
return
|
||||
try:
|
||||
import httpx
|
||||
|
||||
api_url = f"https://{bridge_ip}/clip/v2/resource/grouped_light"
|
||||
headers = {"hue-application-key": hue_username}
|
||||
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
|
||||
resp = await client.get(api_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
# Find the grouped_light for our entertainment config
|
||||
for item in resp.json().get("data", []):
|
||||
await client.put(
|
||||
f"{api_url}/{item['id']}",
|
||||
json={"on": {"on": on}},
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to set Hue power: %s", e)
|
||||
|
||||
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
|
||||
"""Set all lights to a solid color via REST API."""
|
||||
bridge_ip = url.replace("hue://", "").rstrip("/")
|
||||
hue_username = kwargs.get("hue_username", "")
|
||||
if not hue_username:
|
||||
return
|
||||
try:
|
||||
import httpx
|
||||
|
||||
headers = {"hue-application-key": hue_username}
|
||||
# Convert RGB to CIE xy for Hue API
|
||||
r, g, b = [c / 255.0 for c in color]
|
||||
# sRGB to linear
|
||||
r = r / 12.92 if r <= 0.04045 else ((r + 0.055) / 1.055) ** 2.4
|
||||
g = g / 12.92 if g <= 0.04045 else ((g + 0.055) / 1.055) ** 2.4
|
||||
b = b / 12.92 if b <= 0.04045 else ((b + 0.055) / 1.055) ** 2.4
|
||||
X = r * 0.4124 + g * 0.3576 + b * 0.1805
|
||||
Y = r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||
Z = r * 0.0193 + g * 0.1192 + b * 0.9505
|
||||
total = X + Y + Z
|
||||
if total > 0:
|
||||
x = X / total
|
||||
y = Y / total
|
||||
else:
|
||||
x, y = 0.3127, 0.3290 # D65 white point
|
||||
|
||||
api_url = f"https://{bridge_ip}/clip/v2/resource/light"
|
||||
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
|
||||
resp = await client.get(api_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
for light in resp.json().get("data", []):
|
||||
await client.put(
|
||||
f"{api_url}/{light['id']}",
|
||||
json={
|
||||
"on": {"on": True},
|
||||
"color": {"xy": {"x": round(x, 4), "y": round(y, 4)}},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to set Hue color: %s", e)
|
||||
@@ -296,5 +296,26 @@ def _register_builtin_providers():
|
||||
from wled_controller.core.devices.openrgb_provider import OpenRGBDeviceProvider
|
||||
register_provider(OpenRGBDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.dmx_provider import DMXDeviceProvider
|
||||
register_provider(DMXDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.espnow_provider import ESPNowDeviceProvider
|
||||
register_provider(ESPNowDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.hue_provider import HueDeviceProvider
|
||||
register_provider(HueDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.usbhid_provider import USBHIDDeviceProvider
|
||||
register_provider(USBHIDDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.spi_provider import SPIDeviceProvider
|
||||
register_provider(SPIDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.chroma_provider import ChromaDeviceProvider
|
||||
register_provider(ChromaDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.gamesense_provider import GameSenseDeviceProvider
|
||||
register_provider(GameSenseDeviceProvider())
|
||||
|
||||
|
||||
_register_builtin_providers()
|
||||
|
||||
@@ -24,8 +24,11 @@ class MockDeviceProvider(LEDDeviceProvider):
|
||||
return {"manual_led_count", "power_control", "brightness_control"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
kwargs.pop("use_ddp", None)
|
||||
return MockClient(url, **kwargs)
|
||||
return MockClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
send_latency_ms=kwargs.get("send_latency_ms", 0),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
|
||||
|
||||
@@ -31,7 +31,10 @@ class MQTTDeviceProvider(LEDDeviceProvider):
|
||||
return {"manual_led_count"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
return MQTTLEDClient(url, **kwargs)
|
||||
return MQTTLEDClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
)
|
||||
|
||||
async def check_health(
|
||||
self, url: str, http_client, prev_health=None,
|
||||
|
||||
@@ -96,7 +96,7 @@ class OpenRGBLEDClient(LEDClient):
|
||||
self._send_pending: Optional[Tuple[np.ndarray, int]] = None # (pixels, brightness)
|
||||
self._send_thread: Optional[threading.Thread] = None
|
||||
self._send_stop = threading.Event()
|
||||
self._last_sent_pixels: Optional[np.ndarray] = None # for change-threshold dedup
|
||||
self._last_sent_pixels: Optional[np.ndarray] = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to OpenRGB server and access the target device."""
|
||||
@@ -288,23 +288,22 @@ class OpenRGBLEDClient(LEDClient):
|
||||
Builds raw OpenRGB UpdateZoneLeds packets directly with struct.pack,
|
||||
bypassing RGBColor object creation to avoid GC pressure.
|
||||
"""
|
||||
# Apply brightness scaling
|
||||
if brightness < 255:
|
||||
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
|
||||
|
||||
# Truncate to match target LED count
|
||||
n_target = self._device_led_count
|
||||
if len(pixel_array) > n_target:
|
||||
pixel_array = pixel_array[:n_target]
|
||||
|
||||
# Change-threshold dedup — skip if average per-LED color change < 2
|
||||
# GPU I2C/SMBus writes cause system-wide stalls; minimizing writes is critical.
|
||||
# Change-threshold dedup — compare RAW pixels before brightness scaling
|
||||
# so low brightness doesn't crush differences below the threshold.
|
||||
# Exact-match dedup — skip only if pixels are identical to last sent frame.
|
||||
# Threshold-based dedup caused stutter at low brightness.
|
||||
if self._last_sent_pixels is not None and self._last_sent_pixels.shape == pixel_array.shape:
|
||||
diff = np.mean(np.abs(pixel_array.astype(np.int16) - self._last_sent_pixels.astype(np.int16)))
|
||||
if diff < 2.0:
|
||||
if np.array_equal(pixel_array, self._last_sent_pixels):
|
||||
return
|
||||
self._last_sent_pixels = pixel_array.copy()
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
|
||||
# Separate mode: resample full pixel array independently per zone
|
||||
if self._zone_mode == "separate" and len(self._target_zones) > 1:
|
||||
n_src = len(pixel_array)
|
||||
|
||||
@@ -27,15 +27,13 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {"health_check", "auto_restore", "static_color"}
|
||||
return {"health_check", "auto_restore", "static_color", "brightness_control"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
zone_mode = kwargs.pop("zone_mode", "combined")
|
||||
kwargs.pop("led_count", None)
|
||||
kwargs.pop("baud_rate", None)
|
||||
kwargs.pop("send_latency_ms", None)
|
||||
kwargs.pop("rgbw", None)
|
||||
return OpenRGBLEDClient(url, zone_mode=zone_mode, **kwargs)
|
||||
return OpenRGBLEDClient(
|
||||
url,
|
||||
zone_mode=kwargs.get("zone_mode", "combined"),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await OpenRGBLEDClient.check_health(url, http_client, prev_health)
|
||||
|
||||
264
server/src/wled_controller/core/devices/spi_client.py
Normal file
264
server/src/wled_controller/core/devices/spi_client.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""SPI Direct LED client — drives WS2812/SK6812 strips via Raspberry Pi GPIO/SPI."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Supported LED chipsets
|
||||
LED_TYPES = {
|
||||
"WS2812": {"freq_hz": 800000, "invert": False, "strip_type": None},
|
||||
"WS2812B": {"freq_hz": 800000, "invert": False, "strip_type": None},
|
||||
"WS2811": {"freq_hz": 800000, "invert": False, "strip_type": None},
|
||||
"SK6812": {"freq_hz": 800000, "invert": False, "strip_type": None},
|
||||
"SK6812_RGBW": {"freq_hz": 800000, "invert": False, "strip_type": None},
|
||||
}
|
||||
|
||||
# SPI speed for bitbang protocol
|
||||
DEFAULT_SPI_SPEED = 800000
|
||||
|
||||
# GPIO pin for rpi_ws281x (GPIO 18 = PWM0, GPIO 10 = SPI0 MOSI)
|
||||
DEFAULT_GPIO_PIN = 18
|
||||
|
||||
|
||||
def _parse_spi_url(url: str) -> dict:
|
||||
"""Parse SPI URL format.
|
||||
|
||||
Formats:
|
||||
spi://gpio:18 — rpi_ws281x via GPIO pin 18
|
||||
spi://spidev:0.0 — SPI device /dev/spidev0.0
|
||||
spi://0 — shorthand for GPIO pin 0 (actually pin 18 default)
|
||||
"""
|
||||
path = url.replace("spi://", "")
|
||||
|
||||
if path.startswith("gpio:"):
|
||||
pin = int(path.split(":")[1])
|
||||
return {"method": "gpio", "gpio_pin": pin}
|
||||
elif path.startswith("spidev:"):
|
||||
dev = path.split(":")[1]
|
||||
bus, device = dev.split(".")
|
||||
return {"method": "spidev", "bus": int(bus), "device": int(device)}
|
||||
else:
|
||||
# Default: gpio pin
|
||||
try:
|
||||
pin = int(path) if path else DEFAULT_GPIO_PIN
|
||||
except ValueError:
|
||||
pin = DEFAULT_GPIO_PIN
|
||||
return {"method": "gpio", "gpio_pin": pin}
|
||||
|
||||
|
||||
class SPIClient(LEDClient):
|
||||
"""LED client that drives addressable LED strips directly via Raspberry Pi GPIO/SPI.
|
||||
|
||||
Uses rpi_ws281x library for GPIO-based control, or spidev for raw SPI.
|
||||
Requires root privileges or SPI group membership.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "",
|
||||
led_count: int = 0,
|
||||
spi_speed_hz: int = DEFAULT_SPI_SPEED,
|
||||
spi_led_type: str = "WS2812B",
|
||||
**kwargs,
|
||||
):
|
||||
self._config = _parse_spi_url(url)
|
||||
self._led_count = led_count
|
||||
self._speed_hz = spi_speed_hz
|
||||
self._led_type = spi_led_type
|
||||
self._strip = None
|
||||
self._spi = None
|
||||
self._connected = False
|
||||
|
||||
async def connect(self) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if self._config["method"] == "gpio":
|
||||
await loop.run_in_executor(None, self._connect_rpi_ws281x)
|
||||
else:
|
||||
await loop.run_in_executor(None, self._connect_spidev)
|
||||
|
||||
self._connected = True
|
||||
logger.info(
|
||||
"SPI client connected: method=%s leds=%d type=%s",
|
||||
self._config["method"], self._led_count, self._led_type,
|
||||
)
|
||||
return True
|
||||
|
||||
def _connect_rpi_ws281x(self):
|
||||
"""Connect via rpi_ws281x library (GPIO PWM/DMA)."""
|
||||
try:
|
||||
from rpi_ws281x import PixelStrip, Color
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"rpi_ws281x is required for GPIO LED control: "
|
||||
"pip install rpi_ws281x (Linux/RPi only)"
|
||||
)
|
||||
|
||||
led_info = LED_TYPES.get(self._led_type, LED_TYPES["WS2812B"])
|
||||
gpio_pin = self._config["gpio_pin"]
|
||||
|
||||
self._strip = PixelStrip(
|
||||
self._led_count,
|
||||
gpio_pin,
|
||||
led_info["freq_hz"],
|
||||
10, # DMA channel
|
||||
led_info["invert"],
|
||||
255, # max brightness
|
||||
0, # channel (0 for GPIO 18, 1 for GPIO 13)
|
||||
)
|
||||
self._strip.begin()
|
||||
|
||||
def _connect_spidev(self):
|
||||
"""Connect via spidev (raw SPI bus)."""
|
||||
try:
|
||||
import spidev
|
||||
except ImportError:
|
||||
raise RuntimeError("spidev is required for SPI LED control: pip install spidev")
|
||||
|
||||
bus = self._config["bus"]
|
||||
device = self._config["device"]
|
||||
self._spi = spidev.SpiDev()
|
||||
self._spi.open(bus, device)
|
||||
self._spi.max_speed_hz = self._speed_hz
|
||||
self._spi.mode = 0
|
||||
|
||||
async def close(self) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
if self._strip:
|
||||
# Turn off all LEDs
|
||||
def _clear():
|
||||
for i in range(self._led_count):
|
||||
self._strip.setPixelColor(i, 0)
|
||||
self._strip.show()
|
||||
|
||||
await loop.run_in_executor(None, _clear)
|
||||
self._strip = None
|
||||
if self._spi:
|
||||
await loop.run_in_executor(None, self._spi.close)
|
||||
self._spi = None
|
||||
self._connected = False
|
||||
logger.info("SPI client closed")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
if not self._connected:
|
||||
return
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_arr = pixels
|
||||
else:
|
||||
pixel_arr = np.array(pixels, dtype=np.uint8)
|
||||
|
||||
if self._strip:
|
||||
# rpi_ws281x path
|
||||
try:
|
||||
from rpi_ws281x import Color
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
self._strip.setBrightness(255)
|
||||
for i in range(min(len(pixel_arr), self._led_count)):
|
||||
r, g, b = pixel_arr[i]
|
||||
self._strip.setPixelColor(i, Color(int(r), int(g), int(b)))
|
||||
self._strip.show()
|
||||
|
||||
elif self._spi:
|
||||
# SPI bitbang path: convert RGB to WS2812 wire format
|
||||
# Each bit is encoded as 3 SPI bits: 1=110, 0=100
|
||||
scaled = pixel_arr[:self._led_count]
|
||||
# GRB order for WS2812
|
||||
grb = scaled[:, [1, 0, 2]]
|
||||
raw_bytes = grb.tobytes()
|
||||
|
||||
# Encode each byte as 3 SPI bytes (8 bits → 24 SPI bits)
|
||||
spi_data = bytearray()
|
||||
for byte in raw_bytes:
|
||||
for bit in range(7, -1, -1):
|
||||
if byte & (1 << bit):
|
||||
spi_data.append(0b110)
|
||||
else:
|
||||
spi_data.append(0b100)
|
||||
|
||||
# Reset pulse (>50us low)
|
||||
spi_data.extend(b'\x00' * 20)
|
||||
|
||||
self._spi.writebytes2(list(spi_data))
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.send_pixels_fast(pixels, brightness),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("SPI send failed: %s", e)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check if the SPI/GPIO device is accessible."""
|
||||
import platform
|
||||
|
||||
if platform.system() != "Linux":
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
error="SPI direct is only available on Linux (Raspberry Pi)",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
config = _parse_spi_url(url)
|
||||
if config["method"] == "spidev":
|
||||
import os
|
||||
|
||||
dev_path = f"/dev/spidev{config['bus']}.{config['device']}"
|
||||
if os.path.exists(dev_path):
|
||||
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
error=f"SPI device {dev_path} not found",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
else:
|
||||
# GPIO — check if we can import rpi_ws281x
|
||||
try:
|
||||
import rpi_ws281x
|
||||
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
|
||||
except ImportError:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
error="rpi_ws281x not installed",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
133
server/src/wled_controller/core/devices/spi_provider.py
Normal file
133
server/src/wled_controller/core/devices/spi_provider.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""SPI Direct device provider — Raspberry Pi GPIO/SPI direct LED strip control."""
|
||||
|
||||
import platform
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.core.devices.spi_client import SPIClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SPIDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for direct SPI/GPIO LED strip control on Raspberry Pi.
|
||||
|
||||
URL formats:
|
||||
spi://gpio:18 — rpi_ws281x via GPIO pin 18 (PWM)
|
||||
spi://gpio:10 — rpi_ws281x via GPIO pin 10 (SPI MOSI)
|
||||
spi://spidev:0.0 — Raw SPI device /dev/spidev0.0
|
||||
|
||||
Requires Linux (Raspberry Pi) and root privileges or appropriate permissions.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "spi"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {"manual_led_count", "health_check", "power_control", "brightness_control"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
return SPIClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
spi_speed_hz=kwargs.get("spi_speed_hz", 800000),
|
||||
spi_led_type=kwargs.get("spi_led_type", "WS2812B"),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await SPIClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate SPI/GPIO is accessible. LED count is manual."""
|
||||
if platform.system() != "Linux":
|
||||
raise ValueError("SPI direct control is only available on Linux (Raspberry Pi)")
|
||||
|
||||
from wled_controller.core.devices.spi_client import _parse_spi_url
|
||||
|
||||
config = _parse_spi_url(url)
|
||||
if config["method"] == "spidev":
|
||||
import os
|
||||
|
||||
dev_path = f"/dev/spidev{config['bus']}.{config['device']}"
|
||||
if not os.path.exists(dev_path):
|
||||
raise ValueError(f"SPI device {dev_path} not found. Enable SPI in raspi-config.")
|
||||
else:
|
||||
try:
|
||||
import rpi_ws281x # noqa: F401
|
||||
except ImportError:
|
||||
raise ValueError("rpi_ws281x library required: pip install rpi_ws281x")
|
||||
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""Discover available SPI devices on Raspberry Pi."""
|
||||
if platform.system() != "Linux":
|
||||
return []
|
||||
|
||||
results = []
|
||||
|
||||
# Check for SPI devices
|
||||
import os
|
||||
|
||||
for bus in range(2):
|
||||
for device in range(2):
|
||||
path = f"/dev/spidev{bus}.{device}"
|
||||
if os.path.exists(path):
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"SPI Bus {bus} Device {device}",
|
||||
url=f"spi://spidev:{bus}.{device}",
|
||||
device_type="spi",
|
||||
ip="",
|
||||
mac="",
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
|
||||
# Check for GPIO availability
|
||||
try:
|
||||
import rpi_ws281x # noqa: F401
|
||||
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name="GPIO 18 (PWM0)",
|
||||
url="spi://gpio:18",
|
||||
device_type="spi",
|
||||
ip="",
|
||||
mac="",
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name="GPIO 10 (SPI MOSI)",
|
||||
url="spi://gpio:10",
|
||||
device_type="spi",
|
||||
ip="",
|
||||
mac="",
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
return True
|
||||
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
"""Power off = send all-black frame."""
|
||||
pass # Handled at target processor level
|
||||
178
server/src/wled_controller/core/devices/usbhid_client.py
Normal file
178
server/src/wled_controller/core/devices/usbhid_client.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""USB HID LED client — controls RGB peripherals via HID protocol using hidapi."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _parse_hid_url(url: str) -> Tuple[int, int]:
|
||||
"""Parse 'hid://vendor_id:product_id' to (vid, pid) ints."""
|
||||
path = url.replace("hid://", "")
|
||||
parts = path.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Invalid HID URL: {url}. Expected format: hid://VID:PID (hex)")
|
||||
return int(parts[0], 16), int(parts[1], 16)
|
||||
|
||||
|
||||
class USBHIDClient(LEDClient):
|
||||
"""LED client that controls RGB peripherals via USB HID reports.
|
||||
|
||||
Uses the hidapi library to send raw HID reports containing LED color data.
|
||||
The report format is device-specific but follows a common pattern:
|
||||
[REPORT_ID][MODE][LED_INDEX][R][G][B]...
|
||||
|
||||
For generic HID RGB devices, a bulk-set report is used:
|
||||
[REPORT_ID=0x00][CMD=0x0E][ZONE_COUNT][R G B R G B ...]
|
||||
"""
|
||||
|
||||
# Common HID report IDs and commands
|
||||
REPORT_ID_LED = 0x00
|
||||
CMD_SET_LEDS = 0x0E
|
||||
MAX_REPORT_SIZE = 64 # typical HID report size
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "",
|
||||
led_count: int = 0,
|
||||
hid_usage_page: int = 0,
|
||||
**kwargs,
|
||||
):
|
||||
self._vid, self._pid = _parse_hid_url(url)
|
||||
self._led_count = led_count
|
||||
self._usage_page = hid_usage_page
|
||||
self._device = None
|
||||
self._connected = False
|
||||
|
||||
async def connect(self) -> bool:
|
||||
try:
|
||||
import hid
|
||||
except ImportError:
|
||||
raise RuntimeError("hidapi is required for USB HID devices: pip install hidapi")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _open():
|
||||
device = hid.device()
|
||||
device.open(self._vid, self._pid)
|
||||
device.set_nonblocking(True)
|
||||
return device
|
||||
|
||||
self._device = await loop.run_in_executor(None, _open)
|
||||
self._connected = True
|
||||
|
||||
manufacturer = self._device.get_manufacturer_string() or "Unknown"
|
||||
product = self._device.get_product_string() or "Unknown"
|
||||
logger.info(
|
||||
"USB HID client connected: %04X:%04X (%s %s) leds=%d",
|
||||
self._vid, self._pid, manufacturer, product, self._led_count,
|
||||
)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._device:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._device.close)
|
||||
self._device = None
|
||||
self._connected = False
|
||||
logger.info("USB HID client closed: %04X:%04X", self._vid, self._pid)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._device is not None
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
if not self.is_connected:
|
||||
return False
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_list = pixels.tolist()
|
||||
else:
|
||||
pixel_list = list(pixels)
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
|
||||
# Build HID reports — split across multiple reports if needed
|
||||
# Each report: [REPORT_ID][CMD][OFFSET_LO][OFFSET_HI][COUNT][R G B R G B ...]
|
||||
max_leds_per_report = (self.MAX_REPORT_SIZE - 5) // 3
|
||||
offset = 0
|
||||
reports = []
|
||||
|
||||
while offset < len(pixel_list):
|
||||
chunk = pixel_list[offset: offset + max_leds_per_report]
|
||||
report = bytearray(self.MAX_REPORT_SIZE)
|
||||
report[0] = self.REPORT_ID_LED
|
||||
report[1] = self.CMD_SET_LEDS
|
||||
report[2] = offset & 0xFF
|
||||
report[3] = (offset >> 8) & 0xFF
|
||||
report[4] = len(chunk)
|
||||
|
||||
for i, (r, g, b) in enumerate(chunk):
|
||||
base = 5 + i * 3
|
||||
report[base] = int(r)
|
||||
report[base + 1] = int(g)
|
||||
report[base + 2] = int(b)
|
||||
|
||||
reports.append(bytes(report))
|
||||
offset += len(chunk)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _send_all():
|
||||
for report in reports:
|
||||
self._device.write(report)
|
||||
|
||||
try:
|
||||
await loop.run_in_executor(None, _send_all)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("USB HID send failed: %s", e)
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check if the HID device is present."""
|
||||
try:
|
||||
import hid
|
||||
|
||||
vid, pid = _parse_hid_url(url)
|
||||
devices = hid.enumerate(vid, pid)
|
||||
if devices:
|
||||
info = devices[0]
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
latency_ms=0.0,
|
||||
device_name=info.get("product_string", "USB HID Device"),
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
error=f"HID device {vid:04X}:{pid:04X} not found",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
except ImportError:
|
||||
return DeviceHealth(
|
||||
online=False, error="hidapi not installed",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
except Exception as e:
|
||||
return DeviceHealth(
|
||||
online=False, error=str(e),
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
103
server/src/wled_controller/core/devices/usbhid_provider.py
Normal file
103
server/src/wled_controller/core/devices/usbhid_provider.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""USB HID LED device provider — control RGB peripherals via USB HID."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.core.devices.usbhid_client import USBHIDClient, _parse_hid_url
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Known RGB peripheral vendor IDs and names
|
||||
KNOWN_RGB_VENDORS = {
|
||||
0x1532: "Razer",
|
||||
0x1B1C: "Corsair",
|
||||
0x1038: "SteelSeries",
|
||||
0x046D: "Logitech",
|
||||
0x2516: "Cooler Master",
|
||||
0x0951: "HyperX",
|
||||
0x3633: "Glorious",
|
||||
0x320F: "NZXT",
|
||||
}
|
||||
|
||||
|
||||
class USBHIDDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for USB HID RGB peripheral devices.
|
||||
|
||||
URL format: hid://VID:PID (hex, e.g. hid://1532:0084)
|
||||
LED count = number of addressable zones/keys on the device.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "usbhid"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {"manual_led_count", "health_check"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
return USBHIDClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
hid_usage_page=kwargs.get("hid_usage_page", 0),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await USBHIDClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate HID device exists. LED count is manual."""
|
||||
try:
|
||||
import hid
|
||||
|
||||
vid, pid = _parse_hid_url(url)
|
||||
devices = hid.enumerate(vid, pid)
|
||||
if not devices:
|
||||
raise ValueError(f"No HID device found with VID:PID {vid:04X}:{pid:04X}")
|
||||
return {}
|
||||
except ImportError:
|
||||
raise ValueError("hidapi is required for USB HID devices: pip install hidapi")
|
||||
except ValueError:
|
||||
raise
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""Discover connected USB HID devices with known RGB vendor IDs."""
|
||||
try:
|
||||
import hid
|
||||
|
||||
results = []
|
||||
seen = set()
|
||||
for info in hid.enumerate():
|
||||
vid = info.get("vendor_id", 0)
|
||||
pid = info.get("product_id", 0)
|
||||
key = (vid, pid)
|
||||
if key in seen or vid == 0:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
vendor = KNOWN_RGB_VENDORS.get(vid)
|
||||
if not vendor:
|
||||
continue
|
||||
|
||||
product = info.get("product_string", "Unknown Device")
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"{vendor} {product}",
|
||||
url=f"hid://{vid:04x}:{pid:04x}",
|
||||
device_type="usbhid",
|
||||
ip="",
|
||||
mac="",
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
return results
|
||||
except ImportError:
|
||||
return []
|
||||
@@ -378,9 +378,7 @@ class WLEDClient(LEDClient):
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
if brightness < 255:
|
||||
pixels = (pixels.astype(np.uint16) * brightness >> 8).astype(np.uint8)
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
logger.debug(f"Sending {len(pixels)} LEDs via DDP")
|
||||
self._ddp_client.send_pixels_numpy(pixels)
|
||||
logger.debug(f"Successfully sent pixel colors via DDP")
|
||||
@@ -419,7 +417,7 @@ class WLEDClient(LEDClient):
|
||||
# Build WLED JSON state
|
||||
payload = {
|
||||
"on": True,
|
||||
"bri": int(brightness),
|
||||
"bri": 255, # brightness already applied by processor loop
|
||||
"seg": [
|
||||
{
|
||||
"id": segment_id,
|
||||
@@ -461,9 +459,7 @@ class WLEDClient(LEDClient):
|
||||
else:
|
||||
pixel_array = np.array(pixels, dtype=np.uint8)
|
||||
|
||||
if brightness < 255:
|
||||
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
self._ddp_client.send_pixels_numpy(pixel_array)
|
||||
|
||||
# ===== LEDClient abstraction methods =====
|
||||
|
||||
@@ -53,11 +53,10 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
from wled_controller.core.devices.wled_client import WLEDClient
|
||||
kwargs.pop("led_count", None)
|
||||
kwargs.pop("baud_rate", None)
|
||||
kwargs.pop("send_latency_ms", None)
|
||||
kwargs.pop("rgbw", None)
|
||||
return WLEDClient(url, **kwargs)
|
||||
return WLEDClient(
|
||||
url,
|
||||
use_ddp=kwargs.get("use_ddp", False),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
from wled_controller.core.devices.wled_client import WLEDClient
|
||||
|
||||
@@ -27,7 +27,10 @@ class WSDeviceProvider(LEDDeviceProvider):
|
||||
return {"manual_led_count"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
return WSLEDClient(url, **kwargs)
|
||||
return WSLEDClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
)
|
||||
|
||||
async def check_health(
|
||||
self, url: str, http_client, prev_health=None,
|
||||
|
||||
@@ -20,8 +20,10 @@ import wled_controller.core.filters.flip # noqa: F401
|
||||
import wled_controller.core.filters.color_correction # noqa: F401
|
||||
import wled_controller.core.filters.frame_interpolation # noqa: F401
|
||||
import wled_controller.core.filters.filter_template # noqa: F401
|
||||
import wled_controller.core.filters.css_filter_template # noqa: F401
|
||||
import wled_controller.core.filters.noise_gate # noqa: F401
|
||||
import wled_controller.core.filters.palette_quantization # noqa: F401
|
||||
import wled_controller.core.filters.reverse # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"FilterOptionDef",
|
||||
|
||||
@@ -18,6 +18,7 @@ class AutoCropFilter(PostprocessingFilter):
|
||||
|
||||
filter_id = "auto_crop"
|
||||
filter_name = "Auto Crop"
|
||||
supports_strip = False
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
|
||||
@@ -44,11 +44,21 @@ class PostprocessingFilter(ABC):
|
||||
Each filter operates on a full image (np.ndarray H×W×3 uint8).
|
||||
Filters that preserve dimensions modify in-place and return None.
|
||||
Filters that change dimensions return a new array from the image pool.
|
||||
|
||||
Filters that also support 1D LED strip arrays (N×3 uint8) should
|
||||
leave ``supports_strip = True`` (the default). The base class
|
||||
provides a generic ``process_strip`` that reshapes (N,3) → (1,N,3),
|
||||
delegates to ``process_image``, and reshapes back. Subclasses may
|
||||
override for a more efficient implementation.
|
||||
|
||||
Filters that are purely spatial (auto-crop, downscaler, flip) should
|
||||
set ``supports_strip = False``.
|
||||
"""
|
||||
|
||||
filter_id: str = ""
|
||||
filter_name: str = ""
|
||||
supports_idle_frames: bool = False
|
||||
supports_strip: bool = True
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
"""Initialize filter with validated options."""
|
||||
@@ -74,6 +84,31 @@ class PostprocessingFilter(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""Process a 1D LED strip array (N, 3) uint8.
|
||||
|
||||
Default implementation reshapes to (1, N, 3), calls process_image
|
||||
with a no-op pool, and reshapes back. Override for filters that
|
||||
need strip-specific behaviour or use ImagePool.
|
||||
|
||||
Returns:
|
||||
None if modified in-place.
|
||||
New np.ndarray if a new array was created.
|
||||
"""
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
|
||||
img = strip[np.newaxis, :, :] # (1, N, 3)
|
||||
pool = ImagePool(max_size=2)
|
||||
result = self.process_image(img, pool)
|
||||
if result is not None:
|
||||
out = result[0] # (N, 3)
|
||||
pool.release_all()
|
||||
return out
|
||||
# Modified in-place — extract back
|
||||
np.copyto(strip, img[0])
|
||||
pool.release_all()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def validate_options(cls, options: dict) -> dict:
|
||||
"""Validate and clamp options against the schema. Returns cleaned dict."""
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""CSS Filter Template meta-filter — references a color strip processing template.
|
||||
|
||||
This filter exists in the registry for UI discovery only. It is never
|
||||
instantiated at runtime: the store expands it into the referenced
|
||||
template's filters when building the processing pipeline.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class CSSFilterTemplateFilter(PostprocessingFilter):
|
||||
"""Include another color strip processing template's chain at this position."""
|
||||
|
||||
filter_id = "css_filter_template"
|
||||
filter_name = "Strip Filter Template"
|
||||
supports_strip = True
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="template_id",
|
||||
label="Template",
|
||||
option_type="select",
|
||||
default="",
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
choices=[], # populated dynamically by GET /api/v1/strip-filters
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
# Never called — expanded at pipeline build time.
|
||||
return None
|
||||
|
||||
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
|
||||
# Never called — expanded at pipeline build time.
|
||||
return None
|
||||
@@ -16,6 +16,7 @@ class DownscalerFilter(PostprocessingFilter):
|
||||
|
||||
filter_id = "downscaler"
|
||||
filter_name = "Downscaler"
|
||||
supports_strip = False
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
|
||||
@@ -20,6 +20,7 @@ class FilterTemplateFilter(PostprocessingFilter):
|
||||
|
||||
filter_id = "filter_template"
|
||||
filter_name = "Filter Template"
|
||||
supports_strip = False
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
|
||||
@@ -15,6 +15,7 @@ class FlipFilter(PostprocessingFilter):
|
||||
|
||||
filter_id = "flip"
|
||||
filter_name = "Flip"
|
||||
supports_strip = False
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
|
||||
@@ -59,15 +59,23 @@ class FrameInterpolationFilter(PostprocessingFilter):
|
||||
None — image passes through unchanged (no blend needed).
|
||||
ndarray — blended output acquired from image_pool.
|
||||
"""
|
||||
return self._blend(image, lambda shape: image_pool.acquire(*shape))
|
||||
|
||||
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""Frame interpolation for 1D LED strips — allocates directly."""
|
||||
return self._blend(strip, lambda shape: np.empty(shape, dtype=np.uint8))
|
||||
|
||||
def _blend(self, data: np.ndarray, alloc_fn) -> Optional[np.ndarray]:
|
||||
"""Shared blend logic for both images and strips."""
|
||||
now = time.perf_counter()
|
||||
|
||||
# Detect new vs idle frame via cheap 64-byte signature
|
||||
sig = bytes(image.ravel()[:64])
|
||||
sig = bytes(data.ravel()[:64])
|
||||
if sig != self._sig_b:
|
||||
# New source frame — shift A ← B, B ← current
|
||||
self._frame_a = self._frame_b
|
||||
self._time_a = self._time_b
|
||||
self._frame_b = image.copy()
|
||||
self._frame_b = data.copy()
|
||||
self._time_b = now
|
||||
self._sig_b = sig
|
||||
|
||||
@@ -83,8 +91,7 @@ class FrameInterpolationFilter(PostprocessingFilter):
|
||||
|
||||
# Blend: output = (1 - alpha)*A + alpha*B (integer fast path)
|
||||
alpha_i = int(alpha * 256)
|
||||
h, w, c = image.shape
|
||||
shape = (h, w, c)
|
||||
shape = data.shape
|
||||
|
||||
# Resize scratch buffers on shape change
|
||||
if self._blend_shape != shape:
|
||||
@@ -92,9 +99,9 @@ class FrameInterpolationFilter(PostprocessingFilter):
|
||||
self._u16_b = np.empty(shape, dtype=np.uint16)
|
||||
self._blend_shape = shape
|
||||
|
||||
out = image_pool.acquire(h, w, c)
|
||||
out = alloc_fn(shape)
|
||||
np.copyto(self._u16_a, self._frame_a, casting='unsafe')
|
||||
np.copyto(self._u16_b, image, casting='unsafe')
|
||||
np.copyto(self._u16_b, data, casting='unsafe')
|
||||
self._u16_a *= (256 - alpha_i)
|
||||
self._u16_b *= alpha_i
|
||||
self._u16_a += self._u16_b
|
||||
|
||||
@@ -83,7 +83,7 @@ class PaletteQuantizationFilter(PostprocessingFilter):
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
choices=[{"value": k, "label": k.capitalize()} for k in _PRESETS],
|
||||
choices=[{"value": k, "label": k.capitalize(), "colors": v} for k, v in _PRESETS.items()],
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="colors",
|
||||
|
||||
30
server/src/wled_controller/core/filters/reverse.py
Normal file
30
server/src/wled_controller/core/filters/reverse.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Reverse filter — reverses the LED order in a 1D strip."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class ReverseFilter(PostprocessingFilter):
|
||||
"""Reverses the order of LEDs in a color strip."""
|
||||
|
||||
filter_id = "reverse"
|
||||
filter_name = "Reverse"
|
||||
supports_strip = True
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return []
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
"""Reverse image horizontally (for 2D fallback)."""
|
||||
return image[:, ::-1].copy()
|
||||
|
||||
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""Reverse the LED array order."""
|
||||
return strip[::-1].copy()
|
||||
@@ -4,9 +4,9 @@ External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
|
||||
buffers the latest frame and serves it to targets. When no data has been
|
||||
received within `timeout` seconds, LEDs revert to `fallback_color`.
|
||||
|
||||
Thread-safe: push_colors() can be called from any thread (REST handler,
|
||||
WebSocket handler) while get_latest_colors() is called from the target
|
||||
processor thread.
|
||||
Thread-safe: push_colors() / push_segments() can be called from any thread
|
||||
(REST handler, WebSocket handler) while get_latest_colors() is called from
|
||||
the target processor thread.
|
||||
"""
|
||||
|
||||
import threading
|
||||
@@ -20,13 +20,16 @@ from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_DEFAULT_LED_COUNT = 150
|
||||
|
||||
|
||||
class ApiInputColorStripStream(ColorStripStream):
|
||||
"""Color strip stream backed by externally-pushed LED color data.
|
||||
|
||||
Holds a thread-safe np.ndarray buffer. External clients push colors via
|
||||
push_colors(). A background thread checks for timeout and reverts to
|
||||
fallback_color when no data arrives within the configured timeout window.
|
||||
push_colors() or push_segments(). A background thread checks for timeout
|
||||
and reverts to fallback_color when no data arrives within the configured
|
||||
timeout window.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
@@ -43,14 +46,14 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
fallback = source.fallback_color
|
||||
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
||||
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
||||
self._auto_size = not source.led_count
|
||||
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
||||
self._led_count = _DEFAULT_LED_COUNT
|
||||
|
||||
# Build initial fallback buffer
|
||||
self._fallback_array = self._build_fallback(self._led_count)
|
||||
self._colors = self._fallback_array.copy()
|
||||
self._last_push_time: float = 0.0
|
||||
self._timed_out = True # Start in timed-out state
|
||||
self._push_generation: int = 0 # Incremented on each push; used by test WS
|
||||
|
||||
def _build_fallback(self, led_count: int) -> np.ndarray:
|
||||
"""Build a (led_count, 3) uint8 array filled with fallback_color."""
|
||||
@@ -59,40 +62,128 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
(led_count, 1),
|
||||
)
|
||||
|
||||
def _ensure_capacity(self, required: int) -> None:
|
||||
"""Grow the buffer to at least `required` LEDs (must be called under lock)."""
|
||||
if required > self._led_count:
|
||||
self._led_count = required
|
||||
self._fallback_array = self._build_fallback(self._led_count)
|
||||
# Preserve existing data if not timed out
|
||||
if not self._timed_out:
|
||||
new_buf = self._fallback_array.copy()
|
||||
old_len = min(len(self._colors), required)
|
||||
new_buf[:old_len] = self._colors[:old_len]
|
||||
self._colors = new_buf
|
||||
else:
|
||||
self._colors = self._fallback_array.copy()
|
||||
logger.debug(f"ApiInputColorStripStream buffer grown to {required} LEDs")
|
||||
|
||||
def push_colors(self, colors: np.ndarray) -> None:
|
||||
"""Push a new frame of LED colors.
|
||||
|
||||
Thread-safe. The array is truncated or zero-padded to match led_count.
|
||||
Thread-safe. Auto-grows the buffer if the incoming array is larger
|
||||
than the current buffer; otherwise truncates or zero-pads.
|
||||
|
||||
Args:
|
||||
colors: np.ndarray shape (N, 3) uint8
|
||||
"""
|
||||
with self._lock:
|
||||
n = len(colors)
|
||||
# Auto-grow if incoming data is larger
|
||||
if n > self._led_count:
|
||||
self._ensure_capacity(n)
|
||||
if n == self._led_count:
|
||||
self._colors = colors.astype(np.uint8)
|
||||
elif n > self._led_count:
|
||||
self._colors = colors[:self._led_count].astype(np.uint8)
|
||||
if self._colors.shape == colors.shape:
|
||||
np.copyto(self._colors, colors, casting='unsafe')
|
||||
else:
|
||||
self._colors = np.empty((n, 3), dtype=np.uint8)
|
||||
np.copyto(self._colors, colors, casting='unsafe')
|
||||
elif n < self._led_count:
|
||||
# Zero-pad to led_count
|
||||
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
|
||||
padded[:n] = colors[:n]
|
||||
self._colors = padded
|
||||
self._last_push_time = time.monotonic()
|
||||
self._push_generation += 1
|
||||
self._timed_out = False
|
||||
|
||||
def push_segments(self, segments: list) -> None:
|
||||
"""Apply segment-based color updates to the buffer.
|
||||
|
||||
Each segment defines a range and fill mode. Segments are applied in
|
||||
order (last wins on overlap). The buffer is auto-grown if needed.
|
||||
|
||||
Args:
|
||||
segments: list of dicts with keys:
|
||||
start (int) – starting LED index
|
||||
length (int) – number of LEDs in segment
|
||||
mode (str) – "solid" | "per_pixel" | "gradient"
|
||||
color (list) – [R,G,B] for solid mode
|
||||
colors (list) – [[R,G,B], ...] for per_pixel/gradient
|
||||
"""
|
||||
# Compute required buffer size from all segments
|
||||
max_index = max(seg["start"] + seg["length"] for seg in segments)
|
||||
|
||||
with self._lock:
|
||||
# Auto-grow buffer if needed
|
||||
if max_index > self._led_count:
|
||||
self._ensure_capacity(max_index)
|
||||
|
||||
# Start from current buffer (or fallback if timed out)
|
||||
if self._timed_out:
|
||||
buf = self._fallback_array.copy()
|
||||
else:
|
||||
buf = self._colors.copy()
|
||||
|
||||
for seg in segments:
|
||||
start = seg["start"]
|
||||
length = seg["length"]
|
||||
mode = seg["mode"]
|
||||
end = start + length
|
||||
|
||||
if mode == "solid":
|
||||
color = np.array(seg["color"], dtype=np.uint8)
|
||||
buf[start:end] = color
|
||||
|
||||
elif mode == "per_pixel":
|
||||
colors = np.array(seg["colors"], dtype=np.uint8)
|
||||
available = len(colors)
|
||||
if available >= length:
|
||||
buf[start:end] = colors[:length]
|
||||
else:
|
||||
# Pad with zeros if fewer colors than length
|
||||
buf[start:start + available] = colors
|
||||
buf[start + available:end] = 0
|
||||
|
||||
elif mode == "gradient":
|
||||
stops = np.array(seg["colors"], dtype=np.float32)
|
||||
num_stops = len(stops)
|
||||
# Positions of stops evenly spaced 0..length-1
|
||||
stop_positions = np.linspace(0, length - 1, num_stops)
|
||||
pixel_positions = np.arange(length, dtype=np.float32)
|
||||
for ch in range(3):
|
||||
buf[start:end, ch] = np.interp(
|
||||
pixel_positions,
|
||||
stop_positions,
|
||||
stops[:, ch],
|
||||
).astype(np.uint8)
|
||||
|
||||
self._colors = buf
|
||||
self._last_push_time = time.monotonic()
|
||||
self._push_generation += 1
|
||||
self._timed_out = False
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
"""Set LED count from the target device (called on target start).
|
||||
|
||||
Only takes effect when led_count was 0 (auto-size).
|
||||
Always resizes the buffer to the device LED count.
|
||||
"""
|
||||
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
|
||||
if device_led_count > 0 and device_led_count != self._led_count:
|
||||
with self._lock:
|
||||
self._led_count = device_led_count
|
||||
self._fallback_array = self._build_fallback(device_led_count)
|
||||
self._colors = self._fallback_array.copy()
|
||||
self._timed_out = True
|
||||
logger.debug(f"ApiInputColorStripStream auto-sized to {device_led_count} LEDs")
|
||||
logger.debug(f"ApiInputColorStripStream configured to {device_led_count} LEDs")
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
@@ -131,6 +222,11 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
with self._lock:
|
||||
return self._colors
|
||||
|
||||
@property
|
||||
def push_generation(self) -> int:
|
||||
"""Monotonically increasing counter, bumped on each push_colors/push_segments."""
|
||||
return self._push_generation
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
"""Hot-update fallback_color and timeout from updated source config."""
|
||||
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
|
||||
@@ -138,15 +234,6 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
fallback = source.fallback_color
|
||||
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
||||
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
||||
prev_led_count = self._led_count if self._auto_size else None
|
||||
self._auto_size = not source.led_count
|
||||
with self._lock:
|
||||
self._fallback_array = self._build_fallback(self._led_count)
|
||||
if self._timed_out:
|
||||
self._colors = self._fallback_array.copy()
|
||||
# Preserve runtime LED count across updates if auto-sized
|
||||
if prev_led_count and self._auto_size:
|
||||
self._led_count = prev_led_count
|
||||
with self._lock:
|
||||
self._fallback_array = self._build_fallback(self._led_count)
|
||||
if self._timed_out:
|
||||
|
||||
@@ -251,6 +251,7 @@ class AudioColorStripStream(ColorStripStream):
|
||||
_full_amp = np.empty(n, dtype=np.float32)
|
||||
_vu_gradient = np.linspace(0, 1, n, dtype=np.float32)
|
||||
_indices_buf = np.empty(n, dtype=np.int32)
|
||||
_f32_rgb = np.empty((n, 3), dtype=np.float32)
|
||||
self._prev_spectrum = None # reset smoothing on resize
|
||||
|
||||
# Make pre-computed arrays available to render methods
|
||||
@@ -260,6 +261,7 @@ class AudioColorStripStream(ColorStripStream):
|
||||
self._full_amp = _full_amp
|
||||
self._vu_gradient = _vu_gradient
|
||||
self._indices_buf = _indices_buf
|
||||
self._f32_rgb = _f32_rgb
|
||||
|
||||
buf = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
@@ -352,8 +354,11 @@ class AudioColorStripStream(ColorStripStream):
|
||||
|
||||
# Scale brightness by amplitude — restore full_amp to [0, 1]
|
||||
full_amp *= (1.0 / 255.0)
|
||||
for ch in range(3):
|
||||
buf[:, ch] = (colors[:, ch].astype(np.float32) * full_amp).astype(np.uint8)
|
||||
f32_rgb = self._f32_rgb
|
||||
np.copyto(f32_rgb, colors, casting='unsafe')
|
||||
f32_rgb *= full_amp[:, np.newaxis]
|
||||
np.clip(f32_rgb, 0, 255, out=f32_rgb)
|
||||
np.copyto(buf, f32_rgb, casting='unsafe')
|
||||
|
||||
# ── VU Meter ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -189,6 +189,8 @@ class CandlelightColorStripStream(ColorStripStream):
|
||||
- Per-LED noise adds individual variation
|
||||
- Final brightness modulates the base warm color
|
||||
"""
|
||||
# Scale speed so that speed=1 gives a gentle ~1.3 Hz dominant flicker
|
||||
speed = speed * 0.35
|
||||
intensity = self._intensity
|
||||
num_candles = self._num_candles
|
||||
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
|
||||
|
||||
@@ -6,7 +6,7 @@ by processing frames from a LiveStream.
|
||||
|
||||
Multiple WledTargetProcessors may read from the same ColorStripStream instance
|
||||
(shared via ColorStripStreamManager reference counting), meaning the CPU-bound
|
||||
processing — border extraction, pixel mapping, color correction — runs only once
|
||||
processing — border extraction, pixel mapping, smoothing — runs only once
|
||||
even when multiple devices share the same source configuration.
|
||||
"""
|
||||
|
||||
@@ -28,54 +28,6 @@ from wled_controller.utils.timer import high_resolution_timer
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _apply_saturation(colors: np.ndarray, saturation: float,
|
||||
_i32: np.ndarray = None, _i32_gray: np.ndarray = None,
|
||||
_out: np.ndarray = None) -> np.ndarray:
|
||||
"""Adjust saturation via luminance mixing (Rec.601 weights, integer math).
|
||||
|
||||
saturation=1.0: no change
|
||||
saturation=0.0: grayscale
|
||||
saturation=2.0: double saturation (clipped to 0-255)
|
||||
|
||||
Optional pre-allocated scratch buffers (_i32, _i32_gray, _out) avoid
|
||||
per-frame allocations when called from a hot loop.
|
||||
"""
|
||||
n = len(colors)
|
||||
if _i32 is None:
|
||||
_i32 = np.empty((n, 3), dtype=np.int32)
|
||||
if _i32_gray is None:
|
||||
_i32_gray = np.empty((n, 1), dtype=np.int32)
|
||||
if _out is None:
|
||||
_out = np.empty((n, 3), dtype=np.uint8)
|
||||
|
||||
sat_int = int(saturation * 256)
|
||||
np.copyto(_i32, colors, casting='unsafe')
|
||||
_i32_gray[:, 0] = (_i32[:, 0] * 299 + _i32[:, 1] * 587 + _i32[:, 2] * 114) // 1000
|
||||
_i32 *= sat_int
|
||||
_i32_gray *= (256 - sat_int)
|
||||
_i32 += _i32_gray
|
||||
_i32 >>= 8
|
||||
np.clip(_i32, 0, 255, out=_i32)
|
||||
np.copyto(_out, _i32, casting='unsafe')
|
||||
return _out
|
||||
|
||||
|
||||
def _build_gamma_lut(gamma: float) -> np.ndarray:
|
||||
"""Build a 256-entry uint8 LUT for gamma correction.
|
||||
|
||||
gamma=1.0: identity (no correction)
|
||||
gamma<1.0: brighter midtones (gamma < 1 lifts shadows)
|
||||
gamma>1.0: darker midtones (standard LED gamma, e.g. 2.2–2.8)
|
||||
"""
|
||||
if gamma == 1.0:
|
||||
return np.arange(256, dtype=np.uint8)
|
||||
lut = np.array(
|
||||
[min(255, int(((i / 255.0) ** gamma) * 255 + 0.5)) for i in range(256)],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
return lut
|
||||
|
||||
|
||||
class ColorStripStream(ABC):
|
||||
"""Abstract base: a runtime source of LED color arrays.
|
||||
|
||||
@@ -142,10 +94,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
2. Extracts border pixels using the calibration's border_width
|
||||
3. Maps border pixels to LED colors via PixelMapper
|
||||
4. Applies temporal smoothing
|
||||
5. Applies saturation correction
|
||||
6. Applies gamma correction (LUT-based, O(1) per pixel)
|
||||
7. Applies brightness scaling
|
||||
8. Caches the result for lock-free consumer reads
|
||||
5. Caches the result for lock-free consumer reads
|
||||
|
||||
Processing parameters can be hot-updated via update_source() without
|
||||
restarting the thread (except when the underlying LiveStream changes).
|
||||
@@ -167,11 +116,10 @@ class PictureColorStripStream(ColorStripStream):
|
||||
else:
|
||||
self._live_streams = {}
|
||||
self._live_stream = live_stream
|
||||
|
||||
self._fps: int = 30 # internal capture rate (send FPS is on the target)
|
||||
self._frame_time: float = 1.0 / 30
|
||||
self._smoothing: float = source.smoothing
|
||||
self._brightness: float = source.brightness
|
||||
self._saturation: float = source.saturation
|
||||
self._gamma: float = source.gamma
|
||||
self._interpolation_mode: str = source.interpolation_mode
|
||||
self._calibration: CalibrationConfig = source.calibration
|
||||
self._pixel_mapper = create_pixel_mapper(
|
||||
@@ -179,25 +127,21 @@ class PictureColorStripStream(ColorStripStream):
|
||||
)
|
||||
cal_leds = self._calibration.get_total_leds()
|
||||
self._led_count: int = source.led_count if source.led_count > 0 else cal_leds
|
||||
self._gamma_lut: np.ndarray = _build_gamma_lut(self._gamma)
|
||||
|
||||
# Thread-safe color cache
|
||||
self._latest_colors: Optional[np.ndarray] = None
|
||||
self._colors_lock = threading.Lock()
|
||||
self._previous_colors: Optional[np.ndarray] = None
|
||||
|
||||
# Frame interpolation state
|
||||
self._frame_interpolation: bool = source.frame_interpolation
|
||||
self._interp_from: Optional[np.ndarray] = None
|
||||
self._interp_to: Optional[np.ndarray] = None
|
||||
self._interp_start: float = 0.0
|
||||
self._interp_duration: float = 1.0 / self._fps if self._fps > 0 else 1.0
|
||||
self._last_capture_time: float = 0.0
|
||||
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._last_timing: dict = {}
|
||||
|
||||
@property
|
||||
def live_stream(self):
|
||||
"""Public accessor for the underlying LiveStream (used by preview WebSocket)."""
|
||||
return self._live_stream
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
return self._fps
|
||||
@@ -239,9 +183,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
self._thread = None
|
||||
self._latest_colors = None
|
||||
self._previous_colors = None
|
||||
self._interp_from = None
|
||||
self._interp_to = None
|
||||
self._last_capture_time = 0.0
|
||||
logger.info("PictureColorStripStream stopped")
|
||||
|
||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||
@@ -256,7 +197,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
fps = max(1, min(90, fps))
|
||||
if fps != self._fps:
|
||||
self._fps = fps
|
||||
self._interp_duration = 1.0 / fps
|
||||
self._frame_time = 1.0 / fps
|
||||
logger.info(f"PictureColorStripStream capture FPS set to {fps}")
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
@@ -270,12 +211,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
return
|
||||
|
||||
self._smoothing = source.smoothing
|
||||
self._brightness = source.brightness
|
||||
self._saturation = source.saturation
|
||||
|
||||
if source.gamma != self._gamma:
|
||||
self._gamma = source.gamma
|
||||
self._gamma_lut = _build_gamma_lut(source.gamma)
|
||||
|
||||
if (
|
||||
source.interpolation_mode != self._interpolation_mode
|
||||
@@ -291,11 +226,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
)
|
||||
self._previous_colors = None # Reset smoothing history on calibration change
|
||||
|
||||
if source.frame_interpolation != self._frame_interpolation:
|
||||
self._frame_interpolation = source.frame_interpolation
|
||||
self._interp_from = None
|
||||
self._interp_to = None
|
||||
|
||||
logger.info("PictureColorStripStream params updated in-place")
|
||||
|
||||
def _processing_loop(self) -> None:
|
||||
@@ -306,8 +236,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
_pool_n = 0
|
||||
_frame_a = _frame_b = None # double-buffered uint8 output
|
||||
_use_a = True
|
||||
_u16_a = _u16_b = None # uint16 scratch for smoothing / interp blending
|
||||
_i32 = _i32_gray = None # int32 scratch for saturation + brightness
|
||||
_u16_a = _u16_b = None # uint16 scratch for smoothing blending
|
||||
|
||||
def _blend_u16(a, b, alpha_b, out):
|
||||
"""Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8.
|
||||
@@ -323,62 +252,20 @@ class PictureColorStripStream(ColorStripStream):
|
||||
_u16_a >>= 8
|
||||
np.copyto(out, _u16_a, casting='unsafe')
|
||||
|
||||
def _apply_corrections(led_colors, frame_buf):
|
||||
"""Apply saturation, gamma, brightness using pre-allocated scratch.
|
||||
|
||||
Returns the (possibly reassigned) led_colors array.
|
||||
"""
|
||||
nonlocal _i32
|
||||
if self._saturation != 1.0:
|
||||
_apply_saturation(led_colors, self._saturation, _i32, _i32_gray, led_colors)
|
||||
if self._gamma != 1.0:
|
||||
led_colors = self._gamma_lut[led_colors]
|
||||
if self._brightness != 1.0:
|
||||
bright_int = int(self._brightness * 256)
|
||||
np.copyto(_i32, led_colors, casting='unsafe')
|
||||
_i32 *= bright_int
|
||||
_i32 >>= 8
|
||||
np.clip(_i32, 0, 255, out=_i32)
|
||||
np.copyto(frame_buf, _i32, casting='unsafe')
|
||||
led_colors = frame_buf
|
||||
return led_colors
|
||||
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
fps = self._fps
|
||||
frame_time = 1.0 / fps if fps > 0 else 1.0
|
||||
frame_time = self._frame_time
|
||||
|
||||
try:
|
||||
frame = self._live_stream.get_latest_frame()
|
||||
|
||||
if frame is None or frame is cached_frame:
|
||||
if (
|
||||
frame is not None
|
||||
and self._frame_interpolation
|
||||
and self._interp_from is not None
|
||||
and self._interp_to is not None
|
||||
and _u16_a is not None
|
||||
):
|
||||
# Interpolate between previous and current capture
|
||||
t = min(1.0, (loop_start - self._interp_start) / self._interp_duration)
|
||||
frame_buf = _frame_a if _use_a else _frame_b
|
||||
_use_a = not _use_a
|
||||
_blend_u16(self._interp_from, self._interp_to, int(t * 256), frame_buf)
|
||||
led_colors = _apply_corrections(frame_buf, frame_buf)
|
||||
with self._colors_lock:
|
||||
self._latest_colors = led_colors
|
||||
elapsed = time.perf_counter() - loop_start
|
||||
time.sleep(max(frame_time - elapsed, 0.001))
|
||||
continue
|
||||
|
||||
interval = (
|
||||
loop_start - self._last_capture_time
|
||||
if self._last_capture_time > 0
|
||||
else frame_time
|
||||
)
|
||||
self._last_capture_time = loop_start
|
||||
cached_frame = frame
|
||||
|
||||
t0 = time.perf_counter()
|
||||
@@ -410,8 +297,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
_frame_b = np.empty((_n, 3), dtype=np.uint8)
|
||||
_u16_a = np.empty((_n, 3), dtype=np.uint16)
|
||||
_u16_b = np.empty((_n, 3), dtype=np.uint16)
|
||||
_i32 = np.empty((_n, 3), dtype=np.int32)
|
||||
_i32_gray = np.empty((_n, 1), dtype=np.int32)
|
||||
self._previous_colors = None
|
||||
|
||||
# Copy/pad into double-buffered frame (avoids per-frame allocations)
|
||||
@@ -440,38 +325,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
int(smoothing * 256), led_colors)
|
||||
t3 = time.perf_counter()
|
||||
|
||||
# Update interpolation buffers (smoothed colors, before corrections)
|
||||
# Must be AFTER smoothing so idle-tick interpolation produces
|
||||
# output consistent with new-frame ticks (both smoothed).
|
||||
if self._frame_interpolation:
|
||||
self._interp_from = self._interp_to
|
||||
self._interp_to = led_colors.copy()
|
||||
self._interp_start = loop_start
|
||||
self._interp_duration = max(interval, 0.001)
|
||||
|
||||
# Saturation (pre-allocated int32 scratch)
|
||||
saturation = self._saturation
|
||||
if saturation != 1.0:
|
||||
_apply_saturation(led_colors, saturation, _i32, _i32_gray, led_colors)
|
||||
t4 = time.perf_counter()
|
||||
|
||||
# Gamma (LUT lookup — O(1) per pixel)
|
||||
if self._gamma != 1.0:
|
||||
led_colors = self._gamma_lut[led_colors]
|
||||
t5 = time.perf_counter()
|
||||
|
||||
# Brightness (integer math with pre-allocated int32 scratch)
|
||||
brightness = self._brightness
|
||||
if brightness != 1.0:
|
||||
bright_int = int(brightness * 256)
|
||||
np.copyto(_i32, led_colors, casting='unsafe')
|
||||
_i32 *= bright_int
|
||||
_i32 >>= 8
|
||||
np.clip(_i32, 0, 255, out=_i32)
|
||||
np.copyto(frame_buf, _i32, casting='unsafe')
|
||||
led_colors = frame_buf
|
||||
t6 = time.perf_counter()
|
||||
|
||||
self._previous_colors = led_colors
|
||||
|
||||
with self._colors_lock:
|
||||
@@ -481,10 +334,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
"extract_ms": (t1 - t0) * 1000,
|
||||
"map_leds_ms": (t2 - t1) * 1000,
|
||||
"smooth_ms": (t3 - t2) * 1000,
|
||||
"saturation_ms": (t4 - t3) * 1000,
|
||||
"gamma_ms": (t5 - t4) * 1000,
|
||||
"brightness_ms": (t6 - t5) * 1000,
|
||||
"total_ms": (t6 - t0) * 1000,
|
||||
"total_ms": (t3 - t0) * 1000,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -1201,12 +1051,52 @@ class GradientColorStripStream(ColorStripStream):
|
||||
|
||||
elif atype == "rainbow_fade":
|
||||
h_shift = (speed * t * 0.1) % 1.0
|
||||
for i in range(n):
|
||||
r, g, b = base[i]
|
||||
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
|
||||
new_h = (h + h_shift) % 1.0
|
||||
nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3))
|
||||
buf[i] = (int(nr * 255), int(ng * 255), int(nb * 255))
|
||||
# Vectorized RGB->HSV shift->RGB (no per-LED colorsys)
|
||||
rgb_f = base.astype(np.float32) * (1.0 / 255.0)
|
||||
r_f = rgb_f[:, 0]
|
||||
g_f = rgb_f[:, 1]
|
||||
b_f = rgb_f[:, 2]
|
||||
cmax = np.maximum(np.maximum(r_f, g_f), b_f)
|
||||
cmin = np.minimum(np.minimum(r_f, g_f), b_f)
|
||||
delta = cmax - cmin
|
||||
# Hue
|
||||
h_arr = np.zeros(n, dtype=np.float32)
|
||||
mask_r = (delta > 0) & (cmax == r_f)
|
||||
mask_g = (delta > 0) & (cmax == g_f) & ~mask_r
|
||||
mask_b = (delta > 0) & ~mask_r & ~mask_g
|
||||
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
|
||||
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
|
||||
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
|
||||
h_arr *= (1.0 / 6.0)
|
||||
h_arr %= 1.0
|
||||
# Saturation & Value with clamping
|
||||
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
|
||||
np.maximum(s_arr, 0.5, out=s_arr)
|
||||
v_arr = cmax.copy()
|
||||
np.maximum(v_arr, 0.3, out=v_arr)
|
||||
# Shift hue
|
||||
h_arr += h_shift
|
||||
h_arr %= 1.0
|
||||
# Vectorized HSV->RGB
|
||||
h6 = h_arr * 6.0
|
||||
hi = h6.astype(np.int32) % 6
|
||||
f_arr = h6 - np.floor(h6)
|
||||
p = v_arr * (1.0 - s_arr)
|
||||
q = v_arr * (1.0 - s_arr * f_arr)
|
||||
tt = v_arr * (1.0 - s_arr * (1.0 - f_arr))
|
||||
ro = np.empty(n, dtype=np.float32)
|
||||
go = np.empty(n, dtype=np.float32)
|
||||
bo = np.empty(n, dtype=np.float32)
|
||||
for sxt, rv, gv, bv in (
|
||||
(0, v_arr, tt, p), (1, q, v_arr, p),
|
||||
(2, p, v_arr, tt), (3, p, q, v_arr),
|
||||
(4, tt, p, v_arr), (5, v_arr, p, q),
|
||||
):
|
||||
m = hi == sxt
|
||||
ro[m] = rv[m]; go[m] = gv[m]; bo[m] = bv[m]
|
||||
buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8)
|
||||
buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8)
|
||||
buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
|
||||
colors = buf
|
||||
|
||||
if colors is not None:
|
||||
|
||||
@@ -18,6 +18,7 @@ from wled_controller.core.processing.color_strip_stream import (
|
||||
PictureColorStripStream,
|
||||
StaticColorStripStream,
|
||||
)
|
||||
from wled_controller.core.processing.processed_stream import ProcessedColorStripStream
|
||||
from wled_controller.core.processing.effect_stream import EffectColorStripStream
|
||||
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
|
||||
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
|
||||
@@ -68,7 +69,7 @@ class ColorStripStreamManager:
|
||||
keyed by ``{css_id}:{consumer_id}``.
|
||||
"""
|
||||
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None):
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None):
|
||||
"""
|
||||
Args:
|
||||
color_strip_store: ColorStripStore for resolving source configs
|
||||
@@ -76,6 +77,8 @@ class ColorStripStreamManager:
|
||||
audio_capture_manager: AudioCaptureManager for audio-reactive sources
|
||||
audio_source_store: AudioSourceStore for resolving audio source chains
|
||||
sync_clock_manager: SyncClockManager for acquiring clock runtimes
|
||||
value_stream_manager: ValueStreamManager for per-layer brightness sources
|
||||
cspt_store: ColorStripProcessingTemplateStore for per-layer filter chains
|
||||
"""
|
||||
self._color_strip_store = color_strip_store
|
||||
self._live_stream_manager = live_stream_manager
|
||||
@@ -83,6 +86,8 @@ class ColorStripStreamManager:
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
self._sync_clock_manager = sync_clock_manager
|
||||
self._value_stream_manager = value_stream_manager
|
||||
self._cspt_store = cspt_store
|
||||
self._streams: Dict[str, _ColorStripEntry] = {}
|
||||
|
||||
def _inject_clock(self, css_stream, source) -> Optional[str]:
|
||||
@@ -159,10 +164,12 @@ class ColorStripStreamManager:
|
||||
css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store, self._audio_template_store)
|
||||
elif source.source_type == "composite":
|
||||
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
||||
css_stream = CompositeColorStripStream(source, self)
|
||||
css_stream = CompositeColorStripStream(source, self, self._value_stream_manager, self._cspt_store)
|
||||
elif source.source_type == "mapped":
|
||||
from wled_controller.core.processing.mapped_stream import MappedColorStripStream
|
||||
css_stream = MappedColorStripStream(source, self)
|
||||
elif source.source_type == "processed":
|
||||
css_stream = ProcessedColorStripStream(source, self, self._cspt_store)
|
||||
else:
|
||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||
if not stream_cls:
|
||||
|
||||
@@ -16,6 +16,7 @@ _BLEND_NORMAL = "normal"
|
||||
_BLEND_ADD = "add"
|
||||
_BLEND_MULTIPLY = "multiply"
|
||||
_BLEND_SCREEN = "screen"
|
||||
_BLEND_OVERRIDE = "override"
|
||||
|
||||
|
||||
class CompositeColorStripStream(ColorStripStream):
|
||||
@@ -29,23 +30,40 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
sub-stream's latest colors and blending bottom-to-top.
|
||||
"""
|
||||
|
||||
def __init__(self, source, css_manager):
|
||||
def __init__(self, source, css_manager, value_stream_manager=None, cspt_store=None):
|
||||
import uuid as _uuid
|
||||
self._source_id: str = source.id
|
||||
self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races
|
||||
self._layers: List[dict] = list(source.layers)
|
||||
self._led_count: int = source.led_count
|
||||
self._auto_size: bool = source.led_count == 0
|
||||
self._css_manager = css_manager
|
||||
self._value_stream_manager = value_stream_manager
|
||||
self._cspt_store = cspt_store
|
||||
self._fps: int = 30
|
||||
self._frame_time: float = 1.0 / 30
|
||||
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._latest_colors: Optional[np.ndarray] = None
|
||||
self._latest_layer_colors: Optional[List[np.ndarray]] = None
|
||||
self._colors_lock = threading.Lock()
|
||||
self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called
|
||||
|
||||
# layer_index -> (source_id, consumer_id, stream)
|
||||
self._sub_streams: Dict[int, tuple] = {}
|
||||
self._sub_lock = threading.Lock() # guards _sub_streams access across threads
|
||||
# layer_index -> (vs_id, value_stream)
|
||||
self._brightness_streams: Dict[int, tuple] = {}
|
||||
self._sub_lock = threading.Lock() # guards _sub_streams and _brightness_streams
|
||||
self._sub_streams_version: int = 0 # bumped when _sub_streams changes
|
||||
self._sub_snapshot_version: int = -1 # version of cached snapshot
|
||||
self._sub_snapshot_cache: Dict[int, tuple] = {} # cached dict(self._sub_streams)
|
||||
|
||||
# Pre-resolved blend methods: blend_mode_str -> bound method
|
||||
self._blend_methods = {
|
||||
k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()
|
||||
}
|
||||
self._default_blend_method = self._blend_normal
|
||||
|
||||
# Pre-allocated scratch (rebuilt when LED count changes)
|
||||
self._pool_n = 0
|
||||
@@ -101,6 +119,28 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
with self._colors_lock:
|
||||
return self._latest_colors
|
||||
|
||||
def get_layer_colors(self) -> Optional[List[np.ndarray]]:
|
||||
"""Return per-layer color snapshots (after resize/brightness, before blending)."""
|
||||
self._need_layer_snapshots = True
|
||||
with self._colors_lock:
|
||||
return self._latest_layer_colors
|
||||
|
||||
def get_layer_brightness(self) -> List[Optional[float]]:
|
||||
"""Return per-layer brightness values (0.0-1.0) from value sources, or None if no source."""
|
||||
enabled = [l for l in self._layers if l.get("enabled", True)]
|
||||
result: List[Optional[float]] = []
|
||||
with self._sub_lock:
|
||||
for i in range(len(enabled)):
|
||||
if i in self._brightness_streams:
|
||||
_vs_id, vs = self._brightness_streams[i]
|
||||
try:
|
||||
result.append(vs.get_value())
|
||||
except Exception:
|
||||
result.append(None)
|
||||
else:
|
||||
result.append(None)
|
||||
return result
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
|
||||
self._led_count = device_led_count
|
||||
@@ -115,9 +155,9 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
def update_source(self, source) -> None:
|
||||
"""Hot-update: rebuild sub-streams if layer config changed."""
|
||||
new_layers = list(source.layers)
|
||||
old_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"))
|
||||
old_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"), l.get("brightness_source_id"))
|
||||
for l in self._layers]
|
||||
new_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"))
|
||||
new_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"), l.get("brightness_source_id"))
|
||||
for l in new_layers]
|
||||
|
||||
self._layers = new_layers
|
||||
@@ -136,13 +176,14 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
# ── Sub-stream lifecycle ────────────────────────────────────
|
||||
|
||||
def _acquire_sub_streams(self) -> None:
|
||||
self._sub_streams_version += 1
|
||||
for i, layer in enumerate(self._layers):
|
||||
if not layer.get("enabled", True):
|
||||
continue
|
||||
src_id = layer.get("source_id", "")
|
||||
if not src_id:
|
||||
continue
|
||||
consumer_id = f"{self._source_id}__layer_{i}"
|
||||
consumer_id = f"{self._source_id}__{self._instance_id}__layer_{i}"
|
||||
try:
|
||||
stream = self._css_manager.acquire(src_id, consumer_id)
|
||||
if hasattr(stream, "configure") and self._led_count > 0:
|
||||
@@ -152,14 +193,33 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
logger.warning(
|
||||
f"Composite layer {i} (source {src_id}) failed to acquire: {e}"
|
||||
)
|
||||
# Acquire brightness value stream if configured
|
||||
vs_id = layer.get("brightness_source_id")
|
||||
if vs_id and self._value_stream_manager:
|
||||
try:
|
||||
vs = self._value_stream_manager.acquire(vs_id)
|
||||
self._brightness_streams[i] = (vs_id, vs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Composite layer {i} brightness source {vs_id} failed: {e}"
|
||||
)
|
||||
|
||||
def _release_sub_streams(self) -> None:
|
||||
self._sub_streams_version += 1
|
||||
for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()):
|
||||
try:
|
||||
self._css_manager.release(src_id, consumer_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Composite layer release error ({src_id}): {e}")
|
||||
self._sub_streams.clear()
|
||||
# Release brightness value streams
|
||||
if self._value_stream_manager:
|
||||
for _idx, (vs_id, _vs) in list(self._brightness_streams.items()):
|
||||
try:
|
||||
self._value_stream_manager.release(vs_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Composite brightness release error ({vs_id}): {e}")
|
||||
self._brightness_streams.clear()
|
||||
|
||||
# ── Scratch pool ────────────────────────────────────────────
|
||||
|
||||
@@ -254,16 +314,42 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
u16a >>= 8
|
||||
np.copyto(out, u16a, casting="unsafe")
|
||||
|
||||
def _blend_override(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
||||
out: np.ndarray) -> None:
|
||||
"""Override blend: per-pixel alpha derived from top brightness.
|
||||
|
||||
Black pixels are fully transparent (bottom shows through),
|
||||
bright pixels fully opaque (top replaces bottom). Layer opacity
|
||||
scales the per-pixel alpha.
|
||||
"""
|
||||
u16a, u16b = self._u16_a, self._u16_b
|
||||
# Per-pixel brightness = max(R, G, B) for each LED
|
||||
per_px_alpha = np.max(top, axis=1, keepdims=True).astype(np.uint16)
|
||||
# Scale by layer opacity
|
||||
per_px_alpha = (per_px_alpha * alpha) >> 8
|
||||
# Lerp: out = (bottom * (256 - per_px_alpha) + top * per_px_alpha) >> 8
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
np.copyto(u16b, top, casting="unsafe")
|
||||
u16a *= (256 - per_px_alpha)
|
||||
u16b *= per_px_alpha
|
||||
u16a += u16b
|
||||
u16a >>= 8
|
||||
np.copyto(out, u16a, casting="unsafe")
|
||||
|
||||
_BLEND_DISPATCH = {
|
||||
_BLEND_NORMAL: "_blend_normal",
|
||||
_BLEND_ADD: "_blend_add",
|
||||
_BLEND_MULTIPLY: "_blend_multiply",
|
||||
_BLEND_SCREEN: "_blend_screen",
|
||||
_BLEND_OVERRIDE: "_blend_override",
|
||||
}
|
||||
|
||||
# ── Processing loop ─────────────────────────────────────────
|
||||
|
||||
def _processing_loop(self) -> None:
|
||||
# Per-layer CSPT filter cache: layer_index -> (template_id, [PostprocessingFilter, ...])
|
||||
_layer_cspt_cache: Dict[int, tuple] = {}
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
@@ -280,9 +366,13 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
result_buf = self._result_a if self._use_a else self._result_b
|
||||
self._use_a = not self._use_a
|
||||
has_result = False
|
||||
layer_snapshots: List[np.ndarray] = []
|
||||
|
||||
with self._sub_lock:
|
||||
sub_snapshot = dict(self._sub_streams)
|
||||
if self._sub_streams_version != self._sub_snapshot_version:
|
||||
self._sub_snapshot_cache = dict(self._sub_streams)
|
||||
self._sub_snapshot_version = self._sub_streams_version
|
||||
sub_snapshot = self._sub_snapshot_cache
|
||||
|
||||
for i, layer in enumerate(self._layers):
|
||||
if not layer.get("enabled", True):
|
||||
@@ -295,10 +385,52 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
if colors is None:
|
||||
continue
|
||||
|
||||
# Apply per-layer CSPT filters
|
||||
_layer_tmpl_id = layer.get("processing_template_id") or ""
|
||||
if _layer_tmpl_id and self._cspt_store:
|
||||
cached = _layer_cspt_cache.get(i)
|
||||
if cached is None or cached[0] != _layer_tmpl_id:
|
||||
# Resolve and cache filters for this layer
|
||||
try:
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
_resolved = self._cspt_store.resolve_filter_instances(
|
||||
self._cspt_store.get_template(_layer_tmpl_id).filters
|
||||
)
|
||||
_filters = [
|
||||
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
for fi in _resolved
|
||||
if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True)
|
||||
]
|
||||
_layer_cspt_cache[i] = (_layer_tmpl_id, _filters)
|
||||
logger.info(
|
||||
f"Composite layer {i} CSPT resolved {len(_filters)} filters "
|
||||
f"from template {_layer_tmpl_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to resolve layer {i} CSPT {_layer_tmpl_id}: {e}")
|
||||
_layer_cspt_cache[i] = (_layer_tmpl_id, [])
|
||||
_layer_filters = _layer_cspt_cache[i][1]
|
||||
if _layer_filters:
|
||||
for _flt in _layer_filters:
|
||||
_result = _flt.process_strip(colors)
|
||||
if _result is not None:
|
||||
colors = _result
|
||||
|
||||
# Resize to target LED count if needed
|
||||
if len(colors) != target_n:
|
||||
colors = self._resize_to_target(colors, target_n)
|
||||
|
||||
# Apply per-layer brightness from value source
|
||||
if i in self._brightness_streams:
|
||||
_vs_id, vs = self._brightness_streams[i]
|
||||
bri = vs.get_value()
|
||||
if bri < 1.0:
|
||||
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
|
||||
|
||||
# Snapshot layer colors before blending (copy — may alias shared buf)
|
||||
if self._need_layer_snapshots:
|
||||
layer_snapshots.append(colors.copy())
|
||||
|
||||
opacity = layer.get("opacity", 1.0)
|
||||
blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
|
||||
alpha = int(opacity * 256)
|
||||
@@ -310,16 +442,17 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
result_buf[:] = colors
|
||||
else:
|
||||
result_buf[:] = 0
|
||||
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal"))
|
||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
||||
blend_fn(result_buf, colors, alpha, result_buf)
|
||||
has_result = True
|
||||
else:
|
||||
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal"))
|
||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
||||
blend_fn(result_buf, colors, alpha, result_buf)
|
||||
|
||||
if has_result:
|
||||
with self._colors_lock:
|
||||
self._latest_colors = result_buf
|
||||
self._latest_layer_colors = layer_snapshots if len(layer_snapshots) > 1 else None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True)
|
||||
|
||||
@@ -444,21 +444,26 @@ class EffectColorStripStream(ColorStripStream):
|
||||
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
|
||||
np.copyto(buf[:, 2], self._s_f32_c, casting='unsafe')
|
||||
|
||||
# Bright white-ish head (2-3 LEDs — small, leave allocating)
|
||||
head_mask = np.abs(indices - pos) < 1.5
|
||||
head_brightness = np.clip(1.0 - np.abs(indices - pos), 0, 1)
|
||||
buf[head_mask, 0] = np.clip(
|
||||
buf[head_mask, 0].astype(np.int16) + (head_brightness[head_mask] * (255 - r)).astype(np.int16),
|
||||
0, 255,
|
||||
).astype(np.uint8)
|
||||
buf[head_mask, 1] = np.clip(
|
||||
buf[head_mask, 1].astype(np.int16) + (head_brightness[head_mask] * (255 - g)).astype(np.int16),
|
||||
0, 255,
|
||||
).astype(np.uint8)
|
||||
buf[head_mask, 2] = np.clip(
|
||||
buf[head_mask, 2].astype(np.int16) + (head_brightness[head_mask] * (255 - b)).astype(np.int16),
|
||||
0, 255,
|
||||
).astype(np.uint8)
|
||||
# Bright white-ish head (2-3 LEDs) — direct index range to avoid
|
||||
# boolean mask allocations and fancy indexing temporaries.
|
||||
head_lo = max(0, int(pos - 1.5) + 1)
|
||||
head_hi = min(n, int(pos + 1.5) + 1)
|
||||
if head_hi > head_lo:
|
||||
head_sl = slice(head_lo, head_hi)
|
||||
head_dist = self._s_f32_a[head_sl]
|
||||
np.subtract(indices[head_sl], pos, out=head_dist)
|
||||
np.abs(head_dist, out=head_dist)
|
||||
# head_brightness = clip(1 - abs_dist, 0, 1)
|
||||
head_br = self._s_f32_b[head_sl]
|
||||
np.subtract(1.0, head_dist, out=head_br)
|
||||
np.clip(head_br, 0, 1, out=head_br)
|
||||
# Additive blend towards white using scratch _s_f32_c slice
|
||||
tmp = self._s_f32_c[head_sl]
|
||||
for ch_idx, ch_base in enumerate((r, g, b)):
|
||||
np.multiply(head_br, 255 - ch_base, out=tmp)
|
||||
tmp += buf[head_sl, ch_idx]
|
||||
np.clip(tmp, 0, 255, out=tmp)
|
||||
np.copyto(buf[head_sl, ch_idx], tmp, casting='unsafe')
|
||||
|
||||
# ── Plasma ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from wled_controller.core.processing.live_stream import (
|
||||
ScreenCaptureLiveStream,
|
||||
StaticImageLiveStream,
|
||||
)
|
||||
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -178,6 +179,7 @@ class LiveStreamManager:
|
||||
ProcessedPictureSource,
|
||||
ScreenCapturePictureSource,
|
||||
StaticImagePictureSource,
|
||||
VideoCaptureSource,
|
||||
)
|
||||
|
||||
stream_config = self._picture_source_store.get_stream(picture_source_id)
|
||||
@@ -191,6 +193,9 @@ class LiveStreamManager:
|
||||
elif isinstance(stream_config, StaticImagePictureSource):
|
||||
return self._create_static_image_live_stream(stream_config), None
|
||||
|
||||
elif isinstance(stream_config, VideoCaptureSource):
|
||||
return self._create_video_live_stream(stream_config), None
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown picture source type: {type(stream_config)}")
|
||||
|
||||
@@ -259,6 +264,31 @@ class LiveStreamManager:
|
||||
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
|
||||
return resolved
|
||||
|
||||
def _create_video_live_stream(self, config) -> VideoCaptureLiveStream:
|
||||
"""Create a VideoCaptureLiveStream from a VideoCaptureSource config."""
|
||||
stream = VideoCaptureLiveStream(
|
||||
url=config.url,
|
||||
loop=config.loop,
|
||||
playback_speed=config.playback_speed,
|
||||
start_time=config.start_time,
|
||||
end_time=config.end_time,
|
||||
resolution_limit=config.resolution_limit,
|
||||
target_fps=config.target_fps,
|
||||
)
|
||||
|
||||
# Attach sync clock if configured
|
||||
if config.clock_id:
|
||||
try:
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
manager = ProcessorManager.instance()
|
||||
if manager and hasattr(manager, '_sync_clock_manager'):
|
||||
clock = manager._sync_clock_manager.acquire(config.clock_id)
|
||||
stream.set_clock(clock)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not attach clock {config.clock_id} to video stream: {e}")
|
||||
|
||||
return stream
|
||||
|
||||
def _create_static_image_live_stream(self, config) -> StaticImageLiveStream:
|
||||
"""Create a StaticImageLiveStream from a StaticImagePictureSource config."""
|
||||
image = self._load_static_image(config.image_source)
|
||||
@@ -266,7 +296,12 @@ class LiveStreamManager:
|
||||
|
||||
@staticmethod
|
||||
def _load_static_image(image_source: str) -> np.ndarray:
|
||||
"""Load a static image from URL or file path, return as RGB numpy array."""
|
||||
"""Load a static image from URL or file path, return as RGB numpy array.
|
||||
|
||||
Note: Uses synchronous httpx.get() for URLs, which blocks up to 15s.
|
||||
This is acceptable because acquire() (the only caller chain) is always
|
||||
invoked from background worker threads, never from the async event loop.
|
||||
"""
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -163,6 +163,9 @@ class MappedColorStripStream(ColorStripStream):
|
||||
|
||||
def _processing_loop(self) -> None:
|
||||
frame_time = self._frame_time
|
||||
_pool_n = 0
|
||||
_buf_a = _buf_b = None
|
||||
_use_a = True
|
||||
try:
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
@@ -173,7 +176,14 @@ class MappedColorStripStream(ColorStripStream):
|
||||
time.sleep(frame_time)
|
||||
continue
|
||||
|
||||
result = np.zeros((target_n, 3), dtype=np.uint8)
|
||||
if target_n != _pool_n:
|
||||
_pool_n = target_n
|
||||
_buf_a = np.zeros((target_n, 3), dtype=np.uint8)
|
||||
_buf_b = np.zeros((target_n, 3), dtype=np.uint8)
|
||||
|
||||
result = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
result[:] = 0
|
||||
|
||||
with self._sub_lock:
|
||||
sub_snapshot = dict(self._sub_streams)
|
||||
|
||||
@@ -5,7 +5,10 @@ from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional
|
||||
|
||||
import psutil
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -18,8 +21,6 @@ def _collect_system_snapshot() -> dict:
|
||||
|
||||
Returns a dict suitable for direct JSON serialization.
|
||||
"""
|
||||
import psutil
|
||||
|
||||
mem = psutil.virtual_memory()
|
||||
snapshot = {
|
||||
"t": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -32,8 +33,6 @@ def _collect_system_snapshot() -> dict:
|
||||
}
|
||||
|
||||
try:
|
||||
from wled_controller.api.routes.system import _nvml_available, _nvml, _nvml_handle
|
||||
|
||||
if _nvml_available:
|
||||
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
|
||||
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
|
||||
@@ -92,7 +91,8 @@ class MetricsHistory:
|
||||
# Per-target metrics from processor states
|
||||
try:
|
||||
all_states = self._manager.get_all_target_states()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.error("Failed to get target states: %s", e)
|
||||
all_states = {}
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user