Compare commits

...

67 Commits

Author SHA1 Message Date
alexei.dolgolyov adfc39f9d1 chore: release v0.3.0
Build Release / create-release (push) Successful in 3s
Build Release / build-linux (push) Successful in 1m23s
Build Release / build-docker (push) Successful in 2m19s
Lint & Test / test (push) Successful in 3m10s
Build Release / build-windows (push) Successful in 3m29s
2026-04-08 12:41:28 +03:00
alexei.dolgolyov d037a2e929 fix(tray): replace tkinter messagebox with Win32 MessageBoxW
Lint & Test / test (push) Successful in 2m3s
The packaged embedded Python distribution does not ship the tcl/tk
runtime, so tkinter.messagebox.askyesno crashed with 'Can't find a
usable init.tcl' when the user clicked Shutdown or Restart in the
tray menu. Use ctypes + user32.MessageBoxW instead — no tcl/tk,
no extra dependencies.
2026-04-08 12:16:32 +03:00
alexei.dolgolyov fc8ee34369 fix(launcher): start-hidden.vbs must be ASCII + CRLF, use python.exe
Lint & Test / test (push) Successful in 1m40s
Three separate bugs in the VBS launcher wedged together:

1. The previous fix added a UTF-8 em-dash in a comment. wscript.exe
   on Windows refused to execute the file with "Execution of the
   Windows Script Host failed. (Not enough memory resources are
   available to complete this operation.)" — a misleading error that
   actually means "I could not parse this file as ANSI VBScript".
   Fix: keep the file pure ASCII, convert to CRLF.

2. The launcher was invoking pythonw.exe. WshShell.Run spawning
   pythonw.exe inside the wscript host exited immediately (no process,
   no log). python.exe with WindowStyle=0 works reliably and matches
   the pattern used by the Media Server sibling app's VBS launcher,
   which has been running on this machine without issue.

3. The env vars (PYTHONPATH, WLED_CONFIG_PATH) must be set before the
   child process spawns, otherwise config.py falls back to the CWD
   default path that does not exist at install time.
2026-04-08 00:15:49 +03:00
alexei.dolgolyov e262a8b004 fix(launcher): set PYTHONPATH and WLED_CONFIG_PATH in start-hidden.vbs
Lint & Test / test (push) Successful in 2m1s
The VBS launcher (used by Start Menu, desktop, and autostart shortcuts
created by the NSIS installer) ran pythonw.exe without setting any env
vars. LedGrab.bat sets PYTHONPATH and WLED_CONFIG_PATH; the VBS did not.

With CWD set to the install root, config.py fell through to its default
lookup (./config/default_config.yaml), which does not exist there — the
real file is at app/config/default_config.yaml. The server silently ran
with built-in defaults on every shortcut launch: no devices, wrong data
dir, nothing persisted where the user expected.

The fix uses WshShell.Environment("Process") to set env vars on the
current VBS process, which child processes spawned via .Run inherit.
Kept CurrentDirectory = appRoot to preserve prior behavior for anyone
depending on CWD-relative paths inside the app.
2026-04-08 00:02:56 +03:00
alexei.dolgolyov d4ffe2e985 refactor: drop packaging dependency, inline version parsing
Lint & Test / test (push) Successful in 3m9s
The only user of 'packaging' was version_check.py — two small functions
(normalize_version, is_newer) that just need to parse "1.2.3-alpha.1"
and compare PEP 440-style versions. That's well within stdlib reach.

- Inline a NamedTuple-based Version with kind/pre_num ordering
  (dev < alpha < beta < rc < release), same regex-normalized format
- Define a local InvalidVersion exception
- Remove packaging>=23.0 from pyproject.toml dependencies

Why now: the Windows cross-build uses a hard-coded DEPS array in
build-dist-windows.sh, which was never updated when 'packaging' was
added on March 25. Result: importable from pip-installed dev envs,
missing from the portable installer — tray icon appeared but uvicorn
died with ModuleNotFoundError: No module named 'packaging'.

Removing the dep entirely is cleaner than adding one more hard-coded
entry to the Windows DEPS list. Tests (678 passing) and a manual test
matrix covering dev/alpha/beta/rc/release ordering all pass.
2026-04-07 23:54:27 +03:00
alexei.dolgolyov feb91ad281 fix(build): fix shell syntax error in smoke_test_imports heredoc
Lint & Test / test (push) Successful in 2m6s
The 'cmd <<EOF || { ... }' pattern confuses bash's parser — the closing
brace of the inline error block collides with the function's closing
brace. Rewrote to capture the python script into a local var via
$(cat <<EOF), then run it with -c and a plain 'if !' guard.
2026-04-07 23:41:42 +03:00
alexei.dolgolyov 17c5c02993 fix(build): keep .py sources + make smoke test skip uninstalled modules
Lint & Test / test (push) Successful in 1m36s
- compile_and_strip_sources: stop deleting .py files after compileall.
  OpenCV's loader does literal file I/O on cv2/config.py (not a Python
  import), so stripping it breaks `import cv2` with "missing
  configuration file: ['config.py']". Other packages may do similar
  file-based introspection tricks — the ~30% size win isn't worth
  playing whack-a-mole with broken installers. We already hit this
  with numpy.linalg and zeroconf._services; enough incidents.
- smoke_test_imports: only assert importability for modules whose
  top-level dir actually exists in site-packages. Pillow for example
  is a Windows-only dep, and was failing the Linux build spuriously.
  Rewrote as a heredoc for readability.
2026-04-07 23:37:54 +03:00
alexei.dolgolyov fd6776aeac fix(build): stop stripping zeroconf/_services + add import smoke test
Lint & Test / test (push) Successful in 2m39s
- build-common.sh: remove zeroconf/_services from the strip list.
  zeroconf's compiled Cython _listener.pyd imports from _services
  internally, so stripping it broke `import zeroconf` at runtime with
  ModuleNotFoundError — same class of bug as the numpy.linalg strip.
- build-common.sh: add smoke_test_imports() that imports every top-level
  dependency against the stripped site-packages. Catches "we stripped
  something that was actually needed" regressions at build time instead
  of on a user's machine after install.
- build-dist.sh: wire smoke test into the Linux flow (runs real imports).
- build-dist-windows.sh: cross-build can't load win_amd64 .pyd files with
  the host python, so instead verify that the known-required submodule
  dirs (numpy.linalg/lib/matrixlib/ma, zeroconf._services) exist after
  cleanup. Fails loud if any future strip-rule removes them.
2026-04-07 23:32:50 +03:00
alexei.dolgolyov 9f34ffb0a0 fix(build): stop stripping numpy.lib/linalg from site-packages
Lint & Test / test (push) Successful in 2m57s
numpy's own __init__.py imports lib and matrixlib (which in turn imports
numpy.linalg via defmatrix.py). Removing any of these submodules to save
dist size makes `import numpy` raise ModuleNotFoundError on the target,
which cascades into every wled_controller import.

Symptom: v0.0.0.dev0 Windows installer showed a tray icon but uvicorn
died silently in its background thread and port 8080 never listened —
browser got ERR_CONNECTION_REFUSED.

Keep stripping: polynomial, distutils, f2py, typing, _pyinstaller.
These are genuinely unused by numpy's own import chain.
2026-04-07 23:17:12 +03:00
alexei.dolgolyov b5842e6424 fix(build): normalize non-PEP440 versions, fix .py/compileall ordering, wipe NSIS payload dirs
Lint & Test / test (push) Successful in 3m18s
- build-common.sh: detect_version() normalizes non-PEP440 labels (e.g. 'dev',
  'nightly') to 0.0.0.dev0 so stamping pyproject.toml doesn't break pip install.
- build-common.sh: split .py deletion out of cleanup_site_packages into a new
  compile_and_strip_sources() that runs 'compileall -b' FIRST, then removes
  sources. compileall now fails loud instead of silently no-op'ing.
- build-dist.sh: add missing compile_and_strip_sources call on Linux site-packages
  (previous tarballs shipped empty packages with no .py and no .pyc).
- build-dist-windows.sh: reorder so compile_and_strip_sources runs right after
  cleanup_site_packages, not after .py files have already been deleted.
- installer.nsi: RMDir /r payload dirs (python/, app/, scripts/) and delete
  LedGrab.bat at the top of SecCore before File /r. NSIS File /r MERGES into
  existing dirs, so upgrades left half-old/half-new state that surfaced as
  'version mismatch' or duplicate-package ImportErrors. data/ and logs/ remain
  untouched to preserve user config.
2026-04-07 23:04:38 +03:00
alexei.dolgolyov 7a9c368448 refactor: split color-strips.ts into focused modules under color-strips/ folder
Lint & Test / test (push) Successful in 2m6s
Monolithic 3060-line color-strips.ts split into 11 modules:
- index.ts (core orchestrator, modal, type switching, editor, save, CRUD)
- cards.ts (card rendering for all source types)
- game-event.ts (game event mappings, presets, UI)
- gradient.ts (gradient entity modal + CRUD)
- audio.ts (audio viz widgets, load/reset state)
- math-wave.ts (wave layers + waveform selects)
- mapped.ts (mapped zone helpers)
- color-cycle.ts (color cycle add/remove/render)
- composite.ts, notification.ts, test.ts (previously extracted, moved into folder)
2026-04-05 12:54:15 +03:00
alexei.dolgolyov ce53ca6872 feat: add card glare effect to dashboard and perf chart cards
Lint & Test / test (push) Has been cancelled
Extend cursor-tracking spotlight to .dashboard-target and
.perf-chart-card elements with a smaller 100px radius suited
for compact cards.
2026-04-05 12:23:39 +03:00
alexei.dolgolyov b04978af58 feat: add music sync viz modes and auto_gain audio filter
Lint & Test / test (push) Has been cancelled
Add 4 new audio visualization modes powered by MusicAnalyzer:
- pulse_on_beat: BPM-synced pulsing with smooth beat phase
- energy_gradient: bass/mid/treble mapped to scrolling gradient
- spectrum_bands: three VU zones for frequency bands
- strobe_on_drop: state-driven strobe on detected musical drops

MusicAnalyzer provides BPM estimation (median IBI), beat phase tracking,
asymmetric energy envelope, 3-band frequency splitting, and drop
detection state machine (idle/buildup/drop/recovery).

Add auto_gain audio filter for automatic level normalization via rolling
peak tracking with configurable target level and response time.

Deprecate auto_gain on Audio Value Source (use the filter instead).
2026-04-05 01:40:34 +03:00
alexei.dolgolyov 6e8b159126 fix: weather CSS card shows empty source name after hard refresh
Lint & Test / test (push) Has been cancelled
Weather sources cache was not fetched during streams tab load, so the
card renderer could not resolve weather source names. Also widened
lat/lon inputs from 80px to 100px to fit longer coordinates.
2026-04-05 00:49:28 +03:00
alexei.dolgolyov ace24715c8 feat: add math_wave color strip source type
Lint & Test / test (push) Has been cancelled
Mathematical wave generator that produces per-LED colors from
configurable waveform layers (sine, triangle, sawtooth, square) with
superposition, mapped through a gradient palette. Supports sync clocks,
bindable speed, and up to 8 wave layers.

- Storage model with wave validation and apply_update
- Numpy-vectorized stream with gradient LUT color mapping
- API schemas (create/update/response) and route registration
- Frontend editor with dynamic wave layer rows, gradient picker,
  speed widget, and IconSelect waveform selectors
- i18n in en/ru/zh
2026-04-05 00:41:07 +03:00
alexei.dolgolyov edc6d27e2e fix: replace HA test icon with refresh, make automation rules collapsible
Lint & Test / test (push) Has been cancelled
Use refresh icon instead of flask for HA test connection buttons.
Add collapse/expand chevron to automation rule rows (collapsed by default).
2026-04-04 21:28:51 +03:00
alexei.dolgolyov b7da4ab6b5 feat: add Integrations tab and responsive icon-only tabs
Lint & Test / test (push) Successful in 1m48s
Move HA sources, weather sources, game integration, and MQTT settings
into a dedicated Integrations top-level tab with tab-registry pattern.
Collapse tab labels to icon-only at narrow desktop widths (<=1100px)
to prevent toolbar overflow.
2026-04-02 15:29:38 +03:00
alexei.dolgolyov 99460a8043 fix: make pystray a core dependency on Windows instead of optional extra
Lint & Test / test (push) Successful in 2m5s
The tray icon should always appear on Windows, not only when installed
with the [tray] extra.
2026-04-02 14:22:18 +03:00
alexei.dolgolyov 89990f8d63 chore: remove processed-audio-sources plan files 2026-04-02 13:42:37 +03:00
alexei.dolgolyov 0cc0aaa411 feat: processed audio sources with composable filter pipeline
Replace hardcoded MonoAudioSource/BandExtractAudioSource with a
composable ProcessedAudioSource + AudioProcessingTemplate + AudioFilter
system. 11 audio filters: channel extract, band extract, peak hold,
gain, noise gate, envelope follower, spectral smoothing, compressor,
inverter, beat gate, delay. Full frontend UI with filter editor,
tree navigation, and i18n support.
2026-04-02 13:42:18 +03:00
alexei.dolgolyov af2c89c8df fix: audio tree structure, filter i18n, and IconSelect for filter options
Restructure audio tree nav into Capture (Sources + Engine Templates)
and Processed (Sources + Filter Templates) subgroups.
Add missing i18n description keys for all 11 audio filters.
Wrap plain select filter options with IconSelect grids.
2026-04-02 13:37:50 +03:00
alexei.dolgolyov d04192ffb7 fix: add reference check before deleting audio processing template
Prevent deleting templates that are still referenced by
ProcessedAudioSource entities. Returns 400 with source names.
2026-04-01 23:28:58 +03:00
alexei.dolgolyov 992495e2e4 fix: isolate tests from production database
Tests that imported wled_controller.main at module level caused the real
production database (data/ledgrab.db) to be opened before test fixtures
could patch the config. This led to silent data loss.

Patch the global config singleton at conftest module level (before any
test imports main.py) to redirect all DB access to a temp directory.
2026-04-01 19:01:56 +03:00
alexei.dolgolyov 6b0e4e5539 feat(processed-audio-sources): phase 8 - frontend design consistency review
Fix audio source modal error class (modal-error), use Modal.showError(),
reorder audio source card description, remove redundant APT filter count
badge, clean up unused imports in audio-sources.ts.
2026-03-31 23:11:17 +03:00
alexei.dolgolyov ce1f4847f3 feat(processed-audio-sources): phase 7 - testing and polish
Fix test_list_filters test (filter_id field name mismatch).
Add tests for audio filters, template store, and source store.
All 678 tests pass, ruff clean, tsc clean, esbuild clean.
No dead code remaining from old source types.
2026-03-31 22:50:02 +03:00
alexei.dolgolyov 1ce0dc6c61 feat(processed-audio-sources): phase 6 - frontend source type cleanup
Rewrite audio source editor modal for capture/processed types only.
Remove old multichannel/mono/band_extract HTML sections and i18n keys.
Clean up legacy DOM section null-checks in audio-sources.ts.
2026-03-31 19:40:37 +03:00
alexei.dolgolyov 553463935e feat(processed-audio-sources): phase 5 - frontend audio processing templates
Add Audio Processing Templates management UI to Streams tab:
- Template editor modal with filter list via FilterListManager
- CardSection with reconciliation for template cards
- DataCache instances for templates and audio filter defs
- Audio filter icon mappings in filter-list.ts
- i18n keys in en/ru/zh locales
2026-03-31 19:32:17 +03:00
alexei.dolgolyov ab43578049 feat(processed-audio-sources): phase 4 - runtime filter integration
Add AudioFilterPipeline for chained filter execution on AudioAnalysis.
Wire filter pipelines into AudioColorStripStream, AudioValueStream,
and WebSocket test endpoint. Add hot-update support via
ProcessorManager.refresh_audio_filter_pipelines(). Thread
AudioProcessingTemplateStore through dependency injection hierarchy.
2026-03-31 19:15:29 +03:00
alexei.dolgolyov 353c090b42 feat(processed-audio-sources): phase 3 - processed audio source model
Replace MultichannelAudioSource with CaptureAudioSource, add
ProcessedAudioSource (audio_source_id + audio_processing_template_id),
remove MonoAudioSource and BandExtractAudioSource entirely.
Update store resolution to walk processed chains collecting template IDs.
Update all API schemas, routes, and frontend references.
2026-03-31 19:01:46 +03:00
alexei.dolgolyov eb94066386 feat(processed-audio-sources): phase 2 - implement 11 audio filters
Add all audio filters that transform AudioAnalysis data:
- Channel Extract, Band Extract (migration from old source types)
- Peak Hold, Gain, Noise Gate, Envelope Follower
- Spectral Smoothing, Compressor, Inverter, Beat Gate, Delay
All registered via AudioFilterRegistry with option schemas.
2026-03-31 18:43:36 +03:00
alexei.dolgolyov 86a9d344e6 feat(processed-audio-sources): phase 1 - audio filter framework
Add the foundation for audio processing filters, mirroring the existing
picture filter/postprocessing template system:
- AudioFilter base class, AudioFilterRegistry, AudioFilterOptionDef
- AudioProcessingTemplate dataclass + SQLite-backed store
- audio_filter_template meta-filter with recursive resolution
- Full REST API: CRUD templates + filter registry discovery
- Dependency injection wired in dependencies.py and main.py
2026-03-31 17:35:39 +02:00
alexei.dolgolyov c59107c7c7 feat: refactor MQTT from global config to multi-instance entity model
Lint & Test / test (push) Successful in 1m32s
MQTT broker connections are now managed as entities (like HA sources)
instead of a single global config. Each MQTTSource gets its own
runtime with auto-reconnect, ref-counted via MQTTManager.

Backend:
- MQTTSource dataclass + MQTTSourceStore (SQLite)
- MQTTRuntime (per-broker connection, refactored from MQTTService)
- MQTTManager (ref-counted pool, same pattern as HAManager)
- CRUD API at /api/v1/mqtt/sources + test + status endpoints
- MQTTRule gains mqtt_source_id field for source selection
- Automation engine acquires/releases MQTT runtimes automatically
- Legacy MQTTService kept for backward compat during transition

Frontend:
- MQTT source cards in Streams > Integrations tab
- Create/edit modal with test button
- Dashboard integration cards with health-dot indicators
- Removed MQTT tab from settings modal
2026-03-31 18:02:19 +03:00
alexei.dolgolyov e7c9a568dc feat: HA source cards use health-dot indicators
Lint & Test / test (push) Successful in 1m39s
Replace unstyled status-dot classes with the existing health-dot
pattern (green/red glow) matching LED device cards. Tooltip shows
connection status and entity count.
2026-03-31 16:05:01 +03:00
alexei.dolgolyov b36ddfd395 Merge branch 'feature/game-integration'
Lint & Test / test (push) Successful in 1m49s
Game integration system: receive real-time events from games
(CS2, Dota 2, LoL, etc.) and drive LED effects via color strip
streams and value source bindings.
2026-03-31 14:23:38 +03:00
alexei.dolgolyov 492bdb95e3 feat: game integration system
Receive real-time events from games (CS2, Dota 2, LoL, etc.) and drive
LED effects through the existing color strip and value source pipelines.

Core:
- GameEventBus (thread-safe pub/sub) with standardized 23-type event vocabulary
- GameAdapter ABC + AdapterRegistry + MappingAdapter (YAML-driven)
- Built-in adapters: CS2 GSI, Dota 2 GSI, LoL Live Client, Generic Webhook
- Community YAML adapters: Minecraft, Valorant, Rocket League
- GameEventColorStripStream with 5 effects (flash/pulse/sweep/color_shift/breathing)
- GameEventValueSource with EMA smoothing and timeout
- 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert)
- Auto-setup for Valve GSI games (Steam path detection, cfg file writing)
- Demo capture engine exposed to non-demo mode

Frontend:
- Game tab in Streams tree navigation with integration cards
- Game integration editor modal with adapter picker, config fields, event mappings
- game_event source type in CSS and ValueSource editors
- Setup instructions overlay (markdown rendered)
- Live event monitor and connection test

API:
- Full CRUD for game integrations
- Event ingestion endpoint (adapter-level auth)
- Adapter metadata, presets, auto-setup, status/diagnostics endpoints
2026-03-31 13:17:52 +03:00
alexei.dolgolyov b6713be390 feat: system_metrics value source type
Lint & Test / test (push) Successful in 1m32s
New value source that monitors host hardware via psutil/pynvml:
cpu_load, cpu_temp, gpu_load, gpu_temp, ram_usage, disk_usage,
network_rx, network_tx, battery_level, fan_speed.

Each metric normalizes to 0.0-1.0 with configurable ranges, poll
interval, EMA smoothing, and sensor_label for multi-sensor systems.
Conditional editor fields show/hide based on selected metric.

Also fixes: WS test crash when raw_value streams lack _min_ha attr,
toast timer overlap on rapid calls, SW cache bump to v34.
2026-03-30 18:22:58 +03:00
alexei.dolgolyov db5008aaeb feat: system theme option + fix toast timer overlap
Lint & Test / test (push) Successful in 1m27s
Add third theme mode (system) that follows OS prefers-color-scheme.
Theme button cycles dark → light → system with monitor icon.
Listens for OS preference changes in real time when in system mode.

Fix showToast clearing previous timer so rapid calls don't cause
the toast to disappear early.
2026-03-30 13:55:38 +03:00
alexei.dolgolyov 4b7a8d75f4 feat: value source card crosslinks + gradient_map test shows input value
Lint & Test / test (push) Successful in 1m23s
Add navigateToCard crosslinks for ha_entity (→ HA source), gradient_map
(→ input value source + gradient entity), and css_extract (→ color strip).
Gradient map test now charts the input interpolation factor instead of
output luminance, making the 0–1 chart meaningful.
2026-03-30 03:23:05 +03:00
alexei.dolgolyov f6c25cd15f feat: color value source test visualization
Lint & Test / test (push) Successful in 1m6s
WS now sends color RGB data for color-type streams. Test modal renders
a color history swatch strip below the chart, colors the chart line and
fill area with the current output color, and shows rgb() in the stats.
Works for static_color, animated_color, and adaptive_time_color sources.
2026-03-30 03:12:57 +03:00
alexei.dolgolyov 0a8737157c feat: HA value source test — raw value axis + behavior IconSelect
Lint & Test / test (push) Successful in 1m9s
- WS sends raw_value and raw_range for HA entity streams
- Canvas draws right-side Y-axis labels: configured min/max range
  and current raw HA value (green, positioned at correct Y)
- Replace plain <select> for scene behavior with IconSelect (moon/sun)
2026-03-30 03:06:44 +03:00
alexei.dolgolyov 11d5d6b5e1 fix: device card header layout — URL badge overflow and hide button gap
Lint & Test / test (push) Successful in 1m24s
Move URL badge from card-title to card-subtitle row to prevent
overlap with top-right action buttons. Widen card-header padding-right
to 60px for 2-button clearance. Reorder hide button to first position
in top-actions so power and trash stay visually adjacent.
2026-03-30 02:05:45 +03:00
alexei.dolgolyov 384362ccf1 feat: new value source types (HA entity, gradient map, strip extract) + UI fixes
Lint & Test / test (push) Successful in 1m27s
New value source types:
- ha_entity: reads numeric values from HA entity state/attribute, normalizes
  via min/max range, applies EMA smoothing. EntitySelect for HA connection
  and entity selection with live entity list fetching.
- gradient_map: maps a float value source (0-1) through a gradient entity.
  EntitySelect for both input source and gradient with inline previews.
- css_extract: extracts single color by averaging LED range from a color
  strip source. EntitySelect for source selection.

Value source type picker:
- Filter tabs (All / Numeric / Color) above the icon grid
- showTypePicker extended with filterTabs + onFilterChange support

Palette selectors converted to EntitySelect:
- Effect palette, gradient preset, and audio palette selectors now use
  command-palette style EntitySelect with gradient strip previews

Tab indicator fixes:
- Icon now updates on tab switch (was passing no args to updateTabIndicator)
- Visible with any background effect active, not just Noise Field
- Noise Field is the default background effect for new users

Dashboard section collapse fix:
- Split header into clickable toggle (chevron+label) and non-clickable
  actions area — buttons no longer trigger collapse/expand

Discriminated union fix (422 errors):
- source_type/target_type now always included in update payloads for:
  CSS editor, LED target, HA light target, simple calibration,
  advanced calibration
2026-03-29 20:38:22 +03:00
alexei.dolgolyov ea812bb4d5 feat: check if port is busy before starting the server
Lint & Test / test (push) Successful in 1m16s
2026-03-29 14:21:35 +03:00
alexei.dolgolyov a9e6e8cb82 fix: KC color strip test preview — use LiveStreamManager instead of raw engine
Lint & Test / test (push) Successful in 1m28s
BetterCam (DXGI) only supports one capture session per display, so creating
a second engine for the KC test produced no frames when a target was already
running. Now uses LiveStreamManager.acquire() which ref-counts and reuses
the running capture.

Also removed double postprocessing — ProcessedLiveStream already applies
filters, so re-applying them in the KC test halved the resolution (960x400
→ 240x100).
2026-03-29 14:11:01 +03:00
alexei.dolgolyov 78ce6c84d7 fix: composite layer opacity/brightness widgets + CSS layout
Lint & Test / test (push) Successful in 1m7s
- Fix opacity widget empty space (CSS selector .composite-layer-opacity → .composite-layer-opacity-container)
- Replace brightness select dropdown with BindableScalarWidget (slider + VS toggle)
- Legacy brightness_source_id auto-converted to BindableFloat on load
- Add .composite-layer-brightness-container CSS rule
2026-03-29 00:42:42 +03:00
alexei.dolgolyov 8a17bb5caa feat: BindableFloat — universal value source binding for all scalar properties
Lint & Test / test (push) Successful in 1m20s
Introduce BindableFloat abstraction that allows any numeric property to be
either a static value or dynamically driven by a ValueSource. Backward-compatible
serialization: plain float when unbound, {value, source_id} dict when bound.

Backend:
- storage/bindable.py — BindableFloat dataclass + bfloat() helper
- 25+ scalar properties converted across all entity types
- Runtime VS acquisition in ColorStripStreamManager for CSS bindings
- All stream hot loops use self.resolve() for live values
- KeyColorsColorStripStream now inherits ColorStripStream

Frontend:
- BindableScalarWidget (slider + VS picker toggle) for all editors
- TypeScript BindableFloat type + helpers
- Graph editor edges for all bindable properties
- Audio source channel IconSelect grid

Fixes: daylight longitude, candlelight wind_strength/candle_type from_dict
2026-03-29 00:33:24 +03:00
alexei.dolgolyov 5f70302263 feat: use custom app icon for shortcuts and installer
Lint & Test / test (push) Successful in 1m15s
- Generate icon.ico from icon-512.png (16-256px sizes)
- Set MUI_ICON/MUI_UNICON for installer wizard
- Point all shortcuts to icon.ico instead of pythonw.exe
- Add DisplayIcon registry entry for Add/Remove Programs
2026-03-28 18:41:12 +03:00
alexei.dolgolyov 40751fecb7 feat: HA light target live color preview — per-entity swatches via WebSocket
Lint & Test / test (push) Successful in 1m24s
- Cache per-entity colors in HALightTargetProcessor._update_lights()
- Broadcast colors_update to WS clients at target's update_rate
- WS endpoint: /api/v1/output-targets/{target_id}/ha-light/ws
- Frontend: connect WS when target runs, update swatch colors live
- Card shows colored boxes per mapped entity with entity name labels
2026-03-28 18:28:16 +03:00
alexei.dolgolyov 381ee75371 fix: HA light target — brightness source, transition=0, dashboard type label
Lint & Test / test (push) Successful in 1m13s
- Add brightness_value_source_id to HALightOutputTarget model, to_dict,
  from_dict, update_fields, register_with_manager, API response
- Wire value stream in HALightTargetProcessor: acquire/release on
  start/stop, multiply brightness in _update_lights loop
- Fix transition=0 not saving (parseFloat("0") || 0.5 was falsy)
- Fix dashboard showing "Key Colors" for HA targets — now "Home Assistant"
- Fix dashboard FPS showing 0/2 — HA targets show target/target
- Add CSS source subtitle to HA target dashboard cards
2026-03-28 16:03:06 +03:00
alexei.dolgolyov 3e6760f726 refactor: key colors targets → CSS source type, HA target improvements
Lint & Test / test (push) Successful in 1m26s
Key Colors refactor:
- New `key_colors` CSS source type with inline rectangles
- KeyColorsColorStripStream: extracts N colors from screen regions
- CSS editor: EntitySelect for picture source, IconSelect for color mode
- Configure Regions button on card opens pattern canvas editor
- Live WS preview at 5 FPS with rectangle overlay + color swatches
- Removed KC target type, pattern template entity, and related API routes
- Removed KC/pattern template sections from Targets tab

HA light target improvements:
- Update rate, transition, mappings, brightness VS now editable via PUT
- Card crosslinks for HA source, CSS source, brightness VS
- HA connection status icon, text metrics (Hz, uptime)
- Brightness value source selector in editor
2026-03-28 15:28:22 +03:00
alexei.dolgolyov 89d1b13854 fix: rename HA Lights → Home Assistant, HA Light Targets → Light Targets
Lint & Test / test (push) Successful in 1m38s
2026-03-28 11:30:40 +03:00
alexei.dolgolyov 324a308805 feat: entity picker for HA light mapping — searchable EntitySelect for light entities
Lint & Test / test (push) Failing after 11m19s
Replaces plain text input with EntitySelect dropdown that fetches
available light.* entities from the selected HA source. Changing the
HA source refreshes the entity list across all mapping rows.
2026-03-28 00:35:42 +03:00
alexei.dolgolyov cb9289f01f feat: HA light output targets — cast LED colors to Home Assistant lights
Lint & Test / test (push) Has been cancelled
New output target type `ha_light` that sends averaged LED colors to HA
light entities via WebSocket service calls (light.turn_on/turn_off):

Backend:
- HARuntime.call_service(): fire-and-forget WS service calls
- HALightOutputTarget: data model with light mappings, update rate, transition
- HALightTargetProcessor: processing loop with delta detection, rate limiting
- ProcessorManager.add_ha_light_target(): registration
- API schemas/routes updated for ha_light target type

Frontend:
- HA Light Targets section in Targets tab tree nav
- Modal editor: HA source picker, CSS source picker, light entity mappings
- Target cards with start/stop/clone/edit actions
- i18n keys for all new UI strings
2026-03-28 00:08:49 +03:00
alexei.dolgolyov fb98e6e2b8 ci: add manual build workflow for testing artifacts
Lint & Test / test (push) Has been cancelled
workflow_dispatch-triggered build.yml that produces Windows
installer/portable and Linux tarball as CI artifacts without
creating a release. Trigger from Gitea UI → Actions → Run.
2026-03-27 23:41:22 +03:00
alexei.dolgolyov 3c2efd5e4a refactor: move Weather and Home Assistant sources to Integrations tree group
Separates external service connections from utility entities in the
Streams tree navigation for clearer organization.
2026-03-27 23:21:40 +03:00
alexei.dolgolyov 2153dde4b7 feat: Home Assistant integration — WebSocket connection, automation conditions, UI
Add full Home Assistant integration via WebSocket API:
- HARuntime: persistent WebSocket client with auth, auto-reconnect, entity state cache
- HAManager: ref-counted runtime pool (like WeatherManager)
- HomeAssistantCondition: new automation trigger type matching entity states
- REST API: CRUD for HA sources + /test, /entities, /status endpoints
- /api/v1/system/integrations-status: combined MQTT + HA dashboard indicators
- Frontend: HA Sources tab in Streams, condition type in automation editor
- Modal editor with host, token, SSL, entity filters
- websockets>=13.0 dependency added
2026-03-27 22:42:48 +03:00
alexei.dolgolyov f3d07fc47f feat: donation banner, About tab, settings UI improvements
Lint & Test / test (push) Has been cancelled
- Dismissible donation/open-source banner after 3+ sessions (30-day snooze)
- New About tab in Settings: version, repo link, license info
- Centralize project URLs (REPO_URL, DONATE_URL) in __init__.py, served via /health
- Center settings tab bar, reduce tab padding for 6-tab fit
- External URL save button: icon button instead of full-width text button
- Remove redundant settings footer close button
- Footer "Source Code" link replaced with "About" opening settings
- i18n keys for en/ru/zh
2026-03-27 21:09:34 +03:00
alexei.dolgolyov f61a0206d4 feat: custom file drop zone for asset upload modal; fix review issues
Lint & Test / test (push) Successful in 1m29s
Replace plain <input type="file"> with a styled drag-and-drop zone
featuring hover/drag states, file info display, and remove button.

Fix 10 review issues: add 401 handling to upload, remove non-null
assertions, add missing i18n keys (common.remove, error.play_failed,
error.download_failed), normalize close button glyphs to &#x2715;,
i18n the dropzone aria-label, replace silent error catches with toast
notifications, use DataTransfer for cross-browser file assignment.
2026-03-26 21:43:08 +03:00
alexei.dolgolyov f345687600 chore: remove python3.11 version pin from pre-commit config
Lint & Test / test (push) Successful in 1m6s
2026-03-26 20:41:34 +03:00
alexei.dolgolyov e2e1107df7 feat: asset-based image/video sources, notification sounds, UI improvements
Lint & Test / test (push) Has been cancelled
- Replace URL-based image_source/url fields with image_asset_id/video_asset_id
  on StaticImagePictureSource and VideoCaptureSource (clean break, no migration)
- Resolve asset IDs to file paths at runtime via AssetStore.get_file_path()
- Add EntitySelect asset pickers for image/video in stream editor modal
- Add notification sound configuration (global sound + per-app overrides)
- Unify per-app color and sound overrides into single "Per-App Overrides" section
- Persist notification history between server restarts
- Add asset management system (upload, edit, delete, soft-delete)
- Replace emoji buttons with SVG icons throughout UI
- Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
2026-03-26 20:40:25 +03:00
alexei.dolgolyov c0853ce184 fix: improve command palette actions and automation condition button
Lint & Test / test (push) Successful in 1m16s
- Action items (start/stop, enable/disable) no longer close the palette
- Action items toggle state after success (Start→Stop, Enable→Disable)
- Toast z-index raised above command palette backdrop (3000→3500)
- Automation condition remove button uses ICON_TRASH SVG instead of ✕
2026-03-26 02:21:52 +03:00
alexei.dolgolyov 3e0bf8538c feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout
Lint & Test / test (push) Successful in 1m26s
API Input:
- Add interpolation mode (linear/nearest/none) for LED count mismatch
  between incoming data and device LED count
- New IconSelect in editor, i18n for en/ru/zh
- Mark crossfade as won't-do (client owns temporal transitions)
- Mark last-write-wins as already implemented

LED Preview:
- Fix zone-mode preview parsing composite wire format (0xFE header
  bytes were rendered as color data, garbling multi-zone previews)
- Fix _restoreLedPreviewState to handle zone-mode panels

FPS Charts:
- Seed target card charts from server metrics-history on first load
- Add fetchMetricsHistory() with 5s TTL cache shared across
  dashboard, targets, perf-charts, and graph-editor
- Fix chart padding: pass maxSamples per caller (120 for dashboard,
  30 for target cards) instead of hardcoded 120
- Fix dashboard chart empty on tab switch (always fetch server history)
- Left-pad with nulls for consistent chart width across targets

Dashboard:
- Fix metrics row alignment (grid layout with fixed column widths)
- Fix FPS label overflow into uptime column
2026-03-26 02:06:49 +03:00
alexei.dolgolyov be4c98b543 fix: show template name instead of ID in filter list and card badges
Lint & Test / test (push) Successful in 1m7s
Collapsed filter cards in the modal showed raw template IDs (e.g.
pp_cb72e227) instead of resolving select options to their labels.
Card filter chain badges now include the referenced template name.
2026-03-25 23:56:40 +03:00
alexei.dolgolyov dca2d212b1 fix: clip graph node title and subtitle to prevent overflow
Long entity names overflowed past the icon area on graph cards.
Added SVG clipPath to constrain text within the node bounds.
2026-03-25 23:56:30 +03:00
alexei.dolgolyov 53986f8d95 fix: replace emoji with SVG icons on weather and daylight cards
Weather card used  and 🌡 emoji, daylight card used 🕐 and .
Replaced with ICON_FAST_FORWARD, ICON_THERMOMETER, and ICON_CLOCK.
Added thermometer icon path.
2026-03-25 23:56:21 +03:00
alexei.dolgolyov a4a9f6f77f fix: send gradient_id instead of palette in effect transient preview
Lint & Test / test (push) Successful in 1m19s
The preview config was sending `palette` which defaults to "fire" on
the server, ignoring the user's selected gradient. Also removed the
dead fallback notification block and stale custom_palette check.
2026-03-25 23:43:33 +03:00
alexei.dolgolyov 9fcfdb8570 ci: use sparse checkout for release notes in release workflow
Only fetch RELEASE_NOTES.md instead of full repo checkout, and
simplify the file detection to a direct path check.
2026-03-25 23:43:31 +03:00
329 changed files with 45455 additions and 16320 deletions
+80
View File
@@ -0,0 +1,80 @@
name: Build Artifacts
on:
workflow_dispatch:
inputs:
version:
description: 'Version label (e.g. dev, 0.3.0-test)'
required: false
default: 'dev'
jobs:
build-windows:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends zip libportaudio2 nsis msitools
- name: Cross-build Windows distribution
run: |
chmod +x build-dist-windows.sh
./build-dist-windows.sh "v${{ inputs.version }}"
- uses: actions/upload-artifact@v3
with:
name: LedGrab-${{ inputs.version }}-win-x64
path: |
build/LedGrab-*.zip
build/LedGrab-*-setup.exe
retention-days: 90
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libportaudio2
- name: Build Linux distribution
run: |
chmod +x build-dist.sh
./build-dist.sh "v${{ inputs.version }}"
- uses: actions/upload-artifact@v3
with:
name: LedGrab-${{ inputs.version }}-linux-x64
path: build/LedGrab-*.tar.gz
retention-days: 90
+7 -6
View File
@@ -12,8 +12,11 @@ jobs:
outputs:
release_id: ${{ steps.create.outputs.release_id }}
steps:
- name: Checkout
- name: Fetch RELEASE_NOTES.md only
uses: actions/checkout@v4
with:
sparse-checkout: RELEASE_NOTES.md
sparse-checkout-cone-mode: false
- name: Create Gitea release
id: create
@@ -33,11 +36,9 @@ jobs:
REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
DOCKER_IMAGE="${SERVER_HOST}/${REPO}"
# Scan for RELEASE_NOTES.md (check repo root first, then recursively)
NOTES_FILE=$(find . -maxdepth 3 -name "RELEASE_NOTES.md" -type f | head -1)
if [ -n "$NOTES_FILE" ]; then
export RELEASE_NOTES=$(cat "$NOTES_FILE")
echo "Found release notes: $NOTES_FILE"
if [ -f RELEASE_NOTES.md ]; then
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
else
export RELEASE_NOTES=""
echo "No RELEASE_NOTES.md found"
-1
View File
@@ -4,7 +4,6 @@ repos:
hooks:
- id: black
args: [--line-length=100, --target-version=py311]
language_version: python3.11
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
-118
View File
@@ -1,118 +0,0 @@
# Feature Brainstorm — LED Grab
## New Automation Conditions (Profiles)
Right now profiles only trigger on **app detection**. High-value additions:
- **Time-of-day / Schedule** — "warm tones after sunset, off at midnight." Schedule-based value sources pattern already exists
- **Display state** — detect monitor on/off/sleep, auto-stop targets when display is off
- **System idle** — dim or switch to ambient effect after N minutes of no input
- **Sunrise/sunset** — fetch local solar times, drive circadian color temperature shifts
- **Webhook/MQTT trigger** — let external systems activate profiles without HA integration
## New Output Targets
Currently: WLED, Adalight, AmbileD, DDP. Potential:
- **MQTT publish** — generic IoT output, any MQTT subscriber becomes a target
- **Art-Net / sACN (E1.31)** — stage/theatrical lighting protocols, DMX controllers
- **OpenRGB** — control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
- **HTTP webhook** — POST color data to arbitrary endpoints
- **Recording target** — save color streams to file for playback later
## New Color Strip Sources
- **Spotify / media player** — album art color extraction or tempo-synced effects
- **Weather** — pull conditions from API, map to palettes (blue=rain, orange=sun, white=snow)
- **Camera / webcam** — border-sampling from camera feed for video calls or room-reactive lighting
- **Script source** — user-written JS/Python snippets producing color arrays per frame
- **Notification reactive** — flash/pulse on OS notifications (optional app filter)
## Processing Pipeline Extensions
- **Palette quantization** — force output to match a user-defined palette
- **Zone grouping** — merge adjacent LEDs into logical groups sharing one averaged color
- **Color temperature filter** — warm/cool shift separate from hue shift (circadian/mood)
- **Noise gate** — suppress small color changes below threshold, preventing shimmer on static content
## Multi-Instance & Sync
- **Multi-room sync** — multiple instances with shared clock for synchronized effects
- **Multi-display unification** — treat 2-3 monitors as single virtual display for seamless ambilight
- **Leader/follower mode** — one target's output drives others with optional delay (cascade)
## UX & Dashboard
- **PWA / mobile layout** — mobile-first layout + "Add to Home Screen" manifest
- **Scene presets** — bundled source + filters + brightness as one-click presets ("Movie night", "Gaming")
- **Live preview on dashboard** — miniature screen with LED colors rendered around its border
- **Undo/redo for calibration** — reduce frustration in the fiddly calibration editor
- **Drag-and-drop filter ordering** — reorder postprocessing filter chains visually
## API & Integration
- **WebSocket event bus** — broadcast all state changes over a single WS channel
- **OBS integration** — detect active scene, switch profiles; or use OBS virtual camera as source
- **Plugin system** — formalize extension points into documented plugin API with hot-reload
## Creative / Fun
- **Effect sequencer** — timeline-based choreography of effects, colors, and transitions
- **Music BPM sync** — lock effect speed to detected BPM (beat detection already exists)
- **Color extraction from image** — upload photo, extract palette, use as gradient/cycle source
- **Transition effects** — crossfade, wipe, or dissolve between sources/profiles instead of instant cut
---
## Deep Dive: Notification Reactive Source
**Type:** New `ColorStripSource` (`source_type: "notification"`) — normally outputs transparent RGBA, flashes on notification events. Designed to be used as a layer in a **composite source** so it overlays on top of a persistent base (gradient, effect, screen capture, etc.).
### Trigger modes (both active simultaneously)
1. **OS listener (Windows)**`pywinrt` + `Windows.UI.Notifications.Management.UserNotificationListener`. Runs in background thread, pushes events to source via queue. Windows-only for now; macOS (`pyobjc` + `NSUserNotificationCenter`) and Linux (`dbus` + `org.freedesktop.Notifications`) deferred to future.
2. **Webhook**`POST /api/v1/notifications/{source_id}/fire` with optional body `{ "app": "MyApp", "color": "#FF0000" }`. Always available, cross-platform by nature.
### Source config
```yaml
os_listener: true # enable Windows notification listener
app_filter:
mode: whitelist|blacklist # which apps to react to
apps: [Discord, Slack, Telegram]
app_colors: # user-configured app → color mapping
Discord: "#5865F2"
Slack: "#4A154B"
Telegram: "#26A5E4"
default_color: "#FFFFFF" # fallback when app has no mapping
effect: flash|pulse|sweep # visual effect type
duration_ms: 1500 # effect duration
```
### Effect rendering
Source outputs RGBA color array per frame:
- **Idle**: all pixels `(0,0,0,0)` — composite passes through base layer
- **Flash**: instant full-color, linear fade to transparent over `duration_ms`
- **Pulse**: sine fade in/out over `duration_ms`
- **Sweep**: color travels across the strip like a wave
Each notification starts its own mini-timeline from trigger timestamp (not sync clock).
### Overlap handling
New notification while previous effect is active → restart timer with new color. No queuing.
### App color resolution
1. Webhook body `color` field (explicit override) → highest priority
2. `app_colors` mapping by app name
3. `default_color` fallback
---
## Top Picks (impact vs effort)
1. **Time-of-day + idle profile conditions** — builds on existing profile/condition architecture
2. **MQTT output target** — opens the door to an enormous IoT ecosystem
3. **Scene presets** — purely frontend, bundles existing features into one-click UX
-1
View File
@@ -123,7 +123,6 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
```bash
pip install ".[perf]" # DXCam, BetterCam, WGC (Windows only)
pip install ".[notifications]" # OS notification capture
pip install ".[tray]" # System tray icon (Windows only)
pip install ".[dev]" # pytest, black, ruff (development)
```
+20
View File
@@ -121,6 +121,26 @@ Open **http://localhost:8080** to access the dashboard.
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup.
## Demo Mode
Demo mode runs the server with virtual devices, sample data, and isolated storage — useful for exploring the UI without real hardware.
Set the `WLED_DEMO` environment variable to `true`, `1`, or `yes`:
```bash
# Docker
docker compose run -e WLED_DEMO=true server
# Python
WLED_DEMO=true uvicorn wled_controller.main:app --host 0.0.0.0 --port 8081
# Windows (installed app)
set WLED_DEMO=true
LedGrab.bat
```
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data in `data/demo/` (separate from production data). It can run alongside the main server.
## Architecture
```text
+162 -6
View File
@@ -1,15 +1,171 @@
## v0.2.2 (2025-03-25)
## v0.3.0 (2026-04-08)
This release brings a major expansion of integrations and source types: Home Assistant (with light output targets), a unified Integrations tab, processed audio sources with 11 DSP filters, multi-instance MQTT, a game integration system, BindableFloat for universal value-source binding, and many new value source types. Plus a much-improved build and launcher on Windows.
### Features
- Add 4 built-in gradients, searchable gradient picker, cleaner modal titles ([a5e7a4e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a5e7a4e))
#### Home Assistant Integration
- Home Assistant integration with WebSocket connection, automation conditions, and UI ([2153dde](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/2153dde))
- HA light output targets — cast LED colors to Home Assistant lights ([cb9289f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/cb9289f))
- Entity picker for HA light mapping — searchable EntitySelect for light entities ([324a308](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/324a308))
- HA light target live color preview — per-entity swatches via WebSocket ([40751fe](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/40751fe))
- HA source cards use health-dot indicators ([e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e7c9a56))
#### Integrations & Tabs
- New **Integrations** tab and responsive icon-only tabs ([b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b7da4ab))
- Multi-instance MQTT — refactored from global config to entity model ([c59107c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c59107c))
- Game integration system ([492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/492bdb9))
#### Audio
- Processed audio sources — audio filter framework ([86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/86a9d34))
- 11 audio filters implemented ([eb94066](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/eb94066))
- Processed audio source model + runtime filter integration ([353c090](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/353c090), [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ab43578))
- Frontend audio processing templates + source type cleanup ([5534639](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5534639), [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/1ce0dc6))
- Music sync viz modes and `auto_gain` audio filter ([b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b04978a))
#### Value Sources
- **BindableFloat** — universal value source binding for all scalar properties ([8a17bb5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/8a17bb5))
- New value source types: HA entity, gradient map, strip extract ([384362c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/384362c))
- `system_metrics` value source type ([b6713be](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b6713be))
- Color value source test visualization ([f6c25cd](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f6c25cd))
- HA value source test — raw value axis + behavior IconSelect ([0a87371](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/0a87371))
- Value source card crosslinks + gradient_map test shows input value ([4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/4b7a8d7))
#### Sources & Assets
- Asset-based image/video sources, notification sounds, UI improvements ([e2e1107](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e2e1107))
- `math_wave` color strip source type ([ace2471](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ace2471))
- `api_input` LED interpolation; fixes for LED preview, FPS charts, dashboard layout ([3e0bf85](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e0bf85))
- Custom file drop zone for asset upload modal ([f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f61a020))
#### UI & UX
- Card glare effect on dashboard and perf chart cards ([ce53ca6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce53ca6))
- System theme option + toast timer overlap fix ([db5008a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/db5008a))
- Donation banner, About tab, settings UI improvements ([f3d07fc](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f3d07fc))
- Custom app icon for shortcuts and installer ([5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5f70302))
#### Runtime
- Port busy check before starting the server ([ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ea812bb))
### Bug Fixes
- Tray: replace tkinter messagebox with Win32 `MessageBoxW` ([d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d037a2e))
- Launcher: `start-hidden.vbs` must be ASCII + CRLF, use `python.exe` ([fc8ee34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fc8ee34))
- Launcher: set `PYTHONPATH` and `WLED_CONFIG_PATH` in `start-hidden.vbs` ([e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e262a8b))
- Weather CSS card shows empty source name after hard refresh ([6e8b159](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6e8b159))
- Replace HA test icon with refresh; make automation rules collapsible ([edc6d27](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/edc6d27))
- `pystray` is a core dependency on Windows (no longer optional extra) ([99460a8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/99460a8))
- Audio tree structure, filter i18n, IconSelect for filter options ([af2c89c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/af2c89c))
- Reference check before deleting audio processing template ([d04192f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d04192f))
- Device card header layout — URL badge overflow and hide button gap ([11d5d6b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/11d5d6b))
- KC color strip test preview uses `LiveStreamManager` instead of raw engine ([a9e6e8c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a9e6e8c))
- Composite layer opacity/brightness widgets + CSS layout ([78ce6c8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/78ce6c8))
- HA light target — brightness source, `transition=0`, dashboard type label ([381ee75](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/381ee75))
- Rename HA Lights → Home Assistant, HA Light Targets → Light Targets ([89d1b13](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89d1b13))
- Command palette actions and automation condition button ([c0853ce](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c0853ce))
- Show template name instead of ID in filter list and card badges ([be4c98b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/be4c98b))
- Clip graph node title and subtitle to prevent overflow ([dca2d21](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/dca2d21))
- Replace emoji with SVG icons on weather and daylight cards ([53986f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/53986f8))
- Send `gradient_id` instead of palette in effect transient preview ([a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a4a9f6f))
---
### Development / Internal
#### Build
- Drop `packaging` dependency, inline version parsing ([d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d4ffe2e))
- Fix shell syntax error in `smoke_test_imports` heredoc ([feb91ad](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/feb91ad))
- Keep `.py` sources; smoke test skips uninstalled modules ([17c5c02](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/17c5c02))
- Stop stripping `zeroconf/_services`; add import smoke test ([fd6776a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fd6776a))
- Stop stripping `numpy.lib`/`linalg` from site-packages ([9f34ffb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9f34ffb))
- Normalize non-PEP440 versions, fix `.py`/`compileall` ordering, wipe NSIS payload dirs ([b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b5842e6))
#### CI
- Add manual build workflow for testing artifacts ([fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fb98e6e))
- Use sparse checkout for release notes in release workflow ([9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9fcfdb8))
#### Refactoring
- Split `color-strips.ts` into focused modules under `color-strips/` folder ([7a9c368](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/7a9c368))
- Key colors targets → CSS source type; HA target improvements ([3e6760f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e6760f))
- Move Weather and Home Assistant sources to Integrations tree group ([3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3c2efd5))
#### Tests
- Isolate tests from production database ([992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/992495e))
- Processed audio sources: phase 7 testing and polish + phase 8 design review ([ce1f484](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce1f484), [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6b0e4e5))
#### Chores
- Remove processed-audio-sources plan files ([89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89990f8))
- Remove python3.11 version pin from pre-commit config ([f345687](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f345687))
---
<details>
<summary>All Commits</summary>
<summary>All Commits (63)</summary>
| Hash | Message | Author |
|------|---------|--------|
| [a5e7a4e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a5e7a4e) | feat: add 4 built-in gradients, searchable gradient picker, cleaner modal titles | alexei.dolgolyov |
| Hash | Message |
|------|---------|
| [d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d037a2e) | fix(tray): replace tkinter messagebox with Win32 MessageBoxW |
| [fc8ee34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fc8ee34) | fix(launcher): start-hidden.vbs must be ASCII + CRLF, use python.exe |
| [e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e262a8b) | fix(launcher): set PYTHONPATH and WLED_CONFIG_PATH in start-hidden.vbs |
| [d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d4ffe2e) | refactor: drop packaging dependency, inline version parsing |
| [feb91ad](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/feb91ad) | fix(build): fix shell syntax error in smoke_test_imports heredoc |
| [17c5c02](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/17c5c02) | fix(build): keep .py sources + make smoke test skip uninstalled modules |
| [fd6776a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fd6776a) | fix(build): stop stripping zeroconf/_services + add import smoke test |
| [9f34ffb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9f34ffb) | fix(build): stop stripping numpy.lib/linalg from site-packages |
| [b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b5842e6) | fix(build): normalize non-PEP440 versions, fix .py/compileall ordering, wipe NSIS payload dirs |
| [7a9c368](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/7a9c368) | refactor: split color-strips.ts into focused modules under color-strips/ folder |
| [ce53ca6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce53ca6) | feat: add card glare effect to dashboard and perf chart cards |
| [b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b04978a) | feat: add music sync viz modes and auto_gain audio filter |
| [6e8b159](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6e8b159) | fix: weather CSS card shows empty source name after hard refresh |
| [ace2471](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ace2471) | feat: add math_wave color strip source type |
| [edc6d27](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/edc6d27) | fix: replace HA test icon with refresh, make automation rules collapsible |
| [b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b7da4ab) | feat: add Integrations tab and responsive icon-only tabs |
| [99460a8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/99460a8) | fix: make pystray a core dependency on Windows instead of optional extra |
| [89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89990f8) | chore: remove processed-audio-sources plan files |
| [af2c89c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/af2c89c) | fix: audio tree structure, filter i18n, and IconSelect for filter options |
| [d04192f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d04192f) | fix: add reference check before deleting audio processing template |
| [992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/992495e) | fix: isolate tests from production database |
| [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6b0e4e5) | feat(processed-audio-sources): phase 8 - frontend design consistency review |
| [ce1f484](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce1f484) | feat(processed-audio-sources): phase 7 - testing and polish |
| [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/1ce0dc6) | feat(processed-audio-sources): phase 6 - frontend source type cleanup |
| [5534639](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5534639) | feat(processed-audio-sources): phase 5 - frontend audio processing templates |
| [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ab43578) | feat(processed-audio-sources): phase 4 - runtime filter integration |
| [353c090](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/353c090) | feat(processed-audio-sources): phase 3 - processed audio source model |
| [eb94066](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/eb94066) | feat(processed-audio-sources): phase 2 - implement 11 audio filters |
| [86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/86a9d34) | feat(processed-audio-sources): phase 1 - audio filter framework |
| [c59107c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c59107c) | feat: refactor MQTT from global config to multi-instance entity model |
| [e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e7c9a56) | feat: HA source cards use health-dot indicators |
| [492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/492bdb9) | feat: game integration system |
| [b6713be](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b6713be) | feat: system_metrics value source type |
| [db5008a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/db5008a) | feat: system theme option + fix toast timer overlap |
| [4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/4b7a8d7) | feat: value source card crosslinks + gradient_map test shows input value |
| [f6c25cd](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f6c25cd) | feat: color value source test visualization |
| [0a87371](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/0a87371) | feat: HA value source test — raw value axis + behavior IconSelect |
| [11d5d6b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/11d5d6b) | fix: device card header layout — URL badge overflow and hide button gap |
| [384362c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/384362c) | feat: new value source types (HA entity, gradient map, strip extract) + UI fixes |
| [ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ea812bb) | feat: check if port is busy before starting the server |
| [a9e6e8c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a9e6e8c) | fix: KC color strip test preview — use LiveStreamManager instead of raw engine |
| [78ce6c8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/78ce6c8) | fix: composite layer opacity/brightness widgets + CSS layout |
| [8a17bb5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/8a17bb5) | feat: BindableFloat — universal value source binding for all scalar properties |
| [5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5f70302) | feat: use custom app icon for shortcuts and installer |
| [40751fe](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/40751fe) | feat: HA light target live color preview — per-entity swatches via WebSocket |
| [381ee75](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/381ee75) | fix: HA light target — brightness source, transition=0, dashboard type label |
| [3e6760f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e6760f) | refactor: key colors targets → CSS source type, HA target improvements |
| [89d1b13](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89d1b13) | fix: rename HA Lights → Home Assistant, HA Light Targets → Light Targets |
| [324a308](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/324a308) | feat: entity picker for HA light mapping — searchable EntitySelect for light entities |
| [cb9289f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/cb9289f) | feat: HA light output targets — cast LED colors to Home Assistant lights |
| [fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fb98e6e) | ci: add manual build workflow for testing artifacts |
| [3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3c2efd5) | refactor: move Weather and Home Assistant sources to Integrations tree group |
| [2153dde](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/2153dde) | feat: Home Assistant integration — WebSocket connection, automation conditions, UI |
| [f3d07fc](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f3d07fc) | feat: donation banner, About tab, settings UI improvements |
| [f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f61a020) | feat: custom file drop zone for asset upload modal; fix review issues |
| [f345687](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f345687) | chore: remove python3.11 version pin from pre-commit config |
| [e2e1107](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e2e1107) | feat: asset-based image/video sources, notification sounds, UI improvements |
| [c0853ce](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c0853ce) | fix: improve command palette actions and automation condition button |
| [3e0bf85](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e0bf85) | feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout |
| [be4c98b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/be4c98b) | fix: show template name instead of ID in filter list and card badges |
| [dca2d21](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/dca2d21) | fix: clip graph node title and subtitle to prevent overflow |
| [53986f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/53986f8) | fix: replace emoji with SVG icons on weather and daylight cards |
| [a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a4a9f6f) | fix: send gradient_id instead of palette in effect transient preview |
| [9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9fcfdb8) | ci: use sparse checkout for release notes in release workflow |
</details>
+10 -8
View File
@@ -13,10 +13,11 @@
## Donation / Open-Source Banner
- [ ] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
- [ ] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
- [ ] Remember dismissal in localStorage so it doesn't reappear every session
- [ ] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
- [x] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
- [x] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
- [x] Remember dismissal in localStorage so it doesn't reappear every session
- [x] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
- [ ] Configure `DONATE_URL` and `REPO_URL` constants in `donation.ts` once platform is chosen
---
@@ -81,9 +82,9 @@ Need to research HAOS communication options first (WebSocket API, REST API, MQTT
### `api_input`
- [ ] Crossfade transition when new data arrives
- [ ] Interpolation when incoming LED count differs from strip count
- [ ] Last-write-wins from any client (no multi-source blending)
- [x] ~~Crossfade transition~~ — won't do: external client owns temporal transitions; crossfading on our side would double-smooth
- [x] Interpolation when incoming LED count differs from strip count (linear/nearest/none modes)
- [x] Last-write-wins from any client — already the default behavior (push overwrites buffer)
## Architectural / Pipeline
@@ -110,5 +111,6 @@ Needs deeper design discussion. Likely a new entity type `ColorStripSourceTransi
## Remaining Open Discussion
1. **`home_assistant` source** — Need to research HAOS communication protocols first
1. ~~**`home_assistant` source** — Need to research HAOS communication protocols first~~ **DONE** — WebSocket API chosen, connection layer + automation condition + UI implemented
2. **Transition engine** — Design as `ColorStripSourceTransition` entity: what transition types? (crossfade, wipe, dissolve?) How does a target reference its transition config? How do automations trigger it?
3. **Home Assistant output targets** — Investigate casting LED colors TO Home Assistant lights (reverse direction). Use HA `light.turn_on` service call with `rgb_color` via WebSocket API. Could enable: ambient lighting on HA-controlled bulbs (Hue, WLED via HA, Zigbee lights), room-by-room color sync, whole-home ambient scenes. Need to research: rate limiting (don't spam HA with 30fps updates), grouping multiple lights, brightness/color_temp mapping, transition parameter support.
-37
View File
@@ -1,37 +0,0 @@
# Build Size Reduction
## Phase 1: Quick Wins (build scripts)
- [x] Strip unused NumPy submodules (polynomial, linalg, ma, lib, distutils)
- [x] Strip debug symbols from .pyd/.dll/.so files
- [x] Remove zeroconf service database
- [x] Remove .py source from site-packages after compiling to .pyc
- [x] Strip unused PIL image plugins (keep JPEG/PNG/ICO/BMP for tray)
## Phase 2: Replace Pillow with cv2
- [x] Create `utils/image_codec.py` with cv2-based image helpers
- [x] Replace PIL in `_preview_helpers.py`
- [x] Replace PIL in `picture_sources.py`
- [x] Replace PIL in `color_strip_sources.py`
- [x] Replace PIL in `templates.py`
- [x] Replace PIL in `postprocessing.py`
- [x] Replace PIL in `output_targets_keycolors.py`
- [x] Replace PIL in `kc_target_processor.py`
- [x] Replace PIL in `pixelate.py` filter
- [x] Replace PIL in `downscaler.py` filter
- [x] Replace PIL in `scrcpy_engine.py`
- [x] Replace PIL in `live_stream_manager.py`
- [x] Move Pillow from core deps to [tray] optional in pyproject.toml
- [x] Make PIL import conditional in `tray.py`
- [x] Move opencv-python-headless to core dependencies
## Phase 4: OpenCV stripping (build scripts)
- [x] Strip ffmpeg DLL, Haar cascades, dev files (already existed)
- [x] Strip typing stubs (already existed)
## Verification
- [x] Lint: `ruff check src/ tests/ --fix`
- [x] Tests: 341 passed
+140 -7
View File
@@ -24,6 +24,14 @@ detect_version() {
VERSION_CLEAN="${version#v}"
# Normalize non-PEP440 version labels (e.g. "dev", "nightly", "snapshot")
# to a valid PEP440 dev release. Without this, pip/setuptools rejects the
# pyproject.toml with: `project.version` must be pep440.
if ! [[ "$VERSION_CLEAN" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|\.dev|\.post)[0-9]+)*(\+[a-zA-Z0-9.]+)?$ ]]; then
echo " Warning: '$VERSION_CLEAN' is not PEP440-compliant, using 0.0.0.dev0"
VERSION_CLEAN="0.0.0.dev0"
fi
# Stamp the resolved version into pyproject.toml so that
# importlib.metadata reads the correct value at runtime.
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
@@ -102,8 +110,12 @@ cleanup_site_packages() {
fi
# ── NumPy ────────────────────────────────────────────────
# Remove unused submodules (only core, fft, random are used)
for mod in polynomial linalg ma lib distutils f2py typing _pyinstaller; do
# Only strip modules that are safely unused by numpy's own import chain.
# DO NOT strip: lib, linalg, ma, matrixlib — numpy.__init__ imports them
# transitively (e.g. matrixlib → defmatrix → linalg), so removing any of
# these breaks `import numpy` itself, cascading into every downstream
# module. Learned the hard way in the v0.0.0.dev0 Windows build.
for mod in polynomial distutils f2py typing _pyinstaller; do
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
done
rm -rf "$sp_dir/numpy/tests" "$sp_dir/numpy/*/tests" 2>/dev/null || true
@@ -119,7 +131,11 @@ cleanup_site_packages() {
done
# ── zeroconf ─────────────────────────────────────────────
rm -rf "$sp_dir/zeroconf/_services" 2>/dev/null || true
# DO NOT strip zeroconf/_services — the compiled Cython _listener.pyd
# imports from it, and the import fails at runtime with:
# ModuleNotFoundError: No module named 'zeroconf._services'
# Same class of bug as numpy — "presumed unused" submodule is actually
# imported internally by the package's own compiled code.
# ── Strip debug symbols ──────────────────────────────────
if command -v strip &>/dev/null; then
@@ -127,10 +143,6 @@ cleanup_site_packages() {
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
fi
# ── Remove .py source (keep .pyc bytecode) ───────────────
echo " Removing .py source from site-packages (keeping .pyc)..."
find "$sp_dir" -name "*.py" ! -name "__init__.py" -delete 2>/dev/null || true
# ── Remove wled_controller if pip-installed ───────────────
rm -rf "$sp_dir"/wled_controller* "$sp_dir"/wled*.dist-info 2>/dev/null || true
@@ -138,3 +150,124 @@ cleanup_site_packages() {
cleaned_size=$(du -sh "$sp_dir" | cut -f1)
echo " Site-packages after cleanup: $cleaned_size"
}
# ── Pre-compile .py → .pyc (keep sources) ────────────────────
#
# MUST run AFTER cleanup_site_packages. Speeds up first startup by
# producing .pyc alongside .py. We deliberately do NOT delete .py
# sources afterwards:
#
# 1. OpenCV's loader does literal file I/O on cv2/config.py (not
# an import) — stripping it breaks `import cv2` with:
# "OpenCV loader: missing configuration file: ['config.py']".
# 2. Other packages may do similar tricks (inspect.getsource,
# runtime introspection, __file__-relative data loading).
# 3. The size saving (~30%) isn't worth the whack-a-mole of
# shipping broken installers. We already hit this with
# numpy.linalg and zeroconf._services — enough incidents.
#
# Args:
# $1 — directory to compile (site-packages or app/src)
# $2 — python executable to use (default: python3)
compile_and_strip_sources() {
local target_dir="$1"
local py_cmd="${2:-python3}"
if [ ! -d "$target_dir" ]; then
return 0
fi
echo " Pre-compiling Python bytecode in $(basename "$target_dir")..."
"$py_cmd" -m compileall -b -q "$target_dir" 2>/dev/null || {
echo " ERROR: compileall failed for $target_dir — aborting"
return 1
}
# Drop __pycache__ to save the duplicated PEP-3147 copies; the
# `-b` flag above placed legacy .pyc next to each .py, so nothing
# of value is lost here.
find "$target_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
}
# ── Import smoke test ────────────────────────────────────────
#
# Verifies that every top-level dependency that wled_controller actually
# uses can be imported from the stripped site-packages. Catches regressions
# where cleanup_site_packages removes a submodule that turns out to be
# imported internally by the package (e.g. numpy.linalg, zeroconf._services).
# Failing here is cheap; failing on a user's machine after install is not.
#
# Args:
# $1 — path to site-packages to test against
# $2 — python executable
# $3 — (optional) extra PYTHONPATH entry (e.g. app/src for wled_controller)
smoke_test_imports() {
local sp_dir="$1"
local py_cmd="${2:-python3}"
local extra_path="${3:-}"
echo " Running import smoke test..."
local pypath="$sp_dir"
if [ -n "$extra_path" ]; then
pypath="$extra_path:$sp_dir"
fi
# Modules that MUST import cleanly IF PRESENT. We don't enforce
# installation — Pillow for example is only a Windows dep. But if a
# module's top-level package dir exists in site-packages and we
# can't import it, that's a broken install and we abort.
local smoke_script
smoke_script=$(cat <<'PYEOF'
import importlib
import os
import sys
sp_dir = sys.argv[1]
# (module_name, site-packages path to check for presence)
candidates = [
('numpy', 'numpy'),
('numpy.linalg', 'numpy/linalg'),
('numpy.lib', 'numpy/lib'),
('numpy.matrixlib', 'numpy/matrixlib'),
('cv2', 'cv2'),
('fastapi', 'fastapi'),
('uvicorn', 'uvicorn'),
('starlette', 'starlette'),
('pydantic', 'pydantic'),
('zeroconf', 'zeroconf'),
('zeroconf._services', 'zeroconf/_services'),
('PIL', 'PIL'),
('PIL.Image', 'PIL'),
('yaml', 'yaml'),
]
tested = 0
skipped = 0
failed = []
for mod, path in candidates:
if not os.path.exists(os.path.join(sp_dir, path)):
skipped += 1
continue
try:
importlib.import_module(mod)
tested += 1
except Exception as e:
failed.append(f'{mod}: {type(e).__name__}: {e}')
if failed:
print('SMOKE TEST FAILED:', file=sys.stderr)
for f in failed:
print(f' {f}', file=sys.stderr)
sys.exit(1)
print(f' Smoke test passed ({tested} imported, {skipped} not installed)')
PYEOF
)
if ! PYTHONPATH="$pypath" "$py_cmd" -c "$smoke_script" "$sp_dir"; then
echo " ERROR: smoke test failed — site-packages is broken, aborting build"
return 1
fi
}
+23 -3
View File
@@ -273,6 +273,27 @@ rm -rf "$SITE_PACKAGES"/pythonwin 2>/dev/null || true
rm -f "$SITE_PACKAGES"/PyWin32.chm 2>/dev/null || true
find "$SITE_PACKAGES/winrt" -name "*.pyi" -delete 2>/dev/null || true
# Pre-compile and strip .py sources. MUST run AFTER cleanup (so we don't
# waste work compiling files about to be deleted). Uses host python —
# PYTHON_VERSION above must match the embedded Python major.minor or
# the generated .pyc will ImportError on the target.
compile_and_strip_sources "$SITE_PACKAGES" "python"
# Windows cross-build: host python can't load win_amd64 .pyd files, so
# we can't `import numpy` for real. Instead, check that the submodules
# known to be imported internally exist on disk — the same landmines that
# cost users a broken v0.0.0.dev0 installer.
echo " Verifying required submodules exist after cleanup..."
for required in \
"numpy/linalg" "numpy/lib" "numpy/matrixlib" "numpy/ma" \
"zeroconf/_services"; do
if [ ! -d "$SITE_PACKAGES/$required" ] && [ ! -f "$SITE_PACKAGES/$required.py" ] && [ ! -f "$SITE_PACKAGES/$required.pyc" ]; then
echo " ERROR: $required missing from site-packages — cleanup_site_packages removed something required. Aborting."
exit 1
fi
done
echo " All required submodules present."
WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l)
echo " Installed $WHEEL_COUNT packages"
@@ -286,10 +307,9 @@ build_frontend
echo "[8/9] Copying application files..."
copy_app_files
# Pre-compile Python bytecode for faster startup
echo " Pre-compiling Python bytecode..."
# Pre-compile app source for faster startup (keep .py too — app source
# is small and easier to debug in-place if a user reports an issue)
python -m compileall -b -q "$APP_DIR/src" 2>/dev/null || true
python -m compileall -b -q "$SITE_PACKAGES" 2>/dev/null || true
# ── Create launcher ──────────────────────────────────────────
+1 -1
View File
@@ -130,7 +130,7 @@ if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" }
# ── Install dependencies ──────────────────────────────────────
Write-Host "[5/8] Installing dependencies..."
$extras = "camera,notifications,tray"
$extras = "camera,notifications"
if (-not $SkipPerf) { $extras += ",perf" }
# Install the project (pulls all deps via pyproject.toml), then remove
+10
View File
@@ -53,6 +53,12 @@ SITE_PACKAGES=$(echo "$VENV_DIR"/lib/python*/site-packages)
# Clean up with shared function
cleanup_site_packages "$SITE_PACKAGES" "so" "so"
# Pre-compile and strip .py sources (must happen AFTER cleanup)
compile_and_strip_sources "$SITE_PACKAGES" "python"
# Fail loud if cleanup broke any required import
smoke_test_imports "$SITE_PACKAGES" "python"
# ── Build frontend ───────────────────────────────────────────
echo "[4/7] Building frontend..."
@@ -63,6 +69,10 @@ build_frontend
echo "[5/7] Copying application files..."
copy_app_files
# Pre-compile app source for faster startup (keep .py too — app source
# is small and easier to debug in-place if a user reports an issue)
python -m compileall -b -q "$APP_DIR/src" 2>/dev/null || true
# ── Create launcher ──────────────────────────────────────────
echo "[6/7] Creating launcher..."
-143
View File
@@ -1,143 +0,0 @@
# Auto-Update Plan — Phase 1: Check & Notify
> Created: 2026-03-25. Status: **planned, not started.**
## Backend Architecture
### Release Provider Abstraction
```
core/update/
release_provider.py — ABC: get_releases(), get_releases_page_url()
gitea_provider.py — Gitea REST API implementation
version_check.py — normalize_version(), is_newer() using packaging.version
update_service.py — Background asyncio task + state machine
```
**`ReleaseProvider` interface** — two methods:
- `get_releases(limit) → list[ReleaseInfo]` — fetch releases (newest first)
- `get_releases_page_url() → str` — link for "view on web"
**`GiteaReleaseProvider`** calls `GET {base_url}/api/v1/repos/{repo}/releases`. Swapping to GitHub later means implementing the same interface against `api.github.com`.
**Data models:**
```python
@dataclass(frozen=True)
class AssetInfo:
name: str # "LedGrab-v0.3.0-win-x64.zip"
size: int # bytes
download_url: str
@dataclass(frozen=True)
class ReleaseInfo:
tag: str # "v0.3.0"
version: str # "0.3.0"
name: str # "LedGrab v0.3.0"
body: str # release notes markdown
prerelease: bool
published_at: str # ISO 8601
assets: tuple[AssetInfo, ...]
```
### Version Comparison
`version_check.py` — normalize Gitea tags to PEP 440:
- `v0.3.0-alpha.1``0.3.0a1`
- `v0.3.0-beta.2``0.3.0b2`
- `v0.3.0-rc.3``0.3.0rc3`
Uses `packaging.version.Version` for comparison.
### Update Service
Follows the **AutoBackupEngine pattern**:
- Settings in `Database.get_setting("auto_update")`
- asyncio.Task for periodic checks
- 30s startup delay (avoid slowing boot)
- 60s debounce on manual checks
**State machine (Phase 1):** `IDLE → CHECKING → UPDATE_AVAILABLE`
No download/apply in Phase 1 — just detection and notification.
**Settings:** `enabled` (bool), `check_interval_hours` (float), `channel` ("stable" | "pre-release")
**Persisted state:** `dismissed_version`, `last_check` (survives restarts)
### API Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/system/update/status` | Current state + available version |
| `POST` | `/api/v1/system/update/check` | Trigger immediate check |
| `POST` | `/api/v1/system/update/dismiss` | Dismiss notification for current version |
| `GET` | `/api/v1/system/update/settings` | Get settings |
| `PUT` | `/api/v1/system/update/settings` | Update settings |
### Wiring
- New `get_update_service()` in `dependencies.py`
- `UpdateService` created in `main.py` lifespan, `start()`/`stop()` alongside other engines
- Router registered in `api/__init__.py`
- WebSocket event: `update_available` fired via `processor_manager.fire_event()`
## Frontend
### Version badge highlight
The existing `#server-version` pill in the header gets a CSS class `has-update` when a newer version exists — changes the background to `var(--warning-color)` with a subtle pulse, making it clickable to open the update panel in settings.
### Notification popup
On `server:update_available` WebSocket event (and on page load if status says `has_update` and not dismissed):
- A **persistent dismissible banner** slides in below the header (not the ephemeral 3s toast)
- Shows: "Version {x.y.z} is available" + [View Release Notes] + [Dismiss]
- Dismiss calls `POST /dismiss` and hides the bar for that version
- Stored in `localStorage` so it doesn't re-show after page refresh for dismissed versions
### Settings tab: "Updates"
New 5th tab in the settings modal:
- Current version display
- "Check for updates" button + spinner
- Channel selector (stable / pre-release) via IconSelect
- Auto-check toggle + interval selector
- When update available: release name, notes preview, link to releases page
### i18n keys
New `update.*` keys in `en.json`, `ru.json`, `zh.json` for all labels.
## Files to Create
| File | Purpose |
|------|---------|
| `core/update/__init__.py` | Package init |
| `core/update/release_provider.py` | Abstract provider interface + data models |
| `core/update/gitea_provider.py` | Gitea API implementation |
| `core/update/version_check.py` | Semver normalization + comparison |
| `core/update/update_service.py` | Background service + state machine |
| `api/routes/update.py` | REST endpoints |
| `api/schemas/update.py` | Pydantic request/response models |
## Files to Modify
| File | Change |
|------|--------|
| `api/__init__.py` | Register update router |
| `api/dependencies.py` | Add `get_update_service()` |
| `main.py` | Create & start/stop UpdateService in lifespan |
| `templates/modals/settings.html` | Add Updates tab |
| `static/js/features/settings.ts` | Update check/settings UI logic |
| `static/js/core/api.ts` | Version badge highlight on health check |
| `static/css/layout.css` | `.has-update` styles for version badge |
| `static/locales/en.json` | i18n keys |
| `static/locales/ru.json` | i18n keys |
| `static/locales/zh.json` | i18n keys |
## Future Phases (not in scope)
- **Phase 2**: Download & stage artifacts
- **Phase 3**: Apply update & restart (external updater script, NSIS silent mode)
- **Phase 4**: Checksums, "What's new" dialog, update history
+18 -4
View File
@@ -90,6 +90,8 @@ Plain `<select>` dropdowns should be enhanced with visual selectors depending on
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. **The `<select>` and the visual widget are two separate things — changing one does NOT automatically update the other.** After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
**CRITICAL pitfall — `<option>` elements required:** `IconSelect` does NOT create `<option>` elements in the native `<select>` — it only builds a visual popup grid. The native `<select>` must already contain matching `<option value="...">` elements (either from the Jinja2 template or added via JS) **before** `.value` is set. Setting `.value` on a `<select>` with no matching `<option>` **silently fails** — the value stays empty, and all downstream logic (section switching, auto-naming, type setup) breaks with no error. **When adding a new type to any IconSelect-enhanced `<select>`, you MUST add the `<option>` in the HTML template too.**
**Common pitfall:** Using a preset/palette selector (e.g. gradient preset dropdown or effect type picker) that changes the underlying `<select>` value but forgets to call `.setValue()` on the IconSelect — the visual grid still shows the old selection.
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
@@ -175,7 +177,7 @@ When adding **new tabs, sections, or major UI elements**, update the correspondi
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
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.ts` as a new export
3. Add a corresponding `ICON_*` constant in `icons.ts` using `_svg(P.myIcon)`
4. Import and use the constant in your feature module
@@ -213,7 +215,19 @@ Static HTML using `data-i18n` attributes is handled automatically by the i18n sy
- `fetchWithAuth('/devices/dev_123', { method: 'DELETE' })` → `DELETE /api/v1/devices/dev_123`
- Passing `/api/v1/gradients` results in **double prefix**: `/api/v1/api/v1/gradients` (404)
For raw `fetch()` without auth (rare), use the full path manually.
**NEVER use raw `fetch()` or `new Audio(url)` / `new Image()` for authenticated endpoints.** These bypass the auth token and will fail with 401. Always use `fetchWithAuth()` and convert to blob URLs when needed (e.g. for `<audio>`, `<img>`, or download links):
```typescript
// WRONG: no auth header — 401
const audio = new Audio(`${API_BASE}/assets/${id}/file`);
// CORRECT: fetch with auth, then create blob URL
const res = await fetchWithAuth(`/assets/${id}/file`);
const blob = await res.blob();
const audio = new Audio(URL.createObjectURL(blob));
```
The only exception is raw `fetch()` for multipart uploads where you must set `Content-Type` to let the browser handle the boundary — but still use `getHeaders()` for the auth token.
## Bundling & Development Workflow
@@ -266,11 +280,11 @@ See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, bro
### 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`.
Use `formatUptime(seconds)` from `core/ui.ts`. 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.
Use `formatCompact(n)` from `core/ui.ts`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
### Preventing layout shift
+3 -1
View File
@@ -11,7 +11,9 @@ Two independent server modes with separate configs, ports, and data directories:
| **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
Both can run simultaneously on different ports.
Demo mode can also be triggered via the `WLED_DEMO` environment variable (`true`, `1`, or `yes`). This works with any launch method — Python, Docker (`-e WLED_DEMO=true`), or the installed app (`set WLED_DEMO=true` before `LedGrab.bat`).
Both modes can run simultaneously on different ports.
## Restart Procedure
@@ -1,4 +1,5 @@
"""The LED Screen Controller integration."""
from __future__ import annotations
import logging
@@ -18,14 +19,12 @@ from .const import (
CONF_SERVER_URL,
CONF_API_KEY,
DEFAULT_SCAN_INTERVAL,
TARGET_TYPE_KEY_COLORS,
TARGET_TYPE_HA_LIGHT,
DATA_COORDINATOR,
DATA_WS_MANAGER,
DATA_EVENT_LISTENER,
)
from .coordinator import WLEDScreenControllerCoordinator
from .event_listener import EventStreamListener
from .ws_manager import KeyColorsWebSocketManager
_LOGGER = logging.getLogger(__name__)
@@ -56,8 +55,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
ws_manager = KeyColorsWebSocketManager(hass, server_url, api_key)
event_listener = EventStreamListener(hass, server_url, api_key, coordinator)
await event_listener.start()
@@ -68,11 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
model = (
"Key Colors Target"
if target_type == TARGET_TYPE_KEY_COLORS
else "LED Target"
)
if target_type == TARGET_TYPE_HA_LIGHT:
model = "HA Light Target"
else:
model = "LED Target"
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, target_id)},
@@ -98,9 +94,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
current_identifiers.add(scenes_identifier)
# Remove devices for targets that no longer exist
for device_entry in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
for device_entry in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
if not device_entry.identifiers & current_identifiers:
_LOGGER.info("Removing stale device: %s", device_entry.name)
device_registry.async_remove_device(device_entry.id)
@@ -109,20 +103,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
DATA_WS_MANAGER: ws_manager,
DATA_EVENT_LISTENER: event_listener,
}
# Track target and scene IDs to detect changes
known_target_ids = set(
coordinator.data.get("targets", {}).keys() if coordinator.data else []
)
known_target_ids = set(coordinator.data.get("targets", {}).keys() if coordinator.data else [])
known_scene_ids = set(
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
)
def _on_coordinator_update() -> None:
"""Manage WS connections and detect target list changes."""
"""Detect target/scene list changes and trigger reload."""
nonlocal known_target_ids, known_scene_ids
if not coordinator.data:
@@ -130,30 +121,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
targets = coordinator.data.get("targets", {})
# Start/stop WS connections for KC targets based on processing state
for target_id, target_data in targets.items():
info = target_data.get("info", {})
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
current_ids = set(targets.keys())
current_scene_ids = set(
p["id"] for p in coordinator.data.get("scene_presets", [])
)
current_scene_ids = set(p["id"] for p in coordinator.data.get("scene_presets", []))
if current_ids != known_target_ids or current_scene_ids != known_scene_ids:
known_target_ids = current_ids
known_scene_ids = current_scene_ids
_LOGGER.info("Target or scene list changed, reloading integration")
hass.async_create_task(
hass.config_entries.async_reload(entry.entry_id)
)
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
coordinator.async_add_listener(_on_coordinator_update)
@@ -167,9 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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", [])
}
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
@@ -180,10 +153,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
DOMAIN,
"set_leds",
handle_set_leds,
schema=vol.Schema({
vol.Required("source_id"): str,
vol.Required("segments"): list,
}),
schema=vol.Schema(
{
vol.Required("source_id"): str,
vol.Required("segments"): list,
}
),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -194,7 +169,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
await entry_data[DATA_WS_MANAGER].shutdown()
await entry_data[DATA_EVENT_LISTENER].shutdown()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -15,9 +15,8 @@ WS_MAX_RECONNECT_DELAY = 60 # seconds
# Target types
TARGET_TYPE_LED = "led"
TARGET_TYPE_KEY_COLORS = "key_colors"
TARGET_TYPE_HA_LIGHT = "ha_light"
# Data keys stored in hass.data[DOMAIN][entry_id]
DATA_COORDINATOR = "coordinator"
DATA_WS_MANAGER = "ws_manager"
DATA_EVENT_LISTENER = "event_listener"
@@ -1,4 +1,5 @@
"""Data update coordinator for LED Screen Controller."""
from __future__ import annotations
import asyncio
@@ -14,7 +15,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
DOMAIN,
DEFAULT_TIMEOUT,
TARGET_TYPE_KEY_COLORS,
)
_LOGGER = logging.getLogger(__name__)
@@ -74,27 +74,12 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
state = None
metrics = None
result: dict[str, Any] = {
return target_id, {
"info": target,
"state": state,
"metrics": metrics,
}
# Fetch rectangles for key_colors targets
if target.get("target_type") == TARGET_TYPE_KEY_COLORS:
kc_settings = target.get("key_colors_settings") or {}
template_id = kc_settings.get("pattern_template_id", "")
if template_id:
result["rectangles"] = await self._fetch_rectangles(
template_id
)
else:
result["rectangles"] = []
else:
result["rectangles"] = []
return target_id, result
results = await asyncio.gather(
*(fetch_target_data(t) for t in targets_list),
return_exceptions=True,
@@ -108,13 +93,11 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
targets_data[target_id] = data
# Fetch devices, CSS sources, value sources, and scene presets in parallel
devices_data, css_sources, value_sources, scene_presets = (
await asyncio.gather(
self._fetch_devices(),
self._fetch_css_sources(),
self._fetch_value_sources(),
self._fetch_scene_presets(),
)
devices_data, css_sources, value_sources, scene_presets = await asyncio.gather(
self._fetch_devices(),
self._fetch_css_sources(),
self._fetch_value_sources(),
self._fetch_scene_presets(),
)
return {
@@ -176,23 +159,6 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
resp.raise_for_status()
return await resp.json()
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=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("rectangles", [])
except Exception as err:
_LOGGER.warning(
"Failed to fetch pattern template %s: %s", template_id, err
)
return []
async def _fetch_devices(self) -> dict[str, dict[str, Any]]:
"""Fetch all devices with capabilities and brightness."""
try:
@@ -225,7 +191,8 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
except Exception as err:
_LOGGER.warning(
"Failed to fetch brightness for device %s: %s",
device_id, err,
device_id,
err,
)
return device_id, entry
@@ -256,7 +223,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
body = await resp.text()
_LOGGER.error(
"Failed to set brightness for device %s: %s %s",
device_id, resp.status, body,
device_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
@@ -273,25 +242,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
body = await resp.text()
_LOGGER.error(
"Failed to set color for device %s: %s %s",
device_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def set_kc_brightness(self, target_id: str, brightness: int) -> None:
"""Set brightness for a Key Colors target (0-255 mapped to 0.0-1.0)."""
brightness_float = round(brightness / 255, 4)
async with self.session.put(
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=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set KC brightness for target %s: %s %s",
target_id, resp.status, body,
device_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
@@ -353,7 +306,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
body = await resp.text()
_LOGGER.error(
"Failed to push colors to source %s: %s %s",
source_id, resp.status, body,
source_id,
resp.status,
body,
)
resp.raise_for_status()
@@ -369,7 +324,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
body = await resp.text()
_LOGGER.error(
"Failed to push segments to source %s: %s %s",
source_id, resp.status, body,
source_id,
resp.status,
body,
)
resp.raise_for_status()
@@ -384,7 +341,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
body = await resp.text()
_LOGGER.error(
"Failed to activate scene %s: %s %s",
preset_id, resp.status, body,
preset_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
@@ -401,7 +360,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
body = await resp.text()
_LOGGER.error(
"Failed to update source %s: %s %s",
source_id, resp.status, body,
source_id,
resp.status,
body,
)
resp.raise_for_status()
@@ -417,7 +378,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
body = await resp.text()
_LOGGER.error(
"Failed to update target %s: %s %s",
target_id, resp.status, body,
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
@@ -435,7 +398,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
body = await resp.text()
_LOGGER.error(
"Failed to start target %s: %s %s",
target_id, resp.status, body,
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
@@ -453,7 +418,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
body = await resp.text()
_LOGGER.error(
"Failed to stop target %s: %s %s",
target_id, resp.status, body,
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
@@ -1,4 +1,5 @@
"""Number platform for LED Screen Controller (device & KC target brightness)."""
"""Number platform for LED Screen Controller (device brightness & HA light settings)."""
from __future__ import annotations
import logging
@@ -10,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_HA_LIGHT
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -21,24 +22,24 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller brightness numbers."""
"""Set up LED Screen Controller number entities."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
entities: list[NumberEntity] = []
if coordinator.data and "targets" in coordinator.data:
devices = coordinator.data.get("devices") or {}
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
# KC target — brightness lives in key_colors_settings
entities.append(
WLEDScreenControllerKCBrightness(
coordinator, target_id, entry.entry_id,
)
)
if target_type == TARGET_TYPE_HA_LIGHT:
# HA Light target — expose tunable settings
entities.append(HALightUpdateRate(coordinator, target_id, entry.entry_id))
entities.append(HALightTransition(coordinator, target_id, entry.entry_id))
entities.append(HALightMinBrightness(coordinator, target_id, entry.entry_id))
entities.append(HALightColorTolerance(coordinator, target_id, entry.entry_id))
continue
# LED target — brightness lives on the device
@@ -56,7 +57,10 @@ async def async_setup_entry(
entities.append(
WLEDScreenControllerBrightness(
coordinator, target_id, device_id, entry.entry_id,
coordinator,
target_id,
device_id,
entry.entry_id,
)
)
@@ -117,53 +121,113 @@ class WLEDScreenControllerBrightness(CoordinatorEntity, NumberEntity):
await self.coordinator.set_brightness(self._device_id, int(value))
class WLEDScreenControllerKCBrightness(CoordinatorEntity, NumberEntity):
"""Brightness control for a Key Colors target."""
# --- HA Light target number entities ---
class _HALightNumberBase(CoordinatorEntity, NumberEntity):
"""Base class for HA Light target number entities."""
_attr_has_entity_name = True
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:brightness-6"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
*,
field_name: str,
) -> None:
"""Initialize the KC brightness number."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_brightness"
self._attr_translation_key = "brightness"
self._field_name = field_name
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
"""Return the current brightness value (0-255)."""
if not self.coordinator.data:
return None
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
target_data = self._get_target_data()
if not target_data:
return None
kc_settings = target_data.get("info", {}).get("key_colors_settings") or {}
brightness_float = kc_settings.get("brightness", 1.0)
return round(brightness_float * 255)
return target_data.get("info", {}).get(self._field_name)
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.data:
return False
return self._target_id in self.coordinator.data.get("targets", {})
return self._get_target_data() is not None
async def async_set_native_value(self, value: float) -> None:
"""Set brightness value."""
await self.coordinator.set_kc_brightness(self._target_id, int(value))
await self.coordinator.update_target(self._target_id, **{self._field_name: round(value, 2)})
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
class HALightUpdateRate(_HALightNumberBase):
"""Update rate (Hz) for an HA Light target."""
_attr_native_min_value = 0.5
_attr_native_max_value = 5.0
_attr_native_step = 0.5
_attr_native_unit_of_measurement = "Hz"
_attr_icon = "mdi:update"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="update_rate")
self._attr_unique_id = f"{target_id}_update_rate"
self._attr_translation_key = "ha_light_update_rate"
class HALightTransition(_HALightNumberBase):
"""Transition time (seconds) for an HA Light target."""
_attr_native_min_value = 0.0
_attr_native_max_value = 10.0
_attr_native_step = 0.1
_attr_native_unit_of_measurement = "s"
_attr_icon = "mdi:transition-masked"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="transition")
self._attr_unique_id = f"{target_id}_transition"
self._attr_translation_key = "ha_light_transition"
class HALightMinBrightness(_HALightNumberBase):
"""Minimum brightness threshold for an HA Light target."""
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1
_attr_icon = "mdi:brightness-4"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="min_brightness_threshold")
self._attr_unique_id = f"{target_id}_min_brightness"
self._attr_translation_key = "ha_light_min_brightness"
class HALightColorTolerance(_HALightNumberBase):
"""Color tolerance (RGB delta skip threshold) for an HA Light target."""
_attr_native_min_value = 0
_attr_native_max_value = 50
_attr_native_step = 1
_attr_icon = "mdi:palette-outline"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="color_tolerance")
self._attr_unique_id = f"{target_id}_color_tolerance"
self._attr_translation_key = "ha_light_color_tolerance"
@@ -1,4 +1,5 @@
"""Select platform for LED Screen Controller (CSS source & brightness source)."""
from __future__ import annotations
import logging
@@ -10,12 +11,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_HA_LIGHT
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
NONE_OPTION = "— None —"
NONE_OPTION = "\u2014 None \u2014"
async def async_setup_entry(
@@ -31,23 +32,20 @@ async def async_setup_entry(
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
# Only LED targets
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
continue
# Both LED and HA Light targets have a CSS source
entities.append(CSSSourceSelect(coordinator, target_id, entry.entry_id))
entities.append(
CSSSourceSelect(coordinator, target_id, entry.entry_id)
)
entities.append(
BrightnessSourceSelect(coordinator, target_id, entry.entry_id)
)
# Only LED targets have a brightness value source
if target_type != TARGET_TYPE_HA_LIGHT:
entities.append(BrightnessSourceSelect(coordinator, target_id, entry.entry_id))
async_add_entities(entities)
class CSSSourceSelect(CoordinatorEntity, SelectEntity):
"""Select entity for choosing a color strip source for an LED target."""
"""Select entity for choosing a color strip source for a target."""
_attr_has_entity_name = True
_attr_icon = "mdi:palette"
@@ -100,9 +98,7 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
if source_id is None:
_LOGGER.error("CSS source not found: %s", option)
return
await self.coordinator.update_target(
self._target_id, color_strip_source_id=source_id
)
await self.coordinator.update_target(self._target_id, color_strip_source_id=source_id)
def _name_to_id_map(self) -> dict[str, str]:
sources = (self.coordinator.data or {}).get("css_sources") or []
@@ -145,7 +141,12 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data:
return None
current_id = target_data["info"].get("brightness_value_source_id", "")
# BindableFloat: brightness is either a plain float or {"value": float, "source_id": str}
brightness = target_data["info"].get("brightness", "")
if isinstance(brightness, dict):
current_id = brightness.get("source_id", "")
else:
current_id = target_data["info"].get("brightness_value_source_id", "")
if not current_id:
return NONE_OPTION
sources = self.coordinator.data.get("value_sources") or []
@@ -165,13 +166,13 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
source_id = ""
else:
name_map = {
s["name"]: s["id"]
for s in (self.coordinator.data or {}).get("value_sources") or []
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
self._target_id,
brightness={"value": 1.0, "source_id": source_id} if source_id else 1.0,
)
@@ -1,8 +1,8 @@
"""Sensor platform for LED Screen Controller."""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from homeassistant.components.sensor import (
@@ -17,12 +17,10 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DOMAIN,
TARGET_TYPE_KEY_COLORS,
TARGET_TYPE_HA_LIGHT,
DATA_COORDINATOR,
DATA_WS_MANAGER,
)
from .coordinator import WLEDScreenControllerCoordinator
from .ws_manager import KeyColorsWebSocketManager
_LOGGER = logging.getLogger(__name__)
@@ -35,34 +33,19 @@ async def async_setup_entry(
"""Set up LED Screen Controller sensors."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
ws_manager: KeyColorsWebSocketManager = data[DATA_WS_MANAGER]
entities: list[SensorEntity] = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
entities.append(WLEDScreenControllerFPSSensor(coordinator, target_id, entry.entry_id))
entities.append(
WLEDScreenControllerFPSSensor(coordinator, target_id, entry.entry_id)
)
entities.append(
WLEDScreenControllerStatusSensor(
coordinator, target_id, entry.entry_id
)
WLEDScreenControllerStatusSensor(coordinator, target_id, entry.entry_id)
)
# Add color sensors for Key Colors targets
# Add mapped lights sensor for HA Light targets
info = target_data["info"]
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
rectangles = target_data.get("rectangles", [])
for rect in rectangles:
entities.append(
WLEDScreenControllerColorSensor(
coordinator=coordinator,
ws_manager=ws_manager,
target_id=target_id,
rectangle_name=rect["name"],
entry_id=entry.entry_id,
)
)
if info.get("target_type") == TARGET_TYPE_HA_LIGHT:
entities.append(HALightMappedLightsSensor(coordinator, target_id, entry.entry_id))
async_add_entities(entities)
@@ -177,79 +160,58 @@ class WLEDScreenControllerStatusSensor(CoordinatorEntity, SensorEntity):
return self.coordinator.data.get("targets", {}).get(self._target_id)
class WLEDScreenControllerColorSensor(CoordinatorEntity, SensorEntity):
"""Color sensor reporting the extracted screen color for a Key Colors rectangle."""
class HALightMappedLightsSensor(CoordinatorEntity, SensorEntity):
"""Sensor showing the number of mapped HA lights for an HA Light target."""
_attr_has_entity_name = True
_attr_icon = "mdi:palette"
_attr_icon = "mdi:lightbulb-group"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
ws_manager: KeyColorsWebSocketManager,
target_id: str,
rectangle_name: str,
entry_id: str,
) -> None:
"""Initialize the color sensor."""
"""Initialize the sensor."""
super().__init__(coordinator)
self._target_id = target_id
self._rectangle_name = rectangle_name
self._ws_manager = ws_manager
self._entry_id = entry_id
self._unregister_ws: Callable[[], None] | None = None
sanitized = rectangle_name.lower().replace(" ", "_").replace("-", "_")
self._attr_unique_id = f"{target_id}_{sanitized}_color"
self._attr_translation_key = "rectangle_color"
self._attr_translation_placeholders = {"rectangle_name": rectangle_name}
self._attr_unique_id = f"{target_id}_mapped_lights"
self._attr_translation_key = "mapped_lights"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
async def async_added_to_hass(self) -> None:
"""Register WS callback when entity is added."""
await super().async_added_to_hass()
self._unregister_ws = self._ws_manager.register_callback(
self._target_id, self._handle_color_update
)
async def async_will_remove_from_hass(self) -> None:
"""Unregister WS callback when entity is removed."""
if self._unregister_ws:
self._unregister_ws()
self._unregister_ws = None
await super().async_will_remove_from_hass()
def _handle_color_update(self, colors: dict) -> None:
"""Handle incoming color update from WebSocket."""
if self._rectangle_name in colors:
self.async_write_ha_state()
@property
def native_value(self) -> str | None:
"""Return the hex color string (e.g. #FF8800)."""
color = self._get_color()
if color is None:
def native_value(self) -> int | None:
"""Return the number of mapped lights."""
target_data = self._get_target_data()
if not target_data:
return None
return f"#{color['r']:02X}{color['g']:02X}{color['b']:02X}"
mappings = target_data.get("info", {}).get("light_mappings", [])
return len(mappings)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return r, g, b, brightness as attributes."""
color = self._get_color()
if color is None:
"""Return light mapping details as attributes."""
target_data = self._get_target_data()
if not target_data:
return {}
r, g, b = color["r"], color["g"], color["b"]
brightness = int(0.299 * r + 0.587 * g + 0.114 * b)
mappings = target_data.get("info", {}).get("light_mappings", [])
entity_ids = [m.get("entity_id", "") for m in mappings]
return {
"r": r,
"g": g,
"b": b,
"brightness": brightness,
"rgb_color": [r, g, b],
"entity_ids": entity_ids,
"mappings": [
{
"entity_id": m.get("entity_id", ""),
"led_start": m.get("led_start", 0),
"led_end": m.get("led_end", -1),
"brightness_scale": m.get("brightness_scale", 1.0),
}
for m in mappings
],
}
@property
@@ -257,16 +219,6 @@ class WLEDScreenControllerColorSensor(CoordinatorEntity, SensorEntity):
"""Return if entity is available."""
return self._get_target_data() is not None
def _get_color(self) -> dict[str, int] | None:
"""Get the current color for this rectangle from WS manager."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return None
if not target_data["state"].get("processing"):
return None
colors = self._ws_manager.get_latest_colors(self._target_id)
return colors.get(self._rectangle_name)
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
@@ -54,13 +54,25 @@
"unavailable": "Unavailable"
}
},
"rectangle_color": {
"name": "{rectangle_name} Color"
"mapped_lights": {
"name": "Mapped Lights"
}
},
"number": {
"brightness": {
"name": "Brightness"
},
"ha_light_update_rate": {
"name": "Update Rate"
},
"ha_light_transition": {
"name": "Transition"
},
"ha_light_min_brightness": {
"name": "Min Brightness"
},
"ha_light_color_tolerance": {
"name": "Color Tolerance"
}
},
"select": {
@@ -54,13 +54,25 @@
"unavailable": "Unavailable"
}
},
"rectangle_color": {
"name": "{rectangle_name} Color"
"mapped_lights": {
"name": "Mapped Lights"
}
},
"number": {
"brightness": {
"name": "Brightness"
},
"ha_light_update_rate": {
"name": "Update Rate"
},
"ha_light_transition": {
"name": "Transition"
},
"ha_light_min_brightness": {
"name": "Min Brightness"
},
"ha_light_color_tolerance": {
"name": "Color Tolerance"
}
},
"select": {
@@ -54,13 +54,25 @@
"unavailable": "Недоступен"
}
},
"rectangle_color": {
"name": "{rectangle_name} Цвет"
"mapped_lights": {
"name": "Привязанные светильники"
}
},
"number": {
"brightness": {
"name": "Яркость"
},
"ha_light_update_rate": {
"name": "Частота обновления"
},
"ha_light_transition": {
"name": "Переход"
},
"ha_light_min_brightness": {
"name": "Мин. яркость"
},
"ha_light_color_tolerance": {
"name": "Допуск цвета"
}
},
"select": {
@@ -1,136 +0,0 @@
"""WebSocket connection manager for Key Colors target color streams."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from collections.abc import Callable
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import WS_RECONNECT_DELAY, WS_MAX_RECONNECT_DELAY
_LOGGER = logging.getLogger(__name__)
class KeyColorsWebSocketManager:
"""Manages WebSocket connections for Key Colors target color streams."""
def __init__(
self,
hass: HomeAssistant,
server_url: str,
api_key: str,
) -> None:
self._hass = hass
self._server_url = server_url
self._api_key = api_key
self._connections: dict[str, asyncio.Task] = {}
self._callbacks: dict[str, list[Callable]] = {}
self._latest_colors: dict[str, dict[str, dict[str, int]]] = {}
self._shutting_down = False
def _get_ws_url(self, target_id: str) -> str:
"""Build WebSocket URL for a target."""
ws_base = self._server_url.replace("http://", "ws://").replace(
"https://", "wss://"
)
return f"{ws_base}/api/v1/output-targets/{target_id}/ws?token={self._api_key}"
async def start_listening(self, target_id: str) -> None:
"""Start WebSocket connection for a target."""
if target_id in self._connections:
return
task = self._hass.async_create_background_task(
self._ws_loop(target_id),
f"wled_screen_controller_ws_{target_id}",
)
self._connections[target_id] = task
async def stop_listening(self, target_id: str) -> None:
"""Stop WebSocket connection for a target."""
task = self._connections.pop(target_id, None)
if task:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
self._latest_colors.pop(target_id, None)
def register_callback(
self, target_id: str, callback: Callable
) -> Callable[[], None]:
"""Register a callback for color updates. Returns unregister function."""
self._callbacks.setdefault(target_id, []).append(callback)
def unregister() -> None:
cbs = self._callbacks.get(target_id)
if cbs and callback in cbs:
cbs.remove(callback)
return unregister
def get_latest_colors(self, target_id: str) -> dict[str, dict[str, int]]:
"""Get latest colors for a target."""
return self._latest_colors.get(target_id, {})
async def _ws_loop(self, target_id: str) -> None:
"""WebSocket connection loop with reconnection."""
delay = WS_RECONNECT_DELAY
session = async_get_clientsession(self._hass)
while not self._shutting_down:
try:
url = self._get_ws_url(target_id)
async with session.ws_connect(url) as ws:
delay = WS_RECONNECT_DELAY # reset on successful connect
_LOGGER.debug("WS connected for target %s", target_id)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
self._handle_message(target_id, msg.data)
elif msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.ERROR,
):
break
except asyncio.CancelledError:
raise
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
_LOGGER.debug("WS connection error for %s: %s", target_id, err)
except Exception as err:
_LOGGER.error("Unexpected WS error for %s: %s", target_id, err)
if self._shutting_down:
break
await asyncio.sleep(delay)
delay = min(delay * 2, WS_MAX_RECONNECT_DELAY)
def _handle_message(self, target_id: str, raw: str) -> None:
"""Handle incoming WebSocket message."""
try:
data = json.loads(raw)
except json.JSONDecodeError:
return
if data.get("type") != "colors_update":
return
colors: dict[str, Any] = data.get("colors", {})
self._latest_colors[target_id] = colors
for cb in self._callbacks.get(target_id, []):
try:
cb(colors)
except Exception:
_LOGGER.exception("Error in WS color callback for %s", target_id)
async def shutdown(self) -> None:
"""Stop all WebSocket connections."""
self._shutting_down = True
for target_id in list(self._connections):
await self.stop_listening(target_id)
+19 -3
View File
@@ -30,6 +30,8 @@ SetCompressor /SOLID lzma
; ── Modern UI Configuration ─────────────────────────────────
!define MUI_ICON "server\src\wled_controller\static\icons\icon.ico"
!define MUI_UNICON "server\src\wled_controller\static\icons\icon.ico"
!define MUI_ABORTWARNING
; ── Pages ───────────────────────────────────────────────────
@@ -85,6 +87,18 @@ Section "!${APPNAME} (required)" SecCore
SetOutPath "$INSTDIR"
; Wipe prior payload dirs before extracting. NSIS File /r MERGES files
; on top of existing ones — on an upgrade, stale .pyc/.pyd from the old
; version (and any files removed or renamed since) would survive,
; producing a half-old/half-new install that presents as "version
; mismatch" or "duplicate package" ImportErrors at runtime.
; IMPORTANT: only touch payload dirs — never $INSTDIR\data or
; $INSTDIR\logs (user config must be preserved across upgrades).
RMDir /r "$INSTDIR\python"
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\LedGrab.bat"
; Copy the entire portable build
File /r "build\LedGrab\python"
File /r "build\LedGrab\app"
@@ -102,7 +116,7 @@ Section "!${APPNAME} (required)" SecCore
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\pythonw.exe" 0
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
; Registry: install location + Add/Remove Programs entry
@@ -117,6 +131,8 @@ Section "!${APPNAME} (required)" SecCore
"UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"InstallLocation" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayIcon" "$INSTDIR\app\src\wled_controller\static\icons\icon.ico"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"Publisher" "Alexei Dolgolyov"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
@@ -136,13 +152,13 @@ SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\pythonw.exe" 0
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 0
SectionEnd
Section "Start with Windows" SecAutostart
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\pythonw.exe" 0
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 0
SectionEnd
; ── Section Descriptions ────────────────────────────────────
-30
View File
@@ -1,30 +0,0 @@
# Feature Context: Demo Mode
## Current State
Starting implementation. No changes made yet.
## Key Architecture Notes
- `EngineRegistry` (class-level dict) holds capture engines, auto-registered in `capture_engines/__init__.py`
- `AudioEngineRegistry` (class-level dict) holds audio engines, auto-registered in `audio/__init__.py`
- `LEDDeviceProvider` instances registered via `register_provider()` in `led_client.py`
- Already has `MockDeviceProvider` + `MockClient` (device type "mock") for testing
- Config is `pydantic_settings.BaseSettings` in `config.py`, loaded from YAML + env vars
- Frontend header in `templates/index.html` line 27-31: title + version badge
- Frontend bundle: `cd server && npm run build` (esbuild)
- Data stored as JSON in `data/` directory, paths configured via `StorageConfig`
## Temporary Workarounds
- None yet
## Cross-Phase Dependencies
- Phase 1 (config flag) is foundational — all other phases depend on `is_demo_mode()`
- Phase 2 & 3 (engines) can be done independently of each other
- Phase 4 (seed data) depends on knowing what entities to create, which is informed by phases 2-3
- Phase 5 (frontend) depends on the system info API field from phase 1
- Phase 6 (engine resolution) depends on engines existing from phases 2-3
## Implementation Notes
- Demo mode activated via `WLED_DEMO=true` env var or `demo: true` in YAML config
- Isolated data directory `data/demo/` keeps demo entities separate from real config
- Demo engines use `ENGINE_TYPE = "demo"` and are always registered but return `is_available() = True` only in demo mode
- The existing `MockDeviceProvider`/`MockClient` can be reused or extended for demo device output
-44
View File
@@ -1,44 +0,0 @@
# Feature: Demo Mode
**Branch:** `feature/demo-mode`
**Base branch:** `master`
**Created:** 2026-03-20
**Status:** 🟡 In Progress
**Strategy:** Big Bang
**Mode:** Automated
**Execution:** Orchestrator
## Summary
Add a demo mode that allows users to explore and test the app without real hardware. Virtual capture engines, audio engines, and device providers replace real hardware. An isolated data directory with seed data provides a fully populated sandbox. A visual indicator in the UI makes it clear the app is running in demo mode.
## Build & Test Commands
- **Build (frontend):** `cd server && npm run build`
- **Typecheck (frontend):** `cd server && npm run typecheck`
- **Test (backend):** `cd server && python -m pytest ../tests/ -x`
- **Server start:** `cd server && python -m wled_controller.main`
## Phases
- [x] Phase 1: Demo Mode Config & Flag [domain: backend] → [subplan](./phase-1-config-flag.md)
- [x] Phase 2: Virtual Capture Engine [domain: backend] → [subplan](./phase-2-virtual-capture-engine.md)
- [x] Phase 3: Virtual Audio Engine [domain: backend] → [subplan](./phase-3-virtual-audio-engine.md)
- [x] Phase 4: Demo Device Provider & Seed Data [domain: backend] → [subplan](./phase-4-demo-device-seed-data.md)
- [x] Phase 5: Frontend Demo Indicator & Sandbox UX [domain: fullstack] → [subplan](./phase-5-frontend-demo-ux.md)
- [x] Phase 6: Demo-only Engine Resolution [domain: backend] → [subplan](./phase-6-engine-resolution.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Config & Flag | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 2: Virtual Capture Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 3: Virtual Audio Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 4: Demo Device & Seed Data | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 5: Frontend Demo UX | fullstack | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 6: Engine Resolution | backend | ✅ Done | ✅ | ✅ | ⬜ |
## Final Review
- [ ] Comprehensive code review
- [ ] Full build passes
- [ ] Full test suite passes
- [ ] Merged to `master`
-42
View File
@@ -1,42 +0,0 @@
# Phase 1: Demo Mode Config & Flag
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Add a `demo` boolean flag to the application configuration and expose it to the frontend via the system info API. When demo mode is active, the server uses an isolated data directory so demo entities don't pollute real user data.
## Tasks
- [ ] Task 1: Add `demo: bool = False` field to `Config` class in `config.py`
- [ ] Task 2: Add a module-level helper `is_demo_mode() -> bool` in `config.py` for easy import
- [ ] Task 3: Modify `StorageConfig` path resolution: when `demo=True`, prefix all storage paths with `data/demo/` instead of `data/`
- [ ] Task 4: Expose `demo_mode: bool` in the existing `GET /api/v1/system/info` endpoint response
- [ ] Task 5: Add `WLED_DEMO=true` env var support (already handled by pydantic-settings env prefix `WLED_`)
## Files to Modify/Create
- `server/src/wled_controller/config.py` — Add `demo` field, `is_demo_mode()` helper, storage path override
- `server/src/wled_controller/api/routes/system.py` — Add `demo_mode` to system info response
- `server/src/wled_controller/api/schemas/system.py` — Add `demo_mode` field to response schema
## Acceptance Criteria
- `Config(demo=True)` is accepted; default is `False`
- `WLED_DEMO=true` activates demo mode
- `is_demo_mode()` returns the correct value
- When demo mode is on, all storage files resolve under `data/demo/`
- `GET /api/v1/system/info` includes `demo_mode: true/false`
## Notes
- The env var will be `WLED_DEMO` because of `env_prefix="WLED_"` in pydantic-settings
- Storage path override should happen at `Config` construction time, not lazily
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -1,48 +0,0 @@
# Phase 2: Virtual Capture Engine
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create a `DemoCaptureEngine` that provides virtual displays and produces animated test pattern frames, allowing screen capture workflows to function in demo mode without real monitors.
## Tasks
- [ ] Task 1: Create `server/src/wled_controller/core/capture_engines/demo_engine.py` with `DemoCaptureEngine` and `DemoCaptureStream`
- [ ] Task 2: `DemoCaptureEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000` (highest in demo mode)
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
- [ ] Task 4: `get_available_displays()` returns 3 virtual displays:
- "Demo Display 1080p" (1920×1080)
- "Demo Ultrawide" (3440×1440)
- "Demo Portrait" (1080×1920)
- [ ] Task 5: `DemoCaptureStream.capture_frame()` produces animated test patterns:
- Horizontally scrolling rainbow gradient (simple, visually clear)
- Uses `time.time()` for animation so frames change over time
- Returns proper `ScreenCapture` with RGB numpy array
- [ ] Task 6: Register `DemoCaptureEngine` in `capture_engines/__init__.py`
## Files to Modify/Create
- `server/src/wled_controller/core/capture_engines/demo_engine.py` — New file: DemoCaptureEngine + DemoCaptureStream
- `server/src/wled_controller/core/capture_engines/__init__.py` — Register DemoCaptureEngine
## Acceptance Criteria
- `DemoCaptureEngine.is_available()` is True only in demo mode
- Virtual displays appear in the display list API when in demo mode
- `capture_frame()` returns valid RGB frames that change over time
- Engine is properly registered in EngineRegistry
## Notes
- Test patterns should be computationally cheap (no heavy image processing)
- Use numpy operations for pattern generation (vectorized, fast)
- Frame dimensions must match the virtual display dimensions
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -1,47 +0,0 @@
# Phase 3: Virtual Audio Engine
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create a `DemoAudioEngine` that provides virtual audio devices and produces synthetic audio data, enabling audio-reactive visualizations in demo mode.
## Tasks
- [ ] Task 1: Create `server/src/wled_controller/core/audio/demo_engine.py` with `DemoAudioEngine` and `DemoAudioCaptureStream`
- [ ] Task 2: `DemoAudioEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000`
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
- [ ] Task 4: `enumerate_devices()` returns 2 virtual devices:
- "Demo Microphone" (input, not loopback)
- "Demo System Audio" (loopback)
- [ ] Task 5: `DemoAudioCaptureStream` implements:
- `channels = 2`, `sample_rate = 44100`, `chunk_size = 1024`
- `read_chunk()` produces synthetic audio: a mix of sine waves with slowly varying frequencies to simulate music-like beat patterns
- Returns proper float32 ndarray
- [ ] Task 6: Register `DemoAudioEngine` in `audio/__init__.py`
## Files to Modify/Create
- `server/src/wled_controller/core/audio/demo_engine.py` — New file: DemoAudioEngine + DemoAudioCaptureStream
- `server/src/wled_controller/core/audio/__init__.py` — Register DemoAudioEngine
## Acceptance Criteria
- `DemoAudioEngine.is_available()` is True only in demo mode
- Virtual audio devices appear in audio device enumeration when in demo mode
- `read_chunk()` returns valid float32 audio data that varies over time
- Audio analyzer produces non-trivial frequency band data from the synthetic signal
## Notes
- Synthetic audio should produce interesting FFT results (multiple frequencies, amplitude modulation)
- Keep it computationally lightweight
- Must conform to `AudioCaptureStreamBase` interface exactly
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -1,54 +0,0 @@
# Phase 4: Demo Device Provider & Seed Data
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create a demo device provider that exposes discoverable virtual LED devices, and build a seed data generator that populates the demo data directory with sample entities on first run.
## Tasks
- [ ] Task 1: Create `server/src/wled_controller/core/devices/demo_provider.py``DemoDeviceProvider` extending `LEDDeviceProvider`:
- `device_type = "demo"`
- `capabilities = {"manual_led_count", "power_control", "brightness_control", "static_color"}`
- `create_client()` returns a `MockClient` (reuse existing)
- `discover()` returns 3 pre-defined virtual devices:
- "Demo LED Strip" (60 LEDs, ip="demo-strip")
- "Demo LED Matrix" (256 LEDs / 16×16, ip="demo-matrix")
- "Demo LED Ring" (24 LEDs, ip="demo-ring")
- `check_health()` always returns online with simulated ~2ms latency
- `validate_device()` returns `{"led_count": <from url>}`
- [ ] Task 2: Register `DemoDeviceProvider` in `led_client.py` `_register_builtin_providers()`
- [ ] Task 3: Create `server/src/wled_controller/core/demo_seed.py` — seed data generator:
- Function `seed_demo_data(storage_config: StorageConfig)` that checks if demo data dir is empty and populates it
- Seed entities: 3 devices (matching discover results), 2 output targets, 2 picture sources (using demo engine), 2 CSS sources (gradient + color_cycle), 1 audio source (using demo engine), 1 scene preset, 1 automation
- Use proper ID formats matching existing conventions (e.g., `dev_<hex>`, `tgt_<hex>`, etc.)
- [ ] Task 4: Call `seed_demo_data()` during server startup in `main.py` when demo mode is active (before stores are loaded)
## Files to Modify/Create
- `server/src/wled_controller/core/devices/demo_provider.py` — New: DemoDeviceProvider
- `server/src/wled_controller/core/devices/led_client.py` — Register DemoDeviceProvider
- `server/src/wled_controller/core/demo_seed.py` — New: seed data generator
- `server/src/wled_controller/main.py` — Call seed on demo startup
## Acceptance Criteria
- Demo devices appear in discovery results when in demo mode
- Seed data populates `data/demo/` with valid JSON files on first demo run
- Subsequent demo runs don't overwrite existing demo data
- All seeded entities load correctly in stores
## Notes
- Seed data must match the exact schema expected by each store (look at existing JSON files for format)
- Use the entity dataclass `to_dict()` / store patterns to generate valid data
- Demo discovery should NOT appear when not in demo mode
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -1,50 +0,0 @@
# Phase 5: Frontend Demo Indicator & Sandbox UX
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Add visual indicators in the frontend that clearly communicate demo mode status to the user, including a badge, dismissible banner, and engine labeling.
## Tasks
- [ ] Task 1: Add `demo_mode` field to system info API response schema (if not already done in Phase 1)
- [ ] Task 2: In frontend initialization (`app.ts` or `state.ts`), fetch system info and store `demoMode` in app state
- [ ] Task 3: Add `<span class="demo-badge" id="demo-badge" style="display:none">DEMO</span>` next to app title in `index.html` header
- [ ] Task 4: CSS for `.demo-badge`: amber/yellow pill shape, subtle pulse animation, clearly visible but not distracting
- [ ] Task 5: On app load, if `demoMode` is true: show badge, set `document.body.dataset.demo = 'true'`
- [ ] Task 6: Add a dismissible demo banner at the top of the page: "You're in demo mode — all devices and data are virtual. No real hardware is used." with a dismiss (×) button. Store dismissal in localStorage.
- [ ] Task 7: Add i18n keys for demo badge and banner text in `en.json`, `ru.json`, `zh.json`
- [ ] Task 8: In engine/display dropdowns, demo engines should display with "Demo: " prefix for clarity
## Files to Modify/Create
- `server/src/wled_controller/templates/index.html` — Demo badge + banner HTML
- `server/src/wled_controller/static/css/app.css` — Demo badge + banner styles
- `server/src/wled_controller/static/js/app.ts` — Demo mode detection and UI toggle
- `server/src/wled_controller/static/js/core/state.ts` — Store demo mode flag
- `server/src/wled_controller/static/locales/en.json` — i18n keys
- `server/src/wled_controller/static/locales/ru.json` — i18n keys
- `server/src/wled_controller/static/locales/zh.json` — i18n keys
## Acceptance Criteria
- Demo badge visible next to "LED Grab" title when in demo mode
- Demo badge hidden when not in demo mode
- Banner appears on first demo visit, can be dismissed, stays dismissed across refreshes
- Engine dropdowns clearly label demo engines
- All text is localized
## Notes
- Badge should use `--warning-color` or a custom amber for the pill
- Banner should be a thin strip, not intrusive
- `localStorage` key: `demo-banner-dismissed`
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -1,46 +0,0 @@
# Phase 6: Demo-only Engine Resolution
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Ensure demo engines are the primary/preferred engines in demo mode, and are hidden when not in demo mode. This makes demo mode act as a "virtual platform" where only demo engines resolve.
## Tasks
- [ ] Task 1: Modify `EngineRegistry.get_available_engines()` to filter out engines with `ENGINE_TYPE == "demo"` when not in demo mode (they report `is_available()=False` anyway, but belt-and-suspenders)
- [ ] Task 2: Modify `AudioEngineRegistry.get_available_engines()` similarly
- [ ] Task 3: In demo mode, `get_best_available_engine()` should return the demo engine (already handled by priority=1000, but verify)
- [ ] Task 4: Modify the `GET /api/v1/config/displays` endpoint: in demo mode, default to demo engine displays if no engine_type specified
- [ ] Task 5: Modify the audio engine listing endpoint similarly
- [ ] Task 6: Ensure `DemoDeviceProvider.discover()` only returns devices when in demo mode
- [ ] Task 7: End-to-end verification: start server in demo mode, verify only demo engines/devices appear in API responses
## Files to Modify/Create
- `server/src/wled_controller/core/capture_engines/factory.py` — Filter demo engines
- `server/src/wled_controller/core/audio/factory.py` — Filter demo engines
- `server/src/wled_controller/api/routes/system.py` — Display endpoint defaults
- `server/src/wled_controller/api/routes/audio_templates.py` — Audio engine listing
- `server/src/wled_controller/core/devices/demo_provider.py` — Guard discover()
## Acceptance Criteria
- In demo mode: demo engines are primary, real engines may also be listed but demo is default
- Not in demo mode: demo engines are completely hidden from all API responses
- Display list defaults to demo displays in demo mode
- Audio device list defaults to demo devices in demo mode
## Notes
- This is the "demo OS identifier" concept — demo mode acts as a virtual platform
- Be careful not to break existing behavior when demo=False (default)
- The demo engines already have `is_available() = is_demo_mode()`, so the main concern is UI defaults
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
+109
View File
@@ -0,0 +1,109 @@
# math_wave Color Strip Source — Implementation Plan
## Overview
A new CSS type that generates LED colors from configurable mathematical wave functions. Each LED position gets a wave value based on spatial position and time, mapped to a color via a gradient palette. Supports multiple superimposed wave layers, sync clocks, and bindable parameters.
## Requirements
- Waveform types: sine, triangle, sawtooth, square
- Parameters per wave layer: waveform, frequency, amplitude, phase, offset
- Global parameters: speed (bindable), gradient_id (color mapping)
- Wave superposition: list of wave layers combined additively
- Spatial dimension: wave value depends on LED position (0.0-1.0) + time
- Sync clock integration for time parameter
- Color mapping: combined wave output (0.0-1.0) mapped through a gradient
## Phase 1: Storage Model
**File: `server/src/wled_controller/storage/color_strip_source.py`**
- Add `MathWaveColorStripSource` dataclass after `GameEventColorStripSource`
- Fields:
- `waves: list` — default `[{"waveform": "sine", "frequency": 1.0, "amplitude": 1.0, "phase": 0.0, "offset": 0.0}]`
- `speed: BindableFloat` — default 1.0
- `gradient_id: Optional[str]` — references Gradient entity
- Implement `to_dict`, `from_dict`, `create_from_kwargs`, `apply_update` (follow `CandlelightColorStripSource` pattern)
- Valid waveforms: `{"sine", "triangle", "sawtooth", "square"}`
- Add `"math_wave": MathWaveColorStripSource` to `_SOURCE_TYPE_MAP`
## Phase 2: Stream Implementation
**File: `server/src/wled_controller/core/processing/math_wave_stream.py`** (new)
- Class `MathWaveColorStripStream(ColorStripStream)` following `CandlelightColorStripStream` pattern
- Key methods: `__init__`, `_update_from_source`, `configure`, `start`, `stop`, `get_latest_colors`, `update_source`, `set_clock`, `set_gradient_store`
- Animation loop (`_animate_loop`):
- Get `t` from clock (if set) or wall clock
- For each LED at normalized position `p = i / (N-1)`:
- Sum all wave layers: `sum += amplitude * waveform(2*pi*frequency*(p + speed*t) + phase) + offset`
- Clamp result to [0.0, 1.0]
- Map per-LED values to RGB via gradient LUT
- Waveform functions (vectorized with numpy):
- `sine`: `0.5 + 0.5 * np.sin(x)`
- `triangle`: `2.0 * np.abs(np.mod(x / (2*pi), 1.0) - 0.5)`
- `sawtooth`: `np.mod(x / (2*pi), 1.0)`
- `square`: `(np.sin(x) >= 0).astype(float)`
- Double-buffering pattern (same as candlelight)
- Use `self.resolve("speed", self._speed)` for bindable speed
- Gradient resolution via `set_gradient_store` pattern
## Phase 3: API Integration
**File: `server/src/wled_controller/api/schemas/color_strip_sources.py`**
- Add `MathWaveCSSResponse`, `MathWaveCSSCreate`, `MathWaveCSSUpdate`
- Add to `ColorStripSourceResponse`, `ColorStripSourceCreate`, `ColorStripSourceUpdate` unions
**File: `server/src/wled_controller/core/processing/color_strip_stream_manager.py`**
- Import `MathWaveColorStripStream`
- Add `"math_wave": MathWaveColorStripStream` to `_SIMPLE_STREAM_MAP`
- Existing `set_gradient_store` and `_inject_clock` injection handles it automatically
## Phase 4: Frontend
**File: `server/src/wled_controller/static/js/types.ts`**
- Add `'math_wave'` to `CSSSourceType` union
**File: `server/src/wled_controller/static/js/core/icons.ts`**
- Add `math_wave: _svg(P.activity)` to `_colorStripTypeIcons`
**File: `server/src/wled_controller/templates/modals/css-editor.html`**
- Add `<option value="math_wave">` to type select
- Add `<div id="css-editor-math-wave-section">` with:
- Gradient picker (EntitySelect)
- Speed (BindableScalarWidget)
- Wave layers list (dynamic rows: waveform IconSelect, frequency/amplitude/phase/offset inputs, add/remove buttons)
**File: `server/src/wled_controller/static/js/features/color-strips.ts`**
- Add to `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, `NON_PICTURE_TYPES`
- Add to `clockTypes` array in `saveCSSEditor`
- Add type handler: `load(css)`, `reset()`, `getPayload(name)`
- Add card renderer showing wave count, gradient swatch, speed, clock badge
- Add wave layer management: `_renderMathWaveRow`, `addMathWaveLayer`, `removeMathWaveLayer`
**Files: `en.json`, `ru.json`, `zh.json`**
- Add i18n keys for type name, description, all field labels, waveform names
## Phase 5: Testing
**File: `server/tests/core/test_math_wave_stream.py`** (new)
- Test wave functions produce expected values at known inputs
- Test single wave spatial pattern
- Test wave superposition
- Test gradient color mapping
- Test clock integration
- Test `update_source` hot-update
- Test `configure` auto-sizing
**File: `server/tests/e2e/test_color_strip_flow.py`**
- Add `test_math_wave_crud` to lifecycle tests
**Storage model tests:**
- Test `from_dict` roundtrip
- Test `create_from_kwargs` with valid/invalid waveforms
- Test `apply_update`
- Test `_SOURCE_TYPE_MAP` dispatch
## Risks & Mitigations
- **Wave layer UI complexity** — Follow existing composite layers / game event mappings patterns
- **Performance with many layers** — Vectorize with numpy; cap max wave layers to 8
- **Gradient resolution** — Stream manager already injects `set_gradient_store` automatically
+151
View File
@@ -0,0 +1,151 @@
# music_sync Color Strip Source — Implementation Plan
## Overview
A higher-level music-reactive CSS that provides semantic audio analysis (BPM detection, beat tracking, energy envelope, drop detection, frequency band energy) and multiple visualization modes. Builds on existing `AudioCaptureManager` and `AudioAnalysis` infrastructure. No new external dependencies — uses numpy-only analysis.
## Requirements
- BPM estimation from real-time audio
- Beat onset detection with configurable threshold
- Smoothed RMS energy envelope with attack/release
- Drop detection: energy drops/buildups
- Frequency band energy: bass (20-250 Hz), mid (250-4k Hz), treble (4k-20k Hz)
- Four visualization modes: `pulse_on_beat`, `energy_gradient`, `spectrum_bands`, `strobe_on_drop`
- Uses existing audio engine infrastructure (no new audio capture code)
- No external dependencies beyond numpy
## Phase 1: Music Analysis Engine
**File: `server/src/wled_controller/core/audio/music_analyzer.py`** (new)
### 1.1 `MusicFeatures` dataclass (frozen=True)
- `bpm: float` — estimated BPM (0 if unknown)
- `beat: bool` — beat detected this frame
- `beat_intensity: float` — 0.0-1.0
- `beat_phase: float` — 0.0-1.0 position in beat cycle
- `energy: float` — smoothed RMS 0.0-1.0
- `energy_delta: float` — rate of change
- `bass_energy, mid_energy, treble_energy: float` — 0.0-1.0
- `drop_state: str` — "idle"|"buildup"|"drop"|"recovery"
- `drop_intensity: float` — 0.0-1.0
### 1.2 `MusicAnalyzer` class
- **State**: rolling energy buffer (~4s at ~43 Hz = ~172 samples), beat history timestamps (last 30), smoothed band energies, BPM estimate, drop state machine
- **`update(analysis: AudioAnalysis) -> MusicFeatures`**: main entry point
- **BPM estimation**: Track beat timestamps, compute median inter-beat interval, exponential smoothing, clamp 40-220 BPM
- **Beat tracking**: Pass through `AudioAnalysis.beat` + compute `beat_phase` (position in current beat cycle)
- **Energy envelope**: Smoothed RMS with configurable attack/release
- **Drop detection**: State machine: `idle -> buildup` (energy rising steadily 1-2s), `buildup -> drop` (energy drops >50% within 100ms), `drop -> recovery` (after 500ms), `recovery -> idle`
- **Frequency bands**: Sum spectrum bins into 3 bands from 64-band spectrum
## Phase 2: Storage Model
**File: `server/src/wled_controller/storage/color_strip_source.py`**
- Add `MusicSyncColorStripSource` dataclass after `AudioColorStripSource`
- Fields:
- `visualization_mode: str` — default `"pulse_on_beat"`
- `audio_source_id: str` — references AudioSource
- `sensitivity: BindableFloat` — default 1.0
- `smoothing: BindableFloat` — default 0.3
- `palette: str` — default `"rainbow"`
- `gradient_id: Optional[str]`
- `color: BindableColor` — primary color
- `color_secondary: BindableColor` — for two-color modes
- `beat_decay: BindableFloat` — default 0.15
- `led_count: int` — 0 = auto-size
- `mirror: bool`
- Add `"music_sync": MusicSyncColorStripSource` to `_SOURCE_TYPE_MAP`
## Phase 3: Stream Implementation
**File: `server/src/wled_controller/core/processing/music_sync_stream.py`** (new)
- Class `MusicSyncColorStripStream(ColorStripStream)` following `AudioColorStripStream` pattern
- Constructor: accept source + audio_capture_manager + stores. Create `MusicAnalyzer` instance
- `start()`: Acquire audio stream, start background thread
- `stop()`: Release audio stream, stop thread
- `_animate_loop()`:
1. Get `AudioAnalysis` from audio stream
2. Apply audio filter pipeline (if any)
3. Feed to `MusicAnalyzer.update()``MusicFeatures`
4. Dispatch to visualization renderer
5. Double-buffer output
### Visualization Renderers
- **`pulse_on_beat`**: Full-strip flash on beat with exponential decay. Between beats: sine-wave pulsing synced to BPM. Color from palette indexed by beat_intensity.
- **`energy_gradient`**: Maps bass→warm, treble→cool. Overall brightness from energy. Gradient scrolls with beat_phase.
- **`spectrum_bands`**: 3 zones (bass/mid/treble), each fills proportionally to band energy. Mirror mode: bass center, treble edges.
- **`strobe_on_drop`**: Idle=gentle breathing. Buildup=increasing pulse. Drop=rapid strobe at 10 Hz. Recovery=fade back.
## Phase 4: API Integration
**File: `server/src/wled_controller/api/schemas/color_strip_sources.py`**
- Add `MusicSyncCSSResponse`, `MusicSyncCSSCreate`, `MusicSyncCSSUpdate`
- Add to all three union types
**File: `server/src/wled_controller/api/routes/color_strip_sources.py`**
- Import + add to `_RESPONSE_MAP`
**File: `server/src/wled_controller/core/processing/color_strip_stream_manager.py`**
- Add `music_sync` branch in `acquire()` (same pattern as `audio` branch)
- Update `refresh_audio_filter_pipelines()` to include `MusicSyncColorStripStream`
## Phase 5: Frontend
**File: `server/src/wled_controller/static/js/core/icons.ts`**
- Add `music_sync: _svg(P.radio)` icon
**File: `server/src/wled_controller/templates/modals/css-editor.html`**
- Add `<div id="css-editor-music-sync-section">` with:
- Visualization mode selector (IconSelect)
- Audio source dropdown
- Sensitivity, Smoothing, Beat Decay (BindableScalarWidget containers)
- Palette/gradient selector (EntitySelect)
- Primary + Secondary color (BindableColorWidget)
- Mirror checkbox
**File: `server/src/wled_controller/static/js/features/color-strips-music-sync.ts`** (new, extracted)
- Editor logic, widget factories, card renderer
**File: `server/src/wled_controller/static/js/features/color-strips.ts`**
- Register type in `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, `NON_PICTURE_TYPES`
- Import and register from `color-strips-music-sync.ts`
**Files: `en.json`, `ru.json`, `zh.json`**
- Add i18n keys for type, visualization modes, all field labels
## Phase 6: Testing
**File: `server/tests/core/audio/test_music_analyzer.py`** (new)
- BPM estimation from regular beats (within 5 BPM accuracy)
- BPM handles no beats gracefully
- Beat phase progression
- Energy envelope attack/release
- Frequency band splitting
- Drop detection state machine transitions
- No false drops on steady signal
**File: `server/tests/core/processing/test_music_sync_stream.py`** (new)
- Stream lifecycle (start/stop)
- Produces valid colors
- Hot-update parameters
- Auto-size from device
- All 4 visualization modes produce valid (n,3) uint8 arrays
- Mirror mode symmetry
**Storage + API tests:**
- `from_dict` roundtrip
- CRUD via test client
## Risks & Mitigations
- **BPM accuracy** — Median-of-recent-IBIs is robust for dance/electronic. For ambient, BPM noisy but `energy_gradient` and `spectrum_bands` don't depend on BPM
- **Drop detection false positives** — Require minimum energy threshold + sustained increase before buildup state
- **Frontend file size** — Extract to `color-strips-music-sync.ts` (following composite/notification pattern)
- **Strobe photosensitivity** — Cap at 10 Hz (below 15-25 Hz danger zone), add UI warning
- **Thread safety** — `MusicAnalyzer` owned exclusively by one stream thread, no shared access
## Dependency Order
`math_wave` should be implemented first (simpler, no audio dependency), then `music_sync`.
+1 -1
View File
@@ -7,7 +7,7 @@
- `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity)
- `src/wled_controller/core/` — Core business logic (capture, devices, audio, processing, automations)
- `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection)
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
- `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales)
- `src/wled_controller/templates/` — Jinja2 HTML templates
- `config/` — Configuration files (YAML)
+1 -1
View File
@@ -8,7 +8,7 @@ The server component provides:
- 🎯 **Real-time Screen Capture** - Multi-monitor support with configurable FPS
- 🎨 **Advanced Processing** - Border pixel extraction with color correction
- 🔧 **Flexible Calibration** - Map screen edges to any LED layout
- 🌐 **REST API** - Complete control via 17 REST endpoints
- 🌐 **REST API** - Complete control via 25+ REST endpoints
- 💾 **Persistent Storage** - JSON-based device and configuration management
- 📊 **Metrics & Monitoring** - Real-time FPS, status, and performance data
+5 -6
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "wled-screen-controller"
version = "0.2.0"
version = "0.3.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
@@ -26,7 +26,6 @@ dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"httpx>=0.27.2",
"packaging>=23.0",
"mss>=9.0.2",
"numpy>=2.1.3",
"pydantic>=2.9.2",
@@ -46,6 +45,10 @@ dependencies = [
"aiomqtt>=2.0.0",
"openrgb-python>=0.2.15",
"opencv-python-headless>=4.8.0",
"websockets>=13.0",
"just-playback>=0.1.7",
"pystray>=0.19.0; sys_platform == 'win32'",
"Pillow>=10.4.0; sys_platform == 'win32'",
]
[project.optional-dependencies]
@@ -78,10 +81,6 @@ perf = [
"bettercam>=1.0.0; sys_platform == 'win32'",
"windows-capture>=1.5.0; sys_platform == 'win32'",
]
tray = [
"pystray>=0.19.0; sys_platform == 'win32'",
"Pillow>=10.4.0; sys_platform == 'win32'",
]
[project.urls]
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
+9 -2
View File
@@ -4,8 +4,15 @@ Set WshShell = CreateObject("WScript.Shell")
scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
appRoot = fso.GetParentFolderName(scriptDir)
WshShell.CurrentDirectory = appRoot
' Use embedded Python if present (installed dist), otherwise system Python
embeddedPython = appRoot & "\python\pythonw.exe"
' Set env vars for the child process (inherited via WshShell.Run)
Set procEnv = WshShell.Environment("Process")
procEnv("PYTHONPATH") = appRoot & "\app\src"
procEnv("WLED_CONFIG_PATH") = appRoot & "\app\config\default_config.yaml"
' Use embedded python.exe (NOT pythonw.exe) with WindowStyle=0.
' Same pattern as the Media Server sibling app.
embeddedPython = appRoot & "\python\python.exe"
If fso.FileExists(embeddedPython) Then
WshShell.Run """" & embeddedPython & """ -m wled_controller", 0, False
Else
+6
View File
@@ -10,3 +10,9 @@ except PackageNotFoundError:
__author__ = "Alexei Dolgolyov"
__email__ = "dolgolyov.alexei@gmail.com"
# ─── Project links ───────────────────────────────────────────
GITEA_BASE_URL = "https://git.dolgolyov-family.by"
GITEA_REPO = "alexei.dolgolyov/wled-screen-controller-mixed"
REPO_URL = f"{GITEA_BASE_URL}/{GITEA_REPO}"
DONATE_URL = "" # TODO: set once donation platform is chosen
+18 -7
View File
@@ -6,6 +6,7 @@ shows a system-tray icon with **Show UI** / **Exit** actions.
import asyncio
import os
import socket
import sys
import threading
import time
@@ -43,8 +44,20 @@ def _is_restart() -> bool:
return os.environ.get("WLED_RESTART", "") == "1"
def _check_port(host: str, port: int) -> None:
"""Exit with a clear message if the port is already in use."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(1)
try:
sock.bind((host, port))
except OSError:
logger.error("Port %d is already in use on %s", port, host)
sys.exit(1)
def main() -> None:
config = get_config()
_check_port(config.server.host, config.server.port)
uv_config = uvicorn.Config(
"wled_controller.main:app",
@@ -55,16 +68,16 @@ def main() -> None:
server = uvicorn.Server(uv_config)
set_server(server)
use_tray = PYSTRAY_AVAILABLE and (
sys.platform == "win32" or _force_tray()
)
use_tray = PYSTRAY_AVAILABLE and (sys.platform == "win32" or _force_tray())
if use_tray:
logger.info("Starting with system tray icon")
# Uvicorn in a background thread
server_thread = threading.Thread(
target=_run_server, args=(server,), daemon=True,
target=_run_server,
args=(server,),
daemon=True,
)
server_thread.start()
@@ -89,9 +102,7 @@ def main() -> None:
server_thread.join(timeout=10)
else:
if not PYSTRAY_AVAILABLE:
logger.info(
"System tray not available (install pystray for tray support)"
)
logger.info("System tray not available (install pystray for tray support)")
server.run()
+12 -4
View File
@@ -9,10 +9,8 @@ from .routes.devices import router as devices_router
from .routes.templates import router as templates_router
from .routes.postprocessing import router as postprocessing_router
from .routes.picture_sources import router as picture_sources_router
from .routes.pattern_templates import router as pattern_templates_router
from .routes.output_targets import router as output_targets_router
from .routes.output_targets_control import router as output_targets_control_router
from .routes.output_targets_keycolors import router as output_targets_keycolors_router
from .routes.color_strip_sources import router as color_strip_sources_router
from .routes.audio import router as audio_router
from .routes.audio_sources import router as audio_sources_router
@@ -26,6 +24,12 @@ from .routes.color_strip_processing import router as cspt_router
from .routes.gradients import router as gradients_router
from .routes.weather_sources import router as weather_sources_router
from .routes.update import router as update_router
from .routes.assets import router as assets_router
from .routes.home_assistant import router as home_assistant_router
from .routes.mqtt import router as mqtt_router
from .routes.game_integration import router as game_integration_router
from .routes.audio_processing_templates import router as audio_processing_templates_router
from .routes.audio_filters import router as audio_filters_router
router = APIRouter()
router.include_router(system_router)
@@ -34,7 +38,6 @@ router.include_router(system_settings_router)
router.include_router(devices_router)
router.include_router(templates_router)
router.include_router(postprocessing_router)
router.include_router(pattern_templates_router)
router.include_router(picture_sources_router)
router.include_router(color_strip_sources_router)
router.include_router(audio_router)
@@ -43,7 +46,6 @@ router.include_router(audio_templates_router)
router.include_router(value_sources_router)
router.include_router(output_targets_router)
router.include_router(output_targets_control_router)
router.include_router(output_targets_keycolors_router)
router.include_router(automations_router)
router.include_router(scene_presets_router)
router.include_router(webhooks_router)
@@ -52,5 +54,11 @@ router.include_router(cspt_router)
router.include_router(gradients_router)
router.include_router(weather_sources_router)
router.include_router(update_router)
router.include_router(assets_router)
router.include_router(home_assistant_router)
router.include_router(mqtt_router)
router.include_router(game_integration_router)
router.include_router(audio_processing_templates_router)
router.include_router(audio_filters_router)
__all__ = ["router"]
+93 -38
View File
@@ -11,7 +11,6 @@ from wled_controller.storage.database import Database
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore
@@ -21,14 +20,24 @@ 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.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
)
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.storage.asset_store import AssetStore
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.update.update_service import UpdateService
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.storage.mqtt_source_store import MQTTSourceStore
from wled_controller.core.mqtt.mqtt_manager import MQTTManager
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
T = TypeVar("T")
@@ -59,10 +68,6 @@ def get_pp_template_store() -> PostprocessingTemplateStore:
return _get("pp_template_store", "Postprocessing template store")
def get_pattern_template_store() -> PatternTemplateStore:
return _get("pattern_template_store", "Pattern template store")
def get_picture_source_store() -> PictureSourceStore:
return _get("picture_source_store", "Picture source store")
@@ -131,6 +136,38 @@ def get_weather_manager() -> WeatherManager:
return _get("weather_manager", "Weather manager")
def get_asset_store() -> AssetStore:
return _get("asset_store", "Asset store")
def get_ha_store() -> HomeAssistantStore:
return _get("ha_store", "Home Assistant store")
def get_ha_manager() -> HomeAssistantManager:
return _get("ha_manager", "Home Assistant manager")
def get_game_integration_store() -> GameIntegrationStore:
return _get("game_integration_store", "Game integration store")
def get_game_event_bus() -> GameEventBus:
return _get("game_event_bus", "Game event bus")
def get_mqtt_store() -> MQTTSourceStore:
return _get("mqtt_store", "MQTT source store")
def get_mqtt_manager() -> MQTTManager:
return _get("mqtt_manager", "MQTT manager")
def get_audio_processing_template_store() -> AudioProcessingTemplateStore:
return _get("audio_processing_template_store", "Audio processing template store")
def get_database() -> Database:
return _get("database", "Database")
@@ -152,12 +189,14 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""
pm = _deps.get("processor_manager")
if pm is not None:
pm.fire_event({
"type": "entity_changed",
"entity_type": entity_type,
"action": action,
"id": entity_id,
})
pm.fire_event(
{
"type": "entity_changed",
"entity_type": entity_type,
"action": action,
"id": entity_id,
}
)
# ── Initialization ──────────────────────────────────────────────────────
@@ -169,7 +208,6 @@ def init_dependencies(
processor_manager: ProcessorManager,
database: Database | 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,
@@ -187,30 +225,47 @@ def init_dependencies(
weather_source_store: WeatherSourceStore | None = None,
weather_manager: WeatherManager | None = None,
update_service: UpdateService | None = None,
asset_store: AssetStore | None = None,
ha_store: HomeAssistantStore | None = None,
ha_manager: HomeAssistantManager | None = None,
game_integration_store: GameIntegrationStore | None = None,
game_event_bus: GameEventBus | None = None,
mqtt_store: MQTTSourceStore | None = None,
mqtt_manager: MQTTManager | None = None,
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
):
"""Initialize global dependencies."""
_deps.update({
"database": database,
"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,
"gradient_store": gradient_store,
"weather_source_store": weather_source_store,
"weather_manager": weather_manager,
"update_service": update_service,
})
_deps.update(
{
"database": database,
"device_store": device_store,
"template_store": template_store,
"processor_manager": processor_manager,
"pp_template_store": pp_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,
"gradient_store": gradient_store,
"weather_source_store": weather_source_store,
"weather_manager": weather_manager,
"update_service": update_service,
"asset_store": asset_store,
"ha_store": ha_store,
"ha_manager": ha_manager,
"game_integration_store": game_integration_store,
"game_event_bus": game_event_bus,
"mqtt_store": mqtt_store,
"mqtt_manager": mqtt_manager,
"audio_processing_template_store": audio_processing_template_store,
}
)
@@ -123,7 +123,8 @@ async def stream_capture_test(
if stream:
try:
stream.cleanup()
except Exception:
except Exception as e:
logger.debug("Capture stream cleanup error: %s", e)
pass
done_event.set()
@@ -210,8 +211,9 @@ async def stream_capture_test(
"avg_capture_ms": round(avg_ms, 1),
})
except Exception:
except Exception as e:
# WebSocket disconnect or send error — signal capture thread to stop
logger.debug("Capture preview WS error, stopping capture thread: %s", e)
stop_event.set()
await capture_future
raise
@@ -0,0 +1,226 @@
"""Asset routes: CRUD, file upload/download, prebuilt restore."""
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import fire_entity_event, get_asset_store
from wled_controller.api.schemas.assets import (
AssetListResponse,
AssetResponse,
AssetUpdate,
)
from wled_controller.config import get_config
from wled_controller.storage.asset_store import AssetStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# Prebuilt sounds directory (shipped with the app)
_PREBUILT_SOUNDS_DIR = Path(__file__).resolve().parents[2] / "data" / "prebuilt_sounds"
def _asset_to_response(asset) -> AssetResponse:
d = asset.to_dict()
return AssetResponse(**d)
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/assets",
response_model=AssetListResponse,
tags=["Assets"],
)
async def list_assets(
_: AuthRequired,
asset_type: str | None = Query(None, description="Filter by type: sound, image, video, other"),
store: AssetStore = Depends(get_asset_store),
):
"""List all assets, optionally filtered by type."""
if asset_type:
assets = store.get_assets_by_type(asset_type)
else:
assets = store.get_visible_assets()
return AssetListResponse(
assets=[_asset_to_response(a) for a in assets],
count=len(assets),
)
@router.get(
"/api/v1/assets/{asset_id}",
response_model=AssetResponse,
tags=["Assets"],
)
async def get_asset(
asset_id: str,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Get asset metadata by ID."""
try:
asset = store.get_asset(asset_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
if asset.deleted:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
return _asset_to_response(asset)
@router.post(
"/api/v1/assets",
response_model=AssetResponse,
status_code=201,
tags=["Assets"],
)
async def upload_asset(
_: AuthRequired,
file: UploadFile = File(...),
name: str | None = Query(None, description="Display name (defaults to filename)"),
description: str | None = Query(None, description="Optional description"),
store: AssetStore = Depends(get_asset_store),
):
"""Upload a new asset file."""
config = get_config()
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
data = await file.read()
if len(data) > max_size:
raise HTTPException(
status_code=400,
detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
)
if not data:
raise HTTPException(status_code=400, detail="Empty file")
display_name = name or Path(file.filename or "unnamed").stem.replace("_", " ").replace("-", " ").title()
try:
asset = store.create_asset(
name=display_name,
filename=file.filename or "unnamed",
file_data=data,
mime_type=file.content_type,
description=description,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("asset", "created", asset.id)
return _asset_to_response(asset)
@router.put(
"/api/v1/assets/{asset_id}",
response_model=AssetResponse,
tags=["Assets"],
)
async def update_asset(
asset_id: str,
body: AssetUpdate,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Update asset metadata."""
try:
asset = store.update_asset(
asset_id,
name=body.name,
description=body.description,
tags=body.tags,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("asset", "updated", asset.id)
return _asset_to_response(asset)
@router.delete(
"/api/v1/assets/{asset_id}",
tags=["Assets"],
)
async def delete_asset(
asset_id: str,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Delete an asset. Prebuilt assets are soft-deleted and can be restored."""
try:
asset = store.get_asset(asset_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
store.delete_asset(asset_id)
fire_entity_event("asset", "deleted", asset_id)
return {
"status": "deleted",
"id": asset_id,
"restorable": asset.prebuilt,
}
# ---------------------------------------------------------------------------
# File serving
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/assets/{asset_id}/file",
tags=["Assets"],
)
async def serve_asset_file(
asset_id: str,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Serve the actual asset file for playback/display."""
file_path = store.get_file_path(asset_id)
if file_path is None:
raise HTTPException(status_code=404, detail=f"Asset file not found: {asset_id}")
try:
asset = store.get_asset(asset_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
return FileResponse(
path=str(file_path),
media_type=asset.mime_type,
filename=asset.filename,
)
# ---------------------------------------------------------------------------
# Prebuilt restore
# ---------------------------------------------------------------------------
@router.post(
"/api/v1/assets/restore-prebuilt",
tags=["Assets"],
)
async def restore_prebuilt_assets(
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Re-import any deleted prebuilt assets."""
restored = store.restore_prebuilt(_PREBUILT_SOUNDS_DIR)
return {
"status": "ok",
"restored_count": len(restored),
"restored": [_asset_to_response(a) for a in restored],
}
@@ -0,0 +1,61 @@
"""Audio filter registry endpoint."""
from fastapi import APIRouter, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_audio_processing_template_store
from wled_controller.api.schemas.filters import (
FilterOptionDefSchema,
FilterTypeListResponse,
FilterTypeResponse,
)
from wled_controller.core.audio.filters import AudioFilterRegistry
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
router = APIRouter()
@router.get(
"/api/v1/audio-filters",
response_model=FilterTypeListResponse,
tags=["Audio Filters"],
)
async def list_audio_filter_types(
_auth: AuthRequired,
apt_store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
):
"""List all available audio filter types and their options schemas."""
all_filters = AudioFilterRegistry.get_all()
# Pre-build template choices for the audio_filter_template filter
template_choices = None
if apt_store:
try:
templates = apt_store.get_all_templates()
template_choices = [{"value": t.id, "label": t.name} for t in templates]
except Exception:
template_choices = []
responses = []
for filter_id, filter_cls in all_filters.items():
schema = filter_cls.get_options_schema()
opt_schemas = []
for opt in schema:
d = opt.to_dict()
# Dynamically populate template_id choices for audio_filter_template
if (
filter_id == "audio_filter_template"
and opt.key == "template_id"
and template_choices is not None
):
d["choices"] = template_choices
opt_schemas.append(FilterOptionDefSchema(**d))
responses.append(
FilterTypeResponse(
filter_id=filter_id,
filter_name=filter_cls.filter_name,
options_schema=opt_schemas,
)
)
return FilterTypeListResponse(filters=responses, count=len(responses))
@@ -0,0 +1,184 @@
"""Audio processing template routes."""
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_audio_processing_template_store,
get_audio_source_store,
get_processor_manager,
)
from wled_controller.api.schemas.audio_processing import (
AudioProcessingTemplateCreate,
AudioProcessingTemplateListResponse,
AudioProcessingTemplateResponse,
AudioProcessingTemplateUpdate,
)
from wled_controller.api.schemas.filters import FilterInstanceSchema
from wled_controller.core.filters.filter_instance import FilterInstance
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _apt_to_response(t) -> AudioProcessingTemplateResponse:
"""Convert an AudioProcessingTemplate to its API response."""
return AudioProcessingTemplateResponse(
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/audio-processing-templates",
response_model=AudioProcessingTemplateListResponse,
tags=["Audio Processing Templates"],
)
async def list_audio_processing_templates(
_auth: AuthRequired,
store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
):
"""List all audio processing templates."""
templates = store.get_all_templates()
responses = [_apt_to_response(t) for t in templates]
return AudioProcessingTemplateListResponse(templates=responses, count=len(responses))
@router.post(
"/api/v1/audio-processing-templates",
response_model=AudioProcessingTemplateResponse,
tags=["Audio Processing Templates"],
status_code=201,
)
async def create_audio_processing_template(
data: AudioProcessingTemplateCreate,
_auth: AuthRequired,
store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
):
"""Create a new audio 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("audio_processing_template", "created", template.id)
return _apt_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("Failed to create audio processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get(
"/api/v1/audio-processing-templates/{template_id}",
response_model=AudioProcessingTemplateResponse,
tags=["Audio Processing Templates"],
)
async def get_audio_processing_template(
template_id: str,
_auth: AuthRequired,
store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
):
"""Get audio processing template by ID."""
try:
template = store.get_template(template_id)
return _apt_to_response(template)
except ValueError:
raise HTTPException(
status_code=404, detail=f"Audio processing template {template_id} not found"
)
@router.put(
"/api/v1/audio-processing-templates/{template_id}",
response_model=AudioProcessingTemplateResponse,
tags=["Audio Processing Templates"],
)
async def update_audio_processing_template(
template_id: str,
data: AudioProcessingTemplateUpdate,
_auth: AuthRequired,
store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
):
"""Update an audio 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("audio_processing_template", "updated", template_id)
# Hot-update: rebuild filter pipelines for running streams using this template
try:
pm = get_processor_manager()
pm.refresh_audio_filter_pipelines(template_id)
except Exception as exc:
logger.warning("Hot-update of audio filter pipelines failed: %s", exc)
return _apt_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("Failed to update audio processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete(
"/api/v1/audio-processing-templates/{template_id}",
status_code=204,
tags=["Audio Processing Templates"],
)
async def delete_audio_processing_template(
template_id: str,
_auth: AuthRequired,
store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
):
"""Delete an audio processing template."""
try:
# Check for references from audio sources
audio_source_store = get_audio_source_store()
refs = audio_source_store.get_sources_referencing_template(template_id)
if refs:
names = ", ".join(r.name for r in refs)
raise ValueError(f"Template is in use by audio source(s): {names}")
store.delete_template(template_id)
fire_entity_event("audio_processing_template", "deleted", template_id)
# Hot-update: rebuild filter pipelines for running streams that used this template
try:
pm = get_processor_manager()
pm.refresh_audio_filter_pipelines(template_id)
except Exception as exc:
logger.warning("Hot-update of audio filter pipelines after delete failed: %s", exc)
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("Failed to delete audio processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@@ -1,14 +1,15 @@
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
import asyncio
from typing import Optional
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_audio_processing_template_store,
get_audio_source_store,
get_audio_template_store,
get_color_strip_store,
@@ -19,8 +20,14 @@ from wled_controller.api.schemas.audio_sources import (
AudioSourceListResponse,
AudioSourceResponse,
AudioSourceUpdate,
CaptureAudioSourceResponse,
ProcessedAudioSourceResponse,
)
from wled_controller.storage.audio_source import (
AudioSource,
CaptureAudioSource,
ProcessedAudioSource,
)
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
@@ -31,31 +38,56 @@ logger = get_logger(__name__)
router = APIRouter()
_RESPONSE_MAP = {
CaptureAudioSource: lambda s: CaptureAudioSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
device_index=s.device_index,
is_loopback=s.is_loopback,
audio_template_id=s.audio_template_id,
),
ProcessedAudioSource: lambda s: ProcessedAudioSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
audio_source_id=s.audio_source_id,
audio_processing_template_id=s.audio_processing_template_id,
),
}
def _to_response(source: AudioSource) -> AudioSourceResponse:
"""Convert an AudioSource to an AudioSourceResponse."""
return AudioSourceResponse(
id=source.id,
name=source.name,
source_type=source.source_type,
device_index=getattr(source, "device_index", None),
is_loopback=getattr(source, "is_loopback", None),
audio_template_id=getattr(source, "audio_template_id", None),
audio_source_id=getattr(source, "audio_source_id", None),
channel=getattr(source, "channel", None),
band=getattr(source, "band", None),
freq_low=getattr(source, "freq_low", None),
freq_high=getattr(source, "freq_high", None),
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
)
"""Convert an AudioSource dataclass to the matching response schema."""
builder = _RESPONSE_MAP.get(type(source))
if builder is None:
# Fallback for unknown types — return as capture
return CaptureAudioSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
device_index=getattr(source, "device_index", -1),
is_loopback=getattr(source, "is_loopback", True),
audio_template_id=getattr(source, "audio_template_id", None),
)
return builder(source)
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
async def list_audio_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel, mono, or band_extract"),
source_type: Optional[str] = Query(
None, description="Filter by source_type: capture or processed"
),
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""List all audio sources, optionally filtered by type."""
@@ -68,27 +100,26 @@ async def list_audio_sources(
)
@router.post("/api/v1/audio-sources", response_model=AudioSourceResponse, status_code=201, tags=["Audio Sources"])
@router.post(
"/api/v1/audio-sources",
response_model=AudioSourceResponse,
status_code=201,
tags=["Audio Sources"],
)
async def create_audio_source(
data: AudioSourceCreate,
data: Annotated[AudioSourceCreate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Create a new audio source."""
try:
fields = data.model_dump(exclude={"source_type", "name", "description", "tags"})
source = store.create_source(
name=data.name,
source_type=data.source_type,
device_index=data.device_index,
is_loopback=data.is_loopback,
audio_source_id=data.audio_source_id,
channel=data.channel,
description=data.description,
audio_template_id=data.audio_template_id,
tags=data.tags,
band=data.band,
freq_low=data.freq_low,
freq_high=data.freq_high,
**fields,
)
fire_entity_event("audio_source", "created", source.id)
return _to_response(source)
@@ -99,7 +130,9 @@ async def create_audio_source(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
@router.get(
"/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"]
)
async def get_audio_source(
source_id: str,
_auth: AuthRequired,
@@ -113,29 +146,19 @@ async def get_audio_source(
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
@router.put(
"/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"]
)
async def update_audio_source(
source_id: str,
data: AudioSourceUpdate,
data: Annotated[AudioSourceUpdate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Update an existing audio source."""
try:
source = store.update_source(
source_id=source_id,
name=data.name,
device_index=data.device_index,
is_loopback=data.is_loopback,
audio_source_id=data.audio_source_id,
channel=data.channel,
description=data.description,
audio_template_id=data.audio_template_id,
tags=data.tags,
band=data.band,
freq_low=data.freq_low,
freq_high=data.freq_high,
)
fields = data.model_dump(exclude={"source_type"}, exclude_none=True)
source = store.update_source(source_id=source_id, **fields)
fire_entity_event("audio_source", "updated", source_id)
return _to_response(source)
except EntityNotFoundError as e:
@@ -156,11 +179,13 @@ async def delete_audio_source(
try:
# Check if any CSS entities reference this audio source
from wled_controller.storage.color_strip_source import AudioColorStripSource
for css in css_store.get_all_sources():
if isinstance(css, AudioColorStripSource) and getattr(css, "audio_source_id", None) == source_id:
raise ValueError(
f"Cannot delete: referenced by color strip source '{css.name}'"
)
if (
isinstance(css, AudioColorStripSource)
and getattr(css, "audio_source_id", None) == source_id
):
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
@@ -182,18 +207,25 @@ async def test_audio_source_ws(
):
"""WebSocket for real-time audio spectrum analysis. Auth via ?token=<api_key>.
Resolves the audio source to its device, acquires a ManagedAudioStream
(ref-counted — shares with running targets), and streams AudioAnalysis
snapshots as JSON at ~20 Hz.
Resolves the audio source to its device and template chain, acquires a
ManagedAudioStream (ref-counted — shares with running targets), and streams
AudioAnalysis snapshots as JSON at ~20 Hz.
Audio processing filters from the template chain are applied to the
analysis before sending, so the WebSocket output matches what running
streams see.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.core.audio.filters.pipeline import build_pipeline_from_template_ids
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Resolve source → device info + optional band filter
# Resolve source → device info + processing template chain
store = get_audio_source_store()
template_store = get_audio_template_store()
apt_store = get_audio_processing_template_store()
manager = get_processor_manager()
try:
@@ -204,16 +236,9 @@ async def test_audio_source_ws(
device_index = resolved.device_index
is_loopback = resolved.is_loopback
channel = resolved.channel
audio_template_id = resolved.audio_template_id
# Precompute band mask if this is a band_extract source
band_mask = None
if resolved.freq_low is not None and resolved.freq_high is not None:
from wled_controller.core.audio.band_filter import compute_band_mask
band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high)
# Resolve template → engine_type + config
# Resolve capture template → engine_type + config
engine_type = None
engine_config = None
if audio_template_id:
@@ -221,9 +246,19 @@ async def test_audio_source_ws(
template = template_store.get_template(audio_template_id)
engine_type = template.engine_type
engine_config = template.engine_config
except ValueError:
except ValueError as e:
logger.debug("Audio template not found, falling back to best available engine: %s", e)
pass # Fall back to best available engine
# Build filter pipeline from processing template chain
pipeline = None
if resolved.audio_processing_template_ids and apt_store:
pipeline = build_pipeline_from_template_ids(
resolved.audio_processing_template_ids, apt_store
)
if pipeline.empty:
pipeline = None
# Acquire shared audio stream
audio_mgr = manager.audio_capture_manager
try:
@@ -242,35 +277,28 @@ async def test_audio_source_ws(
if analysis is not None and analysis.timestamp != last_ts:
last_ts = analysis.timestamp
# Select channel-specific data
if channel == "left":
spectrum = analysis.left_spectrum
rms = analysis.left_rms
elif channel == "right":
spectrum = analysis.right_spectrum
rms = analysis.right_rms
else:
spectrum = analysis.spectrum
rms = analysis.rms
# Apply filter pipeline (channel extract, band extract, gain, etc.)
if pipeline is not None:
analysis = pipeline.process(analysis)
# Apply band filter if present
if band_mask is not None:
from wled_controller.core.audio.band_filter import apply_band_filter
spectrum, rms = apply_band_filter(spectrum, rms, band_mask)
await websocket.send_json({
"spectrum": spectrum.tolist(),
"rms": round(rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),
})
await websocket.send_json(
{
"spectrum": analysis.spectrum.tolist(),
"rms": round(analysis.rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),
}
)
await asyncio.sleep(0.05)
except WebSocketDisconnect:
logger.debug("Audio test WebSocket disconnected for source %s", source_id)
pass
except Exception as e:
logger.error(f"Audio test WebSocket error for {source_id}: {e}")
finally:
if pipeline is not None:
pipeline.close()
audio_mgr.release(device_index, is_loopback, engine_type)
logger.info(f"Audio test WebSocket disconnected for source {source_id}")
@@ -46,8 +46,8 @@ async def list_audio_templates(
]
return AudioTemplateListResponse(templates=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list audio templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list audio templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201)
@@ -76,8 +76,8 @@ async def create_audio_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create audio template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
@@ -126,8 +126,8 @@ async def update_audio_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update audio template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/audio-templates/{template_id}", status_code=204, tags=["Audio Templates"])
@@ -149,8 +149,8 @@ async def delete_audio_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete audio template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== AUDIO ENGINE ENDPOINTS =====
@@ -175,8 +175,8 @@ async def list_audio_engines(_auth: AuthRequired):
return AudioEngineListResponse(engines=engines, count=len(engines))
except Exception as e:
logger.error(f"Failed to list audio engines: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list audio engines: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== REAL-TIME AUDIO TEMPLATE TEST WEBSOCKET =====
@@ -237,6 +237,7 @@ async def test_audio_template_ws(
})
await asyncio.sleep(0.05)
except WebSocketDisconnect:
logger.debug("Audio template test WebSocket disconnected")
pass
except Exception as e:
logger.error(f"Audio template test WS error: {e}")
@@ -16,19 +16,19 @@ from wled_controller.api.schemas.automations import (
AutomationListResponse,
AutomationResponse,
AutomationUpdate,
ConditionSchema,
RuleSchema,
)
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
Condition,
DisplayStateCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
ApplicationRule,
DisplayStateRule,
HomeAssistantRule,
MQTTRule,
Rule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
WebhookRule,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
@@ -41,69 +41,79 @@ router = APIRouter()
# ===== Helpers =====
def _condition_from_schema(s: ConditionSchema) -> Condition:
_SCHEMA_TO_CONDITION = {
"always": lambda: AlwaysCondition(),
"application": lambda: ApplicationCondition(
def _rule_from_schema(s: RuleSchema) -> Rule:
_SCHEMA_TO_RULE = {
"application": lambda: ApplicationRule(
apps=s.apps or [],
match_type=s.match_type or "running",
),
"time_of_day": lambda: TimeOfDayCondition(
"time_of_day": lambda: TimeOfDayRule(
start_time=s.start_time or "00:00",
end_time=s.end_time or "23:59",
),
"system_idle": lambda: SystemIdleCondition(
"system_idle": lambda: SystemIdleRule(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
when_idle=s.when_idle if s.when_idle is not None else True,
),
"display_state": lambda: DisplayStateCondition(
"display_state": lambda: DisplayStateRule(
state=s.state or "on",
),
"mqtt": lambda: MQTTCondition(
"mqtt": lambda: MQTTRule(
mqtt_source_id=s.mqtt_source_id or "",
topic=s.topic or "",
payload=s.payload or "",
match_mode=s.match_mode or "exact",
),
"webhook": lambda: WebhookCondition(
"webhook": lambda: WebhookRule(
token=s.token or secrets.token_hex(16),
),
"startup": lambda: StartupCondition(),
"startup": lambda: StartupRule(),
"home_assistant": lambda: HomeAssistantRule(
ha_source_id=s.ha_source_id or "",
entity_id=s.entity_id or "",
state=s.state or "",
match_mode=s.match_mode or "exact",
),
}
factory = _SCHEMA_TO_CONDITION.get(s.condition_type)
factory = _SCHEMA_TO_RULE.get(s.rule_type)
if factory is None:
raise ValueError(f"Unknown condition type: {s.condition_type}")
raise ValueError(f"Unknown rule type: {s.rule_type}")
return factory()
def _condition_to_schema(c: Condition) -> ConditionSchema:
d = c.to_dict()
return ConditionSchema(**d)
def _rule_to_schema(r: Rule) -> RuleSchema:
d = r.to_dict()
return RuleSchema(**d)
def _automation_to_response(automation, engine: AutomationEngine, request: Request = None) -> AutomationResponse:
def _automation_to_response(
automation, engine: AutomationEngine, request: Request = None
) -> AutomationResponse:
state = engine.get_automation_state(automation.id)
# Build webhook URL from the first webhook condition (if any)
# Build webhook URL from the first webhook rule (if any)
webhook_url = None
for c in automation.conditions:
if isinstance(c, WebhookCondition) and c.token:
for r in automation.rules:
if isinstance(r, WebhookRule) and r.token:
# Prefer configured external URL, fall back to request base URL
from wled_controller.api.routes.system import load_external_url
ext = load_external_url()
if ext:
webhook_url = ext + f"/api/v1/webhooks/{c.token}"
webhook_url = ext + f"/api/v1/webhooks/{r.token}"
elif request:
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{r.token}"
else:
webhook_url = f"/api/v1/webhooks/{c.token}"
webhook_url = f"/api/v1/webhooks/{r.token}"
break
return AutomationResponse(
id=automation.id,
name=automation.name,
enabled=automation.enabled,
condition_logic=automation.condition_logic,
conditions=[_condition_to_schema(c) for c in automation.conditions],
rule_logic=automation.rule_logic,
rules=[_rule_to_schema(r) for r in automation.rules],
scene_preset_id=automation.scene_preset_id,
deactivation_mode=automation.deactivation_mode,
deactivation_scene_preset_id=automation.deactivation_scene_preset_id,
@@ -117,9 +127,11 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
)
def _validate_condition_logic(logic: str) -> None:
def _validate_rule_logic(logic: str) -> None:
if logic not in ("or", "and"):
raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.")
raise HTTPException(
status_code=400, detail=f"Invalid rule_logic: {logic}. Must be 'or' or 'and'."
)
def _validate_scene_refs(
@@ -136,11 +148,14 @@ def _validate_scene_refs(
try:
scene_store.get_preset(sid)
except ValueError:
raise HTTPException(status_code=400, detail=f"Scene preset not found: {sid} ({label})")
raise HTTPException(
status_code=400, detail=f"Scene preset not found: {sid} ({label})"
)
# ===== CRUD Endpoints =====
@router.post(
"/api/v1/automations",
response_model=AutomationResponse,
@@ -156,11 +171,11 @@ async def create_automation(
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Create a new automation."""
_validate_condition_logic(data.condition_logic)
_validate_rule_logic(data.rule_logic)
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
rules = [_rule_from_schema(r) for r in data.rules]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -170,8 +185,8 @@ async def create_automation(
automation = store.create_automation(
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
rule_logic=data.rule_logic,
rules=rules,
scene_preset_id=data.scene_preset_id,
deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
@@ -240,16 +255,16 @@ async def update_automation(
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Update an automation."""
if data.condition_logic is not None:
_validate_condition_logic(data.condition_logic)
if data.rule_logic is not None:
_validate_rule_logic(data.rule_logic)
# Validate scene refs (only the ones being updated)
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
conditions = None
if data.conditions is not None:
rules = None
if data.rules is not None:
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
rules = [_rule_from_schema(r) for r in data.rules]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -266,8 +281,8 @@ async def update_automation(
automation_id=automation_id,
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
rule_logic=data.rule_logic,
rules=rules,
deactivation_mode=data.deactivation_mode,
tags=data.tags,
)
@@ -280,7 +295,7 @@ async def update_automation(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# Re-evaluate immediately if automation is enabled (may have new conditions/scene)
# Re-evaluate immediately if automation is enabled (may have new rules/scene)
if automation.enabled:
await engine.trigger_evaluate()
@@ -313,6 +328,7 @@ async def delete_automation(
# ===== Enable/Disable =====
@router.post(
"/api/v1/automations/{automation_id}/enable",
response_model=AutomationResponse,
+70 -17
View File
@@ -1,6 +1,7 @@
"""System routes: backup, restore, auto-backup.
All backups are SQLite database snapshots (.db files).
Backups are ZIP files containing a SQLite database snapshot (.db)
and any uploaded asset files from data/assets/.
"""
import asyncio
@@ -8,13 +9,14 @@ import io
import subprocess
import sys
import threading
import zipfile
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_auto_backup_engine, get_database
from wled_controller.api.dependencies import get_asset_store, get_auto_backup_engine, get_database
from wled_controller.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
@@ -22,7 +24,9 @@ from wled_controller.api.schemas.system import (
BackupListResponse,
RestoreResponse,
)
from wled_controller.config import get_config
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.storage.asset_store import AssetStore
from wled_controller.storage.database import Database, freeze_writes
from wled_controller.utils import get_logger
@@ -60,25 +64,43 @@ def _schedule_restart() -> None:
@router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired, db: Database = Depends(get_database)):
"""Download a full database backup as a .db file."""
def backup_config(
_: AuthRequired,
db: Database = Depends(get_database),
asset_store: AssetStore = Depends(get_asset_store),
):
"""Download a full backup as a .zip containing the database and asset files."""
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
db.backup_to(tmp_path)
content = tmp_path.read_bytes()
db_content = tmp_path.read_bytes()
finally:
tmp_path.unlink(missing_ok=True)
# Build ZIP: database + asset files
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("ledgrab.db", db_content)
# Include all asset files
assets_dir = Path(get_config().assets.assets_dir)
if assets_dir.exists():
for asset_file in assets_dir.iterdir():
if asset_file.is_file():
zf.write(asset_file, f"assets/{asset_file.name}")
zip_buffer.seek(0)
from datetime import datetime, timezone
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.db"
filename = f"ledgrab-backup-{timestamp}.zip"
return StreamingResponse(
io.BytesIO(content),
media_type="application/octet-stream",
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@@ -89,21 +111,52 @@ async def restore_config(
file: UploadFile = File(...),
db: Database = Depends(get_database),
):
"""Upload a .db backup file to restore all configuration. Triggers server restart."""
"""Upload a .db or .zip backup file to restore all configuration. Triggers server restart.
ZIP backups contain the database and asset files. Plain .db backups are
also supported for backward compatibility (assets are not restored).
"""
raw = await file.read()
if len(raw) > 50 * 1024 * 1024: # 50 MB limit
raise HTTPException(status_code=400, detail="Backup file too large (max 50 MB)")
if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets)
raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)")
if len(raw) < 100:
raise HTTPException(status_code=400, detail="File too small to be a valid SQLite database")
# SQLite files start with "SQLite format 3\000"
if not raw[:16].startswith(b"SQLite format 3"):
raise HTTPException(status_code=400, detail="Not a valid SQLite database file")
raise HTTPException(status_code=400, detail="File too small to be a valid backup")
import tempfile
is_zip = raw[:4] == b"PK\x03\x04"
is_sqlite = raw[:16].startswith(b"SQLite format 3")
if not is_zip and not is_sqlite:
raise HTTPException(status_code=400, detail="Not a valid backup file (expected .zip or .db)")
if is_zip:
# Extract DB and assets from ZIP
try:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
names = zf.namelist()
if "ledgrab.db" not in names:
raise HTTPException(status_code=400, detail="ZIP backup missing ledgrab.db")
db_bytes = zf.read("ledgrab.db")
# Restore asset files
assets_dir = Path(get_config().assets.assets_dir)
assets_dir.mkdir(parents=True, exist_ok=True)
for name in names:
if name.startswith("assets/") and not name.endswith("/"):
asset_filename = name.split("/", 1)[1]
dest = assets_dir / asset_filename
dest.write_bytes(zf.read(name))
logger.info(f"Restored asset file: {asset_filename}")
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Invalid ZIP file")
else:
db_bytes = raw
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp.write(raw)
tmp.write(db_bytes)
tmp_path = Path(tmp.name)
try:
@@ -57,8 +57,8 @@ async def list_cspt(
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))
logger.error("Failed to list color strip processing templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201)
@@ -84,8 +84,8 @@ async def create_cspt(
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))
logger.error("Failed to create color strip processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
@@ -127,8 +127,8 @@ async def update_cspt(
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))
logger.error("Failed to update color strip processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"])
@@ -159,8 +159,8 @@ async def delete_cspt(
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))
logger.error("Failed to delete color strip processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ── Test / Preview WebSocket ──────────────────────────────────────────
@@ -259,12 +259,14 @@ async def test_cspt_ws(
result = flt.process_strip(colors)
if result is not None:
colors = result
except Exception:
except Exception as e:
logger.debug("Strip filter processing error: %s", e)
pass
await websocket.send_bytes(colors.tobytes())
await asyncio.sleep(frame_interval)
except WebSocketDisconnect:
logger.debug("Color strip processing test WebSocket disconnected")
pass
except Exception as e:
logger.error(f"CSPT test WS error: {e}")
File diff suppressed because it is too large Load Diff
@@ -177,8 +177,8 @@ async def create_device(
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create device: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create device: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
@@ -360,7 +360,8 @@ async def update_device(
led_count=update_data.led_count,
baud_rate=update_data.baud_rate,
)
except ValueError:
except ValueError as e:
logger.debug("Processor manager device update skipped for %s: %s", device_id, e)
pass
# Sync auto_shutdown and zone_mode in runtime state
@@ -377,8 +378,8 @@ async def update_device(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update device: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update device: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"])
@@ -417,8 +418,8 @@ async def delete_device(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete device: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete device: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== DEVICE STATE (health only) =====
@@ -654,6 +655,7 @@ async def device_ws_stream(
while True:
await websocket.receive_text()
except WebSocketDisconnect:
logger.debug("Device event WebSocket disconnected for %s", device_id)
pass
finally:
broadcaster.remove_client(device_id, websocket)
@@ -0,0 +1,661 @@
"""Game integration API routes.
CRUD for game integration configs, event ingestion endpoint,
adapter metadata, and diagnostics.
"""
import threading
import time
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_database,
get_game_integration_store,
get_game_event_bus,
)
from wled_controller.api.schemas.game_integration import (
AdapterInfoResponse,
AdapterListResponse,
ApplyPresetRequest,
AutoSetupResponse,
EffectPresetResponse,
EventMappingSchema,
GameEventPayload,
GameEventResponse,
GameIntegrationCreate,
GameIntegrationListResponse,
GameIntegrationResponse,
GameIntegrationStatusResponse,
GameIntegrationUpdate,
PresetListResponse,
RecentEventsResponse,
)
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.game_integration import EventMapping
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ── Per-integration runtime state (in-memory, not persisted) ──────────────
_integration_state_lock = threading.Lock()
# integration_id -> prev_state dict for diff-based trigger detection
_prev_states: dict[str, dict[str, Any]] = {}
# integration_id -> runtime stats
_integration_stats: dict[str, dict[str, Any]] = {}
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
"""Convert a JSON Schema object into a flat list of field descriptors.
The frontend expects [{name, type, label, default, required, hint}, ...].
"""
properties = schema.get("properties", {})
required_set = set(schema.get("required", []))
fields: list[dict[str, Any]] = []
for name, prop in properties.items():
field: dict[str, Any] = {
"name": name,
"type": prop.get("type", "string"),
"label": prop.get("title", name),
}
if "default" in prop:
field["default"] = prop["default"]
if name in required_set:
field["required"] = True
desc = prop.get("description")
if desc:
field["hint"] = desc
fields.append(field)
return fields
def _get_prev_state(integration_id: str) -> dict[str, Any]:
"""Get or create the prev_state dict for an integration."""
with _integration_state_lock:
if integration_id not in _prev_states:
_prev_states[integration_id] = {}
return _prev_states[integration_id]
def _set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
"""Update the prev_state dict for an integration."""
with _integration_state_lock:
_prev_states[integration_id] = state
def _record_events(integration_id: str, events: list[GameEvent]) -> None:
"""Record event stats for an integration."""
with _integration_state_lock:
if integration_id not in _integration_stats:
_integration_stats[integration_id] = {
"event_count": 0,
"event_counts_by_type": {},
"last_event_time": None,
}
stats = _integration_stats[integration_id]
for event in events:
stats["event_count"] += 1
stats["event_counts_by_type"][event.event_type] = (
stats["event_counts_by_type"].get(event.event_type, 0) + 1
)
stats["last_event_time"] = event.timestamp
def _get_stats(integration_id: str) -> dict[str, Any]:
"""Get runtime stats for an integration."""
with _integration_state_lock:
return _integration_stats.get(
integration_id,
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
)
def _cleanup_state(integration_id: str) -> None:
"""Remove runtime state for a deleted integration."""
with _integration_state_lock:
_prev_states.pop(integration_id, None)
_integration_stats.pop(integration_id, None)
# ── Helper: convert config to response ────────────────────────────────────
def _config_to_response(config: Any) -> GameIntegrationResponse:
"""Convert a GameIntegrationConfig to its API response."""
from wled_controller.api.schemas.game_integration import EventMappingSchema
return GameIntegrationResponse(
id=config.id,
name=config.name,
adapter_type=config.adapter_type,
enabled=config.enabled,
adapter_config=config.adapter_config,
event_mappings=[
EventMappingSchema(
event_type=m.event_type,
effect=m.effect,
color=m.color,
duration_ms=m.duration_ms,
intensity=m.intensity,
priority=m.priority,
)
for m in config.event_mappings
],
created_at=config.created_at,
updated_at=config.updated_at,
description=config.description,
tags=config.tags,
)
# ── Effect Presets (must be before /{integration_id} routes) ────────────
@router.get(
"/api/v1/game-integrations/presets",
response_model=PresetListResponse,
tags=["Game Integration"],
)
async def list_presets(_auth: AuthRequired):
"""List all available built-in effect presets."""
from wled_controller.core.game_integration.presets import get_all_presets
presets = get_all_presets()
responses = [
EffectPresetResponse(
key=p.key,
name=p.name,
description=p.description,
target_game_types=list(p.target_game_types),
event_mappings=[
EventMappingSchema(
event_type=m.event_type,
effect=m.effect,
color=list(m.color),
duration_ms=m.duration_ms,
intensity=m.intensity,
priority=m.priority,
)
for m in p.event_mappings
],
)
for p in presets
]
return PresetListResponse(presets=responses, count=len(responses))
# ── CRUD Endpoints ────────────────────────────────────────────────────────
@router.get(
"/api/v1/game-integrations",
response_model=GameIntegrationListResponse,
tags=["Game Integration"],
)
async def list_integrations(
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""List all game integration configs."""
try:
configs = store.get_all_integrations()
responses = [_config_to_response(c) for c in configs]
return GameIntegrationListResponse(
integrations=responses,
count=len(responses),
)
except Exception as e:
logger.error("Failed to list game integrations: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post(
"/api/v1/game-integrations",
response_model=GameIntegrationResponse,
tags=["Game Integration"],
status_code=201,
)
async def create_integration(
data: GameIntegrationCreate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Create a new game integration config."""
try:
mappings = [
EventMapping(
event_type=m.event_type,
effect=m.effect,
color=list(m.color),
duration_ms=m.duration_ms,
intensity=m.intensity,
priority=m.priority,
)
for m in data.event_mappings
]
config = store.create_integration(
name=data.name,
adapter_type=data.adapter_type,
enabled=data.enabled,
adapter_config=data.adapter_config,
event_mappings=mappings,
description=data.description,
tags=data.tags,
)
fire_entity_event("game_integration", "created", config.id)
return _config_to_response(config)
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("Failed to create game integration: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get(
"/api/v1/game-integrations/{integration_id}",
response_model=GameIntegrationResponse,
tags=["Game Integration"],
)
async def get_integration(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Get a game integration config by ID."""
try:
config = store.get_integration(integration_id)
return _config_to_response(config)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
@router.put(
"/api/v1/game-integrations/{integration_id}",
response_model=GameIntegrationResponse,
tags=["Game Integration"],
)
async def update_integration(
integration_id: str,
data: GameIntegrationUpdate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Update a game integration config."""
try:
mappings = None
if data.event_mappings is not None:
mappings = [
EventMapping(
event_type=m.event_type,
effect=m.effect,
color=list(m.color),
duration_ms=m.duration_ms,
intensity=m.intensity,
priority=m.priority,
)
for m in data.event_mappings
]
config = store.update_integration(
integration_id=integration_id,
name=data.name,
adapter_type=data.adapter_type,
enabled=data.enabled,
adapter_config=data.adapter_config,
event_mappings=mappings,
description=data.description,
tags=data.tags,
)
fire_entity_event("game_integration", "updated", integration_id)
return _config_to_response(config)
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("Failed to update game integration: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete(
"/api/v1/game-integrations/{integration_id}",
status_code=204,
tags=["Game Integration"],
)
async def delete_integration(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Delete a game integration config."""
try:
store.delete_integration(integration_id)
_cleanup_state(integration_id)
fire_entity_event("game_integration", "deleted", integration_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to delete game integration: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ── Event Ingestion ───────────────────────────────────────────────────────
@router.post(
"/api/v1/game-integrations/{integration_id}/event",
tags=["Game Integration"],
status_code=204,
)
async def ingest_event(
integration_id: str,
payload: GameEventPayload,
request: Request,
store: GameIntegrationStore = Depends(get_game_integration_store),
event_bus: GameEventBus = Depends(get_game_event_bus),
):
"""Receive a game event payload from a game client.
This endpoint is designed for low-latency ingestion (games send at
16-64 Hz). Auth is adapter-level: the adapter's validate_auth() is
called before standard API auth.
No AuthRequired dependency — adapter-level auth is used instead.
"""
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
if not config.enabled:
raise HTTPException(status_code=409, detail="Integration is disabled")
# Look up adapter
try:
adapter_cls = AdapterRegistry.get_adapter(config.adapter_type)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Adapter-level auth check
headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
raise HTTPException(status_code=403, detail="Adapter authentication failed")
# Parse payload through adapter
prev_state = _get_prev_state(integration_id)
try:
events, new_state = adapter_cls.parse_payload(
payload.data, config.adapter_config, prev_state
)
except Exception as e:
logger.error(
"Adapter %s failed to parse payload for %s: %s",
config.adapter_type,
integration_id,
e,
)
raise HTTPException(status_code=400, detail=f"Failed to parse payload: {e}")
_set_prev_state(integration_id, new_state)
# Publish events to the bus
for event in events:
event_bus.publish(event)
# Track stats
if events:
_record_events(integration_id, events)
# ── Status / Diagnostics ─────────────────────────────────────────────────
@router.get(
"/api/v1/game-integrations/{integration_id}/status",
response_model=GameIntegrationStatusResponse,
tags=["Game Integration"],
)
async def get_integration_status(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Get runtime status for a game integration."""
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
stats = _get_stats(integration_id)
# Consider "connected" if we received an event in the last 30 seconds
last_event_time = stats["last_event_time"]
connected = False
if last_event_time is not None:
connected = (time.monotonic() - last_event_time) < 30.0
return GameIntegrationStatusResponse(
integration_id=integration_id,
enabled=config.enabled,
connected=connected,
last_event_time=last_event_time,
event_count=stats["event_count"],
event_counts_by_type=stats["event_counts_by_type"],
)
@router.get(
"/api/v1/game-integrations/{integration_id}/events",
response_model=RecentEventsResponse,
tags=["Game Integration"],
)
async def get_recent_events(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
event_bus: GameEventBus = Depends(get_game_event_bus),
limit: int = 50,
):
"""Get recent events for a game integration (for debugging)."""
try:
store.get_integration(integration_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
# Filter bus recent events to this integration
all_recent = event_bus.get_recent_events(limit=200)
filtered = [e for e in all_recent if e.adapter_id == integration_id][-limit:]
event_responses = [
GameEventResponse(
adapter_id=e.adapter_id,
event_type=e.event_type,
value=e.value,
timestamp=e.timestamp,
raw_data=e.raw_data,
)
for e in filtered
]
return RecentEventsResponse(
integration_id=integration_id,
events=event_responses,
count=len(event_responses),
)
# ── Adapter Metadata ─────────────────────────────────────────────────────
@router.get(
"/api/v1/game-adapters",
response_model=AdapterListResponse,
tags=["Game Integration"],
)
async def list_adapters(_auth: AuthRequired):
"""List all available game adapter types with metadata."""
try:
all_adapters = AdapterRegistry.get_all_adapters()
responses = []
for adapter_type, adapter_cls in all_adapters.items():
responses.append(
AdapterInfoResponse(
adapter_type=adapter_type,
display_name=adapter_cls.DISPLAY_NAME,
game_name=adapter_cls.GAME_NAME,
supported_events=list(adapter_cls.SUPPORTED_EVENTS),
config_schema=_schema_to_fields(adapter_cls.get_config_schema()),
setup_instructions=adapter_cls.get_setup_instructions(),
supports_auto_setup=adapter_cls.supports_auto_setup(),
)
)
return AdapterListResponse(adapters=responses, count=len(responses))
except Exception as e:
logger.error("Failed to list adapters: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ── Apply Preset ────────────────────────────────────────────────────────
@router.post(
"/api/v1/game-integrations/{integration_id}/apply-preset",
response_model=GameIntegrationResponse,
tags=["Game Integration"],
)
async def apply_preset(
integration_id: str,
data: ApplyPresetRequest,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Apply a built-in preset to a game integration.
If replace=true, replaces all existing mappings.
If replace=false (default), appends preset mappings to existing ones.
"""
from wled_controller.core.game_integration.presets import get_preset
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
preset = get_preset(data.preset_key)
if preset is None:
raise HTTPException(status_code=404, detail=f"Preset '{data.preset_key}' not found")
if data.replace:
new_mappings = list(preset.event_mappings)
else:
new_mappings = list(config.event_mappings) + list(preset.event_mappings)
try:
updated = store.update_integration(
integration_id=integration_id,
event_mappings=new_mappings,
)
fire_entity_event("game_integration", "updated", integration_id)
return _config_to_response(updated)
except Exception as e:
logger.error("Failed to apply preset: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ── Auto Setup ─────────────────────────────────────────────────────────
@router.post(
"/api/v1/game-integrations/{integration_id}/auto-setup",
response_model=AutoSetupResponse,
tags=["Game Integration"],
)
async def auto_setup_integration(
integration_id: str,
request: Request,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Automatically write game config files for an integration.
Detects the game installation and writes the GSI config file.
Generates an auth token if not already set.
"""
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
# Look up adapter
try:
adapter_cls = AdapterRegistry.get_adapter(config.adapter_type)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not adapter_cls.supports_auto_setup():
raise HTTPException(
status_code=400,
detail=f"Adapter '{config.adapter_type}' does not support auto setup",
)
# Determine server URL
from wled_controller.api.routes.system_settings import load_external_url
db = get_database()
server_url = load_external_url(db)
if not server_url:
host = request.headers.get("host", "localhost:8080")
server_url = f"http://{host}"
# Run auto-setup
try:
result = adapter_cls.auto_setup(
integration_id=integration_id,
adapter_config=config.adapter_config,
server_url=server_url,
)
except Exception as e:
logger.error("Auto setup failed for %s: %s", integration_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Auto setup failed: {e}")
# If a new token was generated, persist the updated adapter_config
if result.get("token_generated") and result.get("adapter_config"):
try:
store.update_integration(
integration_id=integration_id,
adapter_config=result["adapter_config"],
)
fire_entity_event("game_integration", "updated", integration_id)
except Exception as e:
logger.error(
"Failed to save auto-generated token for %s: %s",
integration_id,
e,
)
return AutoSetupResponse(
success=result.get("success", False),
file_path=result.get("file_path", ""),
message=result.get("message", ""),
token_generated=result.get("token_generated", False),
)
@@ -0,0 +1,306 @@
"""Home Assistant source routes: CRUD + test + entity list + status."""
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_ha_manager,
get_ha_store,
)
from wled_controller.api.schemas.home_assistant import (
HomeAssistantConnectionStatus,
HomeAssistantEntityListResponse,
HomeAssistantEntityResponse,
HomeAssistantSourceCreate,
HomeAssistantSourceListResponse,
HomeAssistantSourceResponse,
HomeAssistantSourceUpdate,
HomeAssistantStatusResponse,
HomeAssistantTestResponse,
)
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.core.home_assistant.ha_runtime import HARuntime
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.home_assistant_source import HomeAssistantSource
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _to_response(
source: HomeAssistantSource, manager: HomeAssistantManager
) -> HomeAssistantSourceResponse:
runtime = manager.get_runtime(source.id)
return HomeAssistantSourceResponse(
id=source.id,
name=source.name,
host=source.host,
use_ssl=source.use_ssl,
entity_filters=source.entity_filters,
connected=runtime.is_connected if runtime else False,
entity_count=len(runtime.get_all_states()) if runtime else 0,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
)
@router.get(
"/api/v1/home-assistant/sources",
response_model=HomeAssistantSourceListResponse,
tags=["Home Assistant"],
)
async def list_ha_sources(
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
sources = store.get_all_sources()
return HomeAssistantSourceListResponse(
sources=[_to_response(s, manager) for s in sources],
count=len(sources),
)
@router.post(
"/api/v1/home-assistant/sources",
response_model=HomeAssistantSourceResponse,
status_code=201,
tags=["Home Assistant"],
)
async def create_ha_source(
data: HomeAssistantSourceCreate,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
source = store.create_source(
name=data.name,
host=data.host,
token=data.token,
use_ssl=data.use_ssl,
entity_filters=data.entity_filters,
description=data.description,
tags=data.tags,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("home_assistant_source", "created", source.id)
return _to_response(source, manager)
@router.get(
"/api/v1/home-assistant/sources/{source_id}",
response_model=HomeAssistantSourceResponse,
tags=["Home Assistant"],
)
async def get_ha_source(
source_id: str,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
source = store.get_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
return _to_response(source, manager)
@router.put(
"/api/v1/home-assistant/sources/{source_id}",
response_model=HomeAssistantSourceResponse,
tags=["Home Assistant"],
)
async def update_ha_source(
source_id: str,
data: HomeAssistantSourceUpdate,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
source = store.update_source(
source_id,
name=data.name,
host=data.host,
token=data.token,
use_ssl=data.use_ssl,
entity_filters=data.entity_filters,
description=data.description,
tags=data.tags,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await manager.update_source(source_id)
fire_entity_event("home_assistant_source", "updated", source.id)
return _to_response(source, manager)
@router.delete(
"/api/v1/home-assistant/sources/{source_id}", status_code=204, tags=["Home Assistant"]
)
async def delete_ha_source(
source_id: str,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
store.delete_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
# Release any active runtime
await manager.release(source_id)
fire_entity_event("home_assistant_source", "deleted", source_id)
@router.get(
"/api/v1/home-assistant/sources/{source_id}/entities",
response_model=HomeAssistantEntityListResponse,
tags=["Home Assistant"],
)
async def list_ha_entities(
source_id: str,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
"""List available entities from a HA instance (live query)."""
try:
source = store.get_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
# Try cached states first from running runtime
runtime = manager.get_runtime(source_id)
if runtime and runtime.is_connected:
states = runtime.get_all_states()
entities = [
HomeAssistantEntityResponse(
entity_id=s.entity_id,
state=s.state,
friendly_name=s.attributes.get("friendly_name", s.entity_id),
domain=s.entity_id.split(".")[0] if "." in s.entity_id else "",
)
for s in states.values()
]
return HomeAssistantEntityListResponse(entities=entities, count=len(entities))
# No active runtime — do a one-shot fetch
temp_runtime = HARuntime(source)
try:
raw_entities = await temp_runtime.fetch_entities()
finally:
await temp_runtime.stop()
entities = [HomeAssistantEntityResponse(**e) for e in raw_entities]
return HomeAssistantEntityListResponse(entities=entities, count=len(entities))
@router.post(
"/api/v1/home-assistant/sources/{source_id}/test",
response_model=HomeAssistantTestResponse,
tags=["Home Assistant"],
)
async def test_ha_source(
source_id: str,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
):
"""Test connection to a Home Assistant instance."""
try:
source = store.get_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
try:
import websockets
except ImportError:
return HomeAssistantTestResponse(
success=False,
error="websockets package not installed",
)
try:
async with websockets.connect(source.ws_url) as ws:
# Wait for auth_required
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
if msg.get("type") != "auth_required":
return HomeAssistantTestResponse(
success=False, error=f"Unexpected message: {msg.get('type')}"
)
# Auth
await ws.send(json.dumps({"type": "auth", "access_token": source.token}))
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
if msg.get("type") != "auth_ok":
return HomeAssistantTestResponse(
success=False, error=msg.get("message", "Auth failed")
)
ha_version = msg.get("ha_version")
# Get entity count
await ws.send(json.dumps({"id": 1, "type": "get_states"}))
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
entity_count = len(msg.get("result", [])) if msg.get("success") else 0
return HomeAssistantTestResponse(
success=True,
ha_version=ha_version,
entity_count=entity_count,
)
except Exception as e:
return HomeAssistantTestResponse(success=False, error=str(e))
@router.get(
"/api/v1/home-assistant/status",
response_model=HomeAssistantStatusResponse,
tags=["Home Assistant"],
)
async def get_ha_status(
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
"""Get overall HA integration status (for dashboard indicators)."""
all_sources = store.get_all_sources()
conn_statuses = manager.get_connection_status()
# Build a map for quick lookup
status_map = {s["source_id"]: s for s in conn_statuses}
connections = []
connected_count = 0
for source in all_sources:
status = status_map.get(source.id)
connected = status["connected"] if status else False
if connected:
connected_count += 1
connections.append(
HomeAssistantConnectionStatus(
source_id=source.id,
name=source.name,
connected=connected,
entity_count=status["entity_count"] if status else 0,
)
)
return HomeAssistantStatusResponse(
connections=connections,
total_sources=len(all_sources),
connected_count=connected_count,
)
@@ -0,0 +1,235 @@
"""MQTT source routes: CRUD + test + status."""
import asyncio
import aiomqtt
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_mqtt_manager,
get_mqtt_store,
)
from wled_controller.api.schemas.mqtt import (
MQTTConnectionStatus,
MQTTSourceCreate,
MQTTSourceListResponse,
MQTTSourceResponse,
MQTTSourceUpdate,
MQTTStatusResponse,
MQTTTestResponse,
)
from wled_controller.core.mqtt.mqtt_manager import MQTTManager
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.mqtt_source import MQTTSource
from wled_controller.storage.mqtt_source_store import MQTTSourceStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse:
runtime = manager.get_runtime(source.id)
return MQTTSourceResponse(
id=source.id,
name=source.name,
broker_host=source.broker_host,
broker_port=source.broker_port,
username=source.username,
password_set=bool(source.password),
client_id=source.client_id,
base_topic=source.base_topic,
connected=runtime.is_connected if runtime else False,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
)
@router.get(
"/api/v1/mqtt/sources",
response_model=MQTTSourceListResponse,
tags=["MQTT"],
)
async def list_mqtt_sources(
_auth: AuthRequired,
store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager),
):
sources = store.get_all_sources()
return MQTTSourceListResponse(
sources=[_to_response(s, manager) for s in sources],
count=len(sources),
)
@router.post(
"/api/v1/mqtt/sources",
response_model=MQTTSourceResponse,
status_code=201,
tags=["MQTT"],
)
async def create_mqtt_source(
data: MQTTSourceCreate,
_auth: AuthRequired,
store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager),
):
try:
source = store.create_source(
name=data.name,
broker_host=data.broker_host,
broker_port=data.broker_port,
username=data.username,
password=data.password,
client_id=data.client_id,
base_topic=data.base_topic,
description=data.description,
tags=data.tags,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("mqtt_source", "created", source.id)
return _to_response(source, manager)
@router.get(
"/api/v1/mqtt/sources/{source_id}",
response_model=MQTTSourceResponse,
tags=["MQTT"],
)
async def get_mqtt_source(
source_id: str,
_auth: AuthRequired,
store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager),
):
try:
source = store.get_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found")
return _to_response(source, manager)
@router.put(
"/api/v1/mqtt/sources/{source_id}",
response_model=MQTTSourceResponse,
tags=["MQTT"],
)
async def update_mqtt_source(
source_id: str,
data: MQTTSourceUpdate,
_auth: AuthRequired,
store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager),
):
try:
source = store.update_source(
source_id,
name=data.name,
broker_host=data.broker_host,
broker_port=data.broker_port,
username=data.username,
password=data.password,
client_id=data.client_id,
base_topic=data.base_topic,
description=data.description,
tags=data.tags,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await manager.update_source(source_id)
fire_entity_event("mqtt_source", "updated", source.id)
return _to_response(source, manager)
@router.delete("/api/v1/mqtt/sources/{source_id}", status_code=204, tags=["MQTT"])
async def delete_mqtt_source(
source_id: str,
_auth: AuthRequired,
store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager),
):
try:
store.delete_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found")
# Release any active runtime
await manager.release(source_id)
fire_entity_event("mqtt_source", "deleted", source_id)
@router.post(
"/api/v1/mqtt/sources/{source_id}/test",
response_model=MQTTTestResponse,
tags=["MQTT"],
)
async def test_mqtt_source(
source_id: str,
_auth: AuthRequired,
store: MQTTSourceStore = Depends(get_mqtt_store),
):
"""Test connection to an MQTT broker."""
try:
source = store.get_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found")
try:
async with aiomqtt.Client(
hostname=source.broker_host,
port=source.broker_port,
username=source.username or None,
password=source.password or None,
identifier=f"{source.client_id}-test",
timeout=10.0,
):
return MQTTTestResponse(success=True)
except asyncio.TimeoutError:
return MQTTTestResponse(success=False, error="Connection timed out")
except Exception as e:
return MQTTTestResponse(success=False, error=str(e))
@router.get(
"/api/v1/mqtt/status",
response_model=MQTTStatusResponse,
tags=["MQTT"],
)
async def get_mqtt_status(
_auth: AuthRequired,
store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager),
):
"""Get overall MQTT integration status (for dashboard indicators)."""
all_sources = store.get_all_sources()
statuses = manager.get_all_sources_status()
status_map = {s["source_id"]: s for s in statuses}
connections = []
connected_count = 0
for source in all_sources:
status = status_map.get(source.id)
connected = status["connected"] if status else False
if connected:
connected_count += 1
connections.append(
MQTTConnectionStatus(
source_id=source.id,
name=source.name,
connected=connected,
broker=f"{source.broker_host}:{source.broker_port}",
)
)
return MQTTStatusResponse(
connections=connections,
total_sources=len(all_sources),
connected_count=connected_count,
)
@@ -1,8 +1,9 @@
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, Body, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -12,7 +13,9 @@ from wled_controller.api.dependencies import (
get_processor_manager,
)
from wled_controller.api.schemas.output_targets import (
KeyColorsSettingsSchema,
HALightMappingSchema,
HALightOutputTargetResponse,
LedOutputTargetResponse,
OutputTargetCreate,
OutputTargetListResponse,
OutputTargetResponse,
@@ -20,10 +23,11 @@ from wled_controller.api.schemas.output_targets import (
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.key_colors_output_target import (
KeyColorsSettings,
KeyColorsOutputTarget,
from wled_controller.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
@@ -34,73 +38,70 @@ logger = get_logger(__name__)
router = APIRouter()
def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema:
"""Convert core KeyColorsSettings to schema."""
return KeyColorsSettingsSchema(
fps=settings.fps,
interpolation_mode=settings.interpolation_mode,
smoothing=settings.smoothing,
pattern_template_id=settings.pattern_template_id,
brightness=settings.brightness,
brightness_value_source_id=settings.brightness_value_source_id,
def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse:
"""Convert a WledOutputTarget to LedOutputTargetResponse."""
return LedOutputTargetResponse(
id=target.id,
name=target.name,
device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id,
brightness=target.brightness.to_dict(),
fps=target.fps.to_dict(),
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings:
"""Convert schema KeyColorsSettings to core."""
return KeyColorsSettings(
fps=schema.fps,
interpolation_mode=schema.interpolation_mode,
smoothing=schema.smoothing,
pattern_template_id=schema.pattern_template_id,
brightness=schema.brightness,
brightness_value_source_id=schema.brightness_value_source_id,
def _ha_light_target_to_response(
target: HALightOutputTarget,
) -> HALightOutputTargetResponse:
"""Convert an HALightOutputTarget to HALightOutputTargetResponse."""
return HALightOutputTargetResponse(
id=target.id,
name=target.name,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
brightness=target.brightness.to_dict(),
ha_light_mappings=[
HALightMappingSchema(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale.to_dict(),
)
for m in target.light_mappings
],
update_rate=target.update_rate.to_dict(),
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
def _target_to_response(target) -> OutputTargetResponse:
"""Convert an OutputTarget to OutputTargetResponse."""
"""Convert any OutputTarget to the appropriate typed response."""
if isinstance(target, WledOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
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 or "",
fps=target.fps,
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
min_brightness_threshold=target.min_brightness_threshold,
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
elif isinstance(target, KeyColorsOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
return _led_target_to_response(target)
elif isinstance(target, HALightOutputTarget):
return _ha_light_target_to_response(target)
else:
return OutputTargetResponse(
# Fallback for unknown types — use LED response with defaults
return LedOutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
@@ -108,9 +109,12 @@ def _target_to_response(target) -> OutputTargetResponse:
# ===== CRUD ENDPOINTS =====
@router.post("/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201)
@router.post(
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
)
async def create_target(
data: OutputTargetCreate,
data: Annotated[OutputTargetCreate, Body(discriminator="target_type")],
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store),
@@ -119,31 +123,48 @@ async def create_target(
"""Create a new output target."""
try:
# Validate device exists if provided
if data.device_id:
device_id = getattr(data, "device_id", "")
if device_id:
try:
device_store.get_device(data.device_id)
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = (
[
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in ha_light_mappings_raw
]
if ha_light_mappings_raw
else None
)
# Create in store
target = target_store.create_target(
name=data.name,
target_type=data.target_type,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness_value_source_id=data.brightness_value_source_id,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
picture_source_id=data.picture_source_id,
key_colors_settings=kc_settings,
device_id=device_id,
color_strip_source_id=getattr(data, "color_strip_source_id", ""),
brightness=getattr(data, "brightness", 1.0),
fps=getattr(data, "fps", 30),
keepalive_interval=getattr(data, "keepalive_interval", 1.0),
state_check_interval=getattr(data, "state_check_interval", 30),
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
adaptive_fps=getattr(data, "adaptive_fps", False),
protocol=getattr(data, "protocol", "ddp"),
description=data.description,
tags=data.tags,
ha_source_id=getattr(data, "ha_source_id", ""),
ha_light_mappings=ha_mappings,
update_rate=getattr(data, "update_rate", 2.0),
transition=getattr(data, "transition", 0.5),
color_tolerance=getattr(data, "color_tolerance", 5),
)
# Register in processor manager
@@ -163,8 +184,8 @@ async def create_target(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create target: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets", response_model=OutputTargetListResponse, tags=["Targets"])
@@ -196,7 +217,9 @@ async def batch_target_metrics(
return {"metrics": manager.get_all_target_metrics()}
@router.get("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"])
@router.get(
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
)
async def get_target(
target_id: str,
_auth: AuthRequired,
@@ -210,10 +233,12 @@ async def get_target(
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"])
@router.put(
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
)
async def update_target(
target_id: str,
data: OutputTargetUpdate,
data: Annotated[OutputTargetUpdate, Body(discriminator="target_type")],
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store),
@@ -222,83 +247,90 @@ async def update_target(
"""Update a output target."""
try:
# Validate device exists if changing
if data.device_id is not None and data.device_id:
device_id = getattr(data, "device_id", None)
if device_id is not None and device_id:
try:
device_store.get_device(data.device_id)
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
# Build KC settings with partial-update support: only apply fields that were
# explicitly provided in the request body, merging with the existing settings.
kc_settings = None
if data.key_colors_settings is not None:
incoming = data.key_colors_settings.model_dump(exclude_unset=True)
try:
existing_target = target_store.get_target(target_id)
except ValueError:
existing_target = None
if isinstance(existing_target, KeyColorsOutputTarget):
ex = existing_target.settings
merged = KeyColorsSettingsSchema(
fps=incoming.get("fps", ex.fps),
interpolation_mode=incoming.get("interpolation_mode", ex.interpolation_mode),
smoothing=incoming.get("smoothing", ex.smoothing),
pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id),
brightness=incoming.get("brightness", ex.brightness),
brightness_value_source_id=incoming.get("brightness_value_source_id", ex.brightness_value_source_id),
# Build HA light mappings if provided
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = None
if ha_light_mappings_raw is not None:
ha_mappings = [
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
kc_settings = _kc_schema_to_settings(merged)
else:
kc_settings = _kc_schema_to_settings(data.key_colors_settings)
for m in ha_light_mappings_raw
]
# Update in store
target = target_store.update_target(
target_id=target_id,
name=data.name,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness_value_source_id=data.brightness_value_source_id,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
key_colors_settings=kc_settings,
device_id=device_id,
color_strip_source_id=getattr(data, "color_strip_source_id", None),
brightness=getattr(data, "brightness", None),
fps=getattr(data, "fps", None),
keepalive_interval=getattr(data, "keepalive_interval", None),
state_check_interval=getattr(data, "state_check_interval", None),
min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
adaptive_fps=getattr(data, "adaptive_fps", None),
protocol=getattr(data, "protocol", None),
description=data.description,
tags=data.tags,
ha_source_id=getattr(data, "ha_source_id", None),
ha_light_mappings=ha_mappings,
update_rate=getattr(data, "update_rate", None),
transition=getattr(data, "transition", None),
color_tolerance=getattr(data, "color_tolerance", None),
)
# Detect KC brightness VS change (inside key_colors_settings)
kc_brightness_vs_changed = False
if data.key_colors_settings is not None:
kc_incoming = data.key_colors_settings.model_dump(exclude_unset=True)
if "brightness_value_source_id" in kc_incoming:
kc_brightness_vs_changed = True
# Sync processor manager (run in thread — css release/acquire can block)
color_strip_source_id = getattr(data, "color_strip_source_id", None)
fps = getattr(data, "fps", None)
keepalive_interval = getattr(data, "keepalive_interval", None)
state_check_interval = getattr(data, "state_check_interval", None)
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
adaptive_fps = getattr(data, "adaptive_fps", None)
update_rate = getattr(data, "update_rate", None)
transition = getattr(data, "transition", None)
color_tolerance = getattr(data, "color_tolerance", None)
brightness = getattr(data, "brightness", None)
try:
await asyncio.to_thread(
target.sync_with_manager,
manager,
settings_changed=(data.fps is not None or
data.keepalive_interval is not None or
data.state_check_interval is not None or
data.min_brightness_threshold is not None or
data.adaptive_fps is not None or
data.key_colors_settings is not None),
css_changed=data.color_strip_source_id is not None,
brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed),
settings_changed=(
fps is not None
or keepalive_interval is not None
or state_check_interval is not None
or min_brightness_threshold is not None
or adaptive_fps is not None
or update_rate is not None
or transition is not None
or color_tolerance is not None
or ha_light_mappings_raw is not None
or brightness is not None
),
css_changed=color_strip_source_id is not None,
brightness_changed=brightness is not None,
)
except ValueError:
except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
pass
# Device change requires async stop -> swap -> start cycle
if data.device_id is not None:
if device_id is not None:
try:
await manager.update_target_device(target_id, target.device_id)
except ValueError:
except ValueError as e:
logger.debug("Device update skipped for target %s: %s", target_id, e)
pass
fire_entity_event("output_target", "updated", target_id)
@@ -309,8 +341,8 @@ async def update_target(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update target: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/output-targets/{target_id}", status_code=204, tags=["Targets"])
@@ -325,13 +357,15 @@ async def delete_target(
# Stop processing if running
try:
await manager.stop_processing(target_id)
except ValueError:
except ValueError as e:
logger.debug("Stop processing skipped for target %s (not running): %s", target_id, e)
pass
# Remove from manager
try:
manager.remove_target(target_id)
except (ValueError, RuntimeError):
except (ValueError, RuntimeError) as e:
logger.debug("Remove target from manager skipped for %s: %s", target_id, e)
pass
# Delete from store
@@ -343,5 +377,5 @@ async def delete_target(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete target: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@@ -3,7 +3,6 @@
Extracted from output_targets.py to keep files under 800 lines.
"""
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
@@ -22,7 +21,10 @@ from wled_controller.api.schemas.output_targets import (
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource
from wled_controller.storage.color_strip_source import (
AdvancedPictureColorStripSource,
PictureColorStripSource,
)
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore
@@ -35,7 +37,10 @@ router = APIRouter()
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
@router.post(
"/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"]
)
async def bulk_start_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
@@ -67,7 +72,9 @@ async def bulk_start_processing(
return BulkTargetResponse(started=started, errors=errors)
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"])
@router.post(
"/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"]
)
async def bulk_stop_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
@@ -93,6 +100,7 @@ async def bulk_stop_processing(
# ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
async def start_processing(
target_id: str,
@@ -120,8 +128,8 @@ async def start_processing(
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))
logger.error("Failed to start processing: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
@@ -140,13 +148,18 @@ async def stop_processing(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to stop processing: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== STATE & METRICS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"])
@router.get(
"/api/v1/output-targets/{target_id}/state",
response_model=TargetProcessingState,
tags=["Processing"],
)
async def get_target_state(
target_id: str,
_auth: AuthRequired,
@@ -160,11 +173,15 @@ async def get_target_state(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target state: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to get target state: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
@router.get(
"/api/v1/output-targets/{target_id}/metrics",
response_model=TargetMetricsResponse,
tags=["Metrics"],
)
async def get_target_metrics(
target_id: str,
_auth: AuthRequired,
@@ -178,8 +195,8 @@ async def get_target_metrics(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to get target metrics: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== STATE CHANGE EVENT STREAM =====
@@ -192,6 +209,7 @@ async def events_ws(
):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -215,6 +233,7 @@ async def events_ws(
# ===== OVERLAY VISUALIZATION =====
@router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"])
async def start_target_overlay(
target_id: str,
@@ -247,10 +266,16 @@ async def start_target_overlay(
if first_css_id:
try:
css = color_strip_store.get_source(first_css_id)
if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration:
if (
isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource))
and css.calibration
):
calibration = css.calibration
# Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
from wled_controller.api.routes.color_strip_sources import (
_resolve_display_index,
)
ps_id = getattr(css, "picture_source_id", "") or ""
display_index = _resolve_display_index(ps_id, picture_source_store)
displays = get_available_displays()
@@ -258,9 +283,13 @@ async def start_target_overlay(
display_index = min(display_index, len(displays) - 1)
display_info = displays[display_index]
except Exception as e:
logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}")
logger.warning(
f"Could not pre-load CSS calibration for overlay on {target_id}: {e}"
)
await manager.start_overlay(target_id, target.name, calibration=calibration, display_info=display_info)
await manager.start_overlay(
target_id, target.name, calibration=calibration, display_info=display_info
)
return {"status": "started", "target_id": target_id}
except ValueError as e:
@@ -268,8 +297,8 @@ async def start_target_overlay(
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error(f"Failed to start overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to start overlay: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
@@ -286,8 +315,8 @@ async def stop_target_overlay(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to stop overlay: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])
@@ -305,8 +334,55 @@ async def get_overlay_status(
raise HTTPException(status_code=404, detail=str(e))
# ===== HA LIGHT COLOR PREVIEW WEBSOCKET =====
@router.websocket("/api/v1/output-targets/{target_id}/ha-light/ws")
async def ha_light_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for live HA light entity color preview.
Streams: {"type": "colors_update", "colors": {entity_id: {r,g,b,hex}, ...}}
at the target's update_rate.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
manager: ProcessorManager = get_processor_manager()
try:
proc = manager._processors.get(target_id)
if not proc or not proc.is_running:
await websocket.close(code=4003, reason="Target not running")
return
except Exception as e:
await websocket.close(code=4004, reason=str(e))
return
await websocket.accept()
try:
manager.add_ha_light_ws_client(target_id, websocket)
while True:
# Keep connection alive — wait for client disconnect
await websocket.receive_text()
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
manager.remove_ha_light_ws_client(target_id, websocket)
# ===== LED PREVIEW WEBSOCKET =====
@router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws")
async def led_preview_ws(
websocket: WebSocket,
@@ -315,6 +391,7 @@ async def led_preview_ws(
):
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -1,529 +0,0 @@
"""Output target routes: key colors endpoints, testing, and WebSocket streams.
Extracted from output_targets.py to keep files under 800 lines.
"""
import asyncio
import time
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 (
get_device_store,
get_output_target_store,
get_pattern_template_store,
get_picture_source_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.output_targets import (
ExtractedColorResponse,
KCTestRectangleResponse,
KCTestResponse,
KeyColorsResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ===== KEY COLORS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
async def get_target_colors(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get latest extracted colors for a key-colors target (polling)."""
try:
raw_colors = manager.get_kc_latest_colors(target_id)
colors = {}
for name, (r, g, b) in raw_colors.items():
colors[name] = ExtractedColorResponse(
r=r, g=g, b=b,
hex=f"#{r:02x}{g:02x}{b:02x}",
)
from datetime import datetime, timezone
return KeyColorsResponse(
target_id=target_id,
colors=colors,
timestamp=datetime.now(timezone.utc),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"])
async def test_kc_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
source_store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
pattern_store: PatternTemplateStore = Depends(get_pattern_template_store),
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
pp_template_store=Depends(get_pp_template_store),
):
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
import httpx
stream = None
try:
# 1. Load and validate KC target
try:
target = target_store.get_target(target_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if not isinstance(target, KeyColorsOutputTarget):
raise HTTPException(status_code=400, detail="Target is not a key_colors target")
settings = target.settings
# 2. Resolve pattern template
if not settings.pattern_template_id:
raise HTTPException(status_code=400, detail="No pattern template configured")
try:
pattern_tmpl = pattern_store.get_template(settings.pattern_template_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}")
rectangles = pattern_tmpl.rectangles
if not rectangles:
raise HTTPException(status_code=400, detail="Pattern template has no rectangles")
# 3. Resolve picture source and capture a frame
if not target.picture_source_id:
raise HTTPException(status_code=400, detail="No picture source configured")
try:
chain = source_store.resolve_stream_chain(target.picture_source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
image = load_image_bytes(resp.content)
else:
from pathlib import Path
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
image = load_image_file(path)
elif isinstance(raw_stream, ScreenCapturePictureSource):
try:
capture_template = template_store.get_template(raw_stream.capture_template_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Capture template not found: {raw_stream.capture_template_id}",
)
display_index = raw_stream.display_index
if capture_template.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException(
status_code=400,
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
)
locked_device_id = processor_manager.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
raise HTTPException(
status_code=409,
detail=f"Display {display_index} is currently being captured by device '{device_name}'. "
f"Please stop the device processing before testing.",
)
stream = EngineRegistry.create_stream(
capture_template.engine_type, display_index, capture_template.engine_config
)
stream.initialize()
screen_capture = stream.capture_frame()
if screen_capture is None:
raise RuntimeError("No frame captured")
if not isinstance(screen_capture.image, np.ndarray):
raise ValueError("Unexpected image format from engine")
image = screen_capture.image
else:
raise HTTPException(status_code=400, detail="Unsupported picture source type")
# 3b. Apply postprocessing filters (if the picture source has a filter chain)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store:
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store.get_template(pp_id)
except ValueError:
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
continue
flat_filters = pp_template_store.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(image, image_pool)
if result is not None:
image = result
except ValueError:
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
# 4. Extract colors from each rectangle
img_array = image
h, w = img_array.shape[:2]
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append(KCTestRectangleResponse(
name=rect.name,
x=rect.x,
y=rect.y,
width=rect.width,
height=rect.height,
color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"),
))
# 5. Encode frame as base64 JPEG
from wled_controller.utils.image_codec import encode_jpeg_data_uri
image_data_uri = encode_jpeg_data_uri(image, quality=90)
return KCTestResponse(
image=image_data_uri,
rectangles=result_rects,
interpolation_mode=settings.interpolation_mode,
pattern_template_name=pattern_tmpl.name,
)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}")
except Exception as e:
logger.error(f"Failed to test KC target: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
if stream:
try:
stream.cleanup()
except Exception as e:
logger.error(f"Error cleaning up test stream: {e}")
@router.websocket("/api/v1/output-targets/{target_id}/test/ws")
async def test_kc_target_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
fps: int = Query(3),
preview_width: int = Query(480),
):
"""WebSocket for real-time KC target test preview. Auth via ?token=<api_key>.
Streams JSON frames: {"type": "frame", "image": "data:image/jpeg;base64,...",
"rectangles": [...], "pattern_template_name": "...", "interpolation_mode": "..."}
"""
import json as _json
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Load stores
target_store_inst: OutputTargetStore = get_output_target_store()
source_store_inst: PictureSourceStore = get_picture_source_store()
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
if not isinstance(capture.image, np.ndarray):
await asyncio.sleep(frame_interval)
continue
cur_image = capture.image
if cur_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:
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(cur_image, image_pool)
if result is not None:
cur_image = result
except ValueError:
pass
# Extract colors
img_array = cur_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
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
frame_to_encode = resize_down(cur_image, preview_width) if preview_width else cur_image
frame_uri = encode_jpeg_data_uri(frame_to_encode, quality=85)
await websocket.send_text(_json.dumps({
"type": "frame",
"image": frame_uri,
"rectangles": result_rects,
"pattern_template_name": pattern_tmpl.name,
"interpolation_mode": settings.interpolation_mode,
}))
except (WebSocketDisconnect, Exception) as inner_e:
if isinstance(inner_e, WebSocketDisconnect):
raise
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
elapsed = time.monotonic() - loop_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except WebSocketDisconnect:
logger.info(f"KC test WS disconnected for {target_id}")
except Exception as e:
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
finally:
if live_stream is not None:
try:
await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id
)
except Exception:
pass
logger.info(f"KC test WS closed for {target_id}")
@router.websocket("/api/v1/output-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
try:
manager.add_kc_ws_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
# Keep alive — wait for client messages (or disconnect)
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)
@@ -15,7 +15,7 @@ from wled_controller.api.schemas.pattern_templates import (
PatternTemplateUpdate,
)
from wled_controller.api.schemas.output_targets import KeyColorRectangleSchema
from wled_controller.storage.key_colors_output_target import KeyColorRectangle
from wled_controller.storage.pattern_template 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
@@ -42,7 +42,11 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
)
@router.get("/api/v1/pattern-templates", response_model=PatternTemplateListResponse, tags=["Pattern Templates"])
@router.get(
"/api/v1/pattern-templates",
response_model=PatternTemplateListResponse,
tags=["Pattern Templates"],
)
async def list_pattern_templates(
_auth: AuthRequired,
store: PatternTemplateStore = Depends(get_pattern_template_store),
@@ -53,11 +57,16 @@ async def list_pattern_templates(
responses = [_pat_template_to_response(t) for t in templates]
return PatternTemplateListResponse(templates=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list pattern templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list pattern templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/pattern-templates", response_model=PatternTemplateResponse, tags=["Pattern Templates"], status_code=201)
@router.post(
"/api/v1/pattern-templates",
response_model=PatternTemplateResponse,
tags=["Pattern Templates"],
status_code=201,
)
async def create_pattern_template(
data: PatternTemplateCreate,
_auth: AuthRequired,
@@ -83,11 +92,15 @@ async def create_pattern_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create pattern template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create pattern template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"])
@router.get(
"/api/v1/pattern-templates/{template_id}",
response_model=PatternTemplateResponse,
tags=["Pattern Templates"],
)
async def get_pattern_template(
template_id: str,
_auth: AuthRequired,
@@ -101,7 +114,11 @@ async def get_pattern_template(
raise HTTPException(status_code=404, detail=f"Pattern template {template_id} not found")
@router.put("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"])
@router.put(
"/api/v1/pattern-templates/{template_id}",
response_model=PatternTemplateResponse,
tags=["Pattern Templates"],
)
async def update_pattern_template(
template_id: str,
data: PatternTemplateUpdate,
@@ -131,11 +148,13 @@ async def update_pattern_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update pattern template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update pattern template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/pattern-templates/{template_id}", status_code=204, tags=["Pattern Templates"])
@router.delete(
"/api/v1/pattern-templates/{template_id}", status_code=204, tags=["Pattern Templates"]
)
async def delete_pattern_template(
template_id: str,
_auth: AuthRequired,
@@ -150,7 +169,7 @@ async def delete_pattern_template(
raise HTTPException(
status_code=409,
detail=f"Cannot delete pattern template: it is referenced by target(s): {names}. "
"Please reassign those targets before deleting.",
"Please reassign those targets before deleting.",
)
store.delete_template(template_id)
fire_entity_event("pattern_template", "deleted", template_id)
@@ -162,5 +181,5 @@ async def delete_pattern_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete pattern template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete pattern template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@@ -2,10 +2,11 @@
import asyncio
import time
from typing import Annotated
import httpx
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Body, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from wled_controller.api.auth import AuthRequired
@@ -29,6 +30,10 @@ from wled_controller.api.schemas.picture_sources import (
PictureSourceResponse,
PictureSourceTestRequest,
PictureSourceUpdate,
ProcessedPictureSourceResponse,
RawPictureSourceResponse,
StaticImagePictureSourceResponse,
VideoPictureSourceResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
@@ -36,7 +41,12 @@ 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, VideoCaptureSource
from wled_controller.storage.picture_source import (
ProcessedPictureSource,
ScreenCapturePictureSource,
StaticImagePictureSource,
VideoCaptureSource,
)
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
@@ -45,34 +55,67 @@ logger = get_logger(__name__)
router = APIRouter()
def _stream_to_response(s) -> PictureSourceResponse:
"""Convert a PictureSource to its API response."""
return PictureSourceResponse(
_RESPONSE_MAP = {
ScreenCapturePictureSource: lambda s: RawPictureSourceResponse(
id=s.id,
name=s.name,
stream_type=s.stream_type,
display_index=getattr(s, "display_index", None),
capture_template_id=getattr(s, "capture_template_id", None),
target_fps=getattr(s, "target_fps", None),
source_stream_id=getattr(s, "source_stream_id", None),
postprocessing_template_id=getattr(s, "postprocessing_template_id", None),
image_source=getattr(s, "image_source", None),
created_at=s.created_at,
updated_at=s.updated_at,
description=s.description,
tags=s.tags,
# Video fields
url=getattr(s, "url", None),
loop=getattr(s, "loop", None),
playback_speed=getattr(s, "playback_speed", None),
start_time=getattr(s, "start_time", None),
end_time=getattr(s, "end_time", None),
resolution_limit=getattr(s, "resolution_limit", None),
clock_id=getattr(s, "clock_id", None),
)
created_at=s.created_at,
updated_at=s.updated_at,
display_index=s.display_index,
capture_template_id=s.capture_template_id,
target_fps=s.target_fps,
),
ProcessedPictureSource: lambda s: ProcessedPictureSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
source_stream_id=s.source_stream_id,
postprocessing_template_id=s.postprocessing_template_id,
),
StaticImagePictureSource: lambda s: StaticImagePictureSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
image_asset_id=s.image_asset_id,
),
VideoCaptureSource: lambda s: VideoPictureSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
video_asset_id=s.video_asset_id,
loop=s.loop,
playback_speed=s.playback_speed,
start_time=s.start_time,
end_time=s.end_time,
resolution_limit=s.resolution_limit,
clock_id=s.clock_id,
target_fps=s.target_fps,
),
}
@router.get("/api/v1/picture-sources", response_model=PictureSourceListResponse, tags=["Picture Sources"])
def _stream_to_response(s) -> PictureSourceResponse:
"""Convert a PictureSource storage model to the matching response schema."""
builder = _RESPONSE_MAP.get(type(s))
if builder is None:
raise ValueError(f"Unknown picture source type: {type(s).__name__}")
return builder(s)
@router.get(
"/api/v1/picture-sources", response_model=PictureSourceListResponse, tags=["Picture Sources"]
)
async def list_picture_sources(
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
@@ -83,30 +126,36 @@ async def list_picture_sources(
responses = [_stream_to_response(s) for s in streams]
return PictureSourceListResponse(streams=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list picture sources: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list picture sources: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"])
@router.post(
"/api/v1/picture-sources/validate-image",
response_model=ImageValidateResponse,
tags=["Picture Sources"],
)
async def validate_image(
data: ImageValidateRequest,
_auth: AuthRequired,
):
"""Validate an image source (URL or file path) and return a preview thumbnail."""
try:
from pathlib import Path
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
source = data.image_source.strip()
if not source:
return ImageValidateResponse(valid=False, error="Image source is empty")
if source.startswith(("http://", "https://")):
validate_image_url(source)
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(source)
response.raise_for_status()
img_bytes = response.content
else:
path = Path(source)
path = validate_image_path(source)
if not path.exists():
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
img_bytes = path
@@ -118,6 +167,7 @@ async def validate_image(
load_image_file,
thumbnail as make_thumbnail,
)
if isinstance(src, bytes):
image = load_image_bytes(src)
else:
@@ -129,12 +179,12 @@ async def validate_image(
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
return ImageValidateResponse(
valid=True, width=width, height=height, preview=preview
)
return ImageValidateResponse(valid=True, width=width, height=height, preview=preview)
except httpx.HTTPStatusError as e:
return ImageValidateResponse(valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}")
return ImageValidateResponse(
valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}"
)
except httpx.RequestError as e:
return ImageValidateResponse(valid=False, error=f"Request failed: {e}")
except Exception as e:
@@ -147,22 +197,29 @@ async def get_full_image(
source: str = Query(..., description="Image URL or local file path"),
):
"""Serve the full-resolution image for lightbox preview."""
from pathlib import Path
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
try:
if source.startswith(("http://", "https://")):
validate_image_url(source)
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(source)
response.raise_for_status()
img_bytes = response.content
else:
path = Path(source)
path = validate_image_path(source)
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
img_bytes = path
def _encode_full(src):
from wled_controller.utils.image_codec import encode_jpeg, load_image_bytes, load_image_file
from wled_controller.utils.image_codec import (
encode_jpeg,
load_image_bytes,
load_image_file,
)
if isinstance(src, bytes):
image = load_image_bytes(src)
else:
@@ -178,9 +235,14 @@ async def get_full_image(
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/v1/picture-sources", response_model=PictureSourceResponse, tags=["Picture Sources"], status_code=201)
@router.post(
"/api/v1/picture-sources",
response_model=PictureSourceResponse,
tags=["Picture Sources"],
status_code=201,
)
async def create_picture_source(
data: PictureSourceCreate,
data: Annotated[PictureSourceCreate, Body(discriminator="stream_type")],
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
@@ -207,25 +269,13 @@ async def create_picture_source(
detail=f"Postprocessing template not found: {data.postprocessing_template_id}",
)
fields = data.model_dump(exclude={"stream_type", "name", "description", "tags"})
stream = store.create_stream(
name=data.name,
stream_type=data.stream_type,
display_index=data.display_index,
capture_template_id=data.capture_template_id,
target_fps=data.target_fps,
source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id,
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,
**fields,
)
fire_entity_event("picture_source", "created", stream.id)
return _stream_to_response(stream)
@@ -237,11 +287,15 @@ async def create_picture_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create picture source: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
@router.get(
"/api/v1/picture-sources/{stream_id}",
response_model=PictureSourceResponse,
tags=["Picture Sources"],
)
async def get_picture_source(
stream_id: str,
_auth: AuthRequired,
@@ -255,35 +309,21 @@ async def get_picture_source(
raise HTTPException(status_code=404, detail=f"Picture source {stream_id} not found")
@router.put("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
@router.put(
"/api/v1/picture-sources/{stream_id}",
response_model=PictureSourceResponse,
tags=["Picture Sources"],
)
async def update_picture_source(
stream_id: str,
data: PictureSourceUpdate,
data: Annotated[PictureSourceUpdate, Body(discriminator="stream_type")],
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Update a picture source."""
try:
stream = store.update_stream(
stream_id=stream_id,
name=data.name,
display_index=data.display_index,
capture_template_id=data.capture_template_id,
target_fps=data.target_fps,
source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id,
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,
)
fields = data.model_dump(exclude={"stream_type"}, exclude_none=True)
stream = store.update_stream(stream_id=stream_id, **fields)
fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream)
except EntityNotFoundError as e:
@@ -292,8 +332,8 @@ async def update_picture_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update picture source: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/picture-sources/{stream_id}", status_code=204, tags=["Picture Sources"])
@@ -312,7 +352,7 @@ async def delete_picture_source(
raise HTTPException(
status_code=409,
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
"Please reassign those targets before deleting.",
"Please reassign those targets before deleting.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
@@ -324,8 +364,8 @@ async def delete_picture_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete picture source: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"])
@@ -344,8 +384,18 @@ async def get_video_thumbnail(
if not isinstance(source, VideoCaptureSource):
raise HTTPException(status_code=400, detail="Not a video source")
# Resolve video asset to file path
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
video_path = (
asset_store.get_file_path(source.video_asset_id) if source.video_asset_id else None
)
if not video_path:
raise HTTPException(status_code=400, detail="Video asset not found or missing file")
frame = await asyncio.get_event_loop().run_in_executor(
None, extract_thumbnail, source.url, source.resolution_limit
None, extract_thumbnail, str(video_path), source.resolution_limit
)
if frame is None:
raise HTTPException(status_code=404, detail="Could not extract thumbnail")
@@ -360,11 +410,15 @@ async def get_video_thumbnail(
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to extract video thumbnail: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to extract video thumbnail: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
@router.post(
"/api/v1/picture-sources/{stream_id}/test",
response_model=TemplateTestResponse,
tags=["Picture Sources"],
)
async def test_picture_source(
stream_id: str,
test_request: PictureSourceTestRequest,
@@ -394,24 +448,21 @@ async def test_picture_source(
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
# Static image stream: load image directly, no engine needed
from pathlib import Path
# Static image stream: load image from asset
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
from wled_controller.utils.image_codec import load_image_file
asset_store = _get_asset_store()
image_path = (
asset_store.get_file_path(raw_stream.image_asset_id)
if raw_stream.image_asset_id
else None
)
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
source = raw_stream.image_source
start_time = time.perf_counter()
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
image = load_image_bytes(resp.content)
else:
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
image = await asyncio.to_thread(load_image_file, path)
image = await asyncio.to_thread(load_image_file, image_path)
actual_duration = time.perf_counter() - start_time
frame_count = 1
@@ -456,7 +507,9 @@ async def test_picture_source(
frame_count = 1
last_frame = screen_capture
else:
logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}")
logger.info(
f"Starting {test_request.capture_duration}s stream test for {stream_id}"
)
end_time = start_time + test_request.capture_duration
while time.perf_counter() < end_time:
capture_start = time.perf_counter()
@@ -478,7 +531,10 @@ async def test_picture_source(
image = last_frame.image
# Create thumbnail + encode (CPU-bound — run in thread)
from wled_controller.utils.image_codec import encode_jpeg_data_uri, thumbnail as make_thumbnail
from wled_controller.utils.image_codec import (
encode_jpeg_data_uri,
thumbnail as make_thumbnail,
)
pp_template_ids = chain["postprocessing_template_ids"]
flat_filters = None
@@ -487,13 +543,16 @@ async def test_picture_source(
pp_template = pp_store.get_template(pp_template_ids[0])
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")
logger.warning(
f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview"
)
def _create_thumbnails_and_encode(img, filters):
thumb = make_thumbnail(img, 640)
if filters:
pool = ImagePool()
def apply_filters(arr):
for fi in filters:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
@@ -501,6 +560,7 @@ async def test_picture_source(
if result is not None:
arr = result
return arr
thumb = apply_filters(thumb)
img = apply_filters(img)
@@ -509,8 +569,8 @@ async def test_picture_source(
th, tw = thumb.shape[:2]
return tw, th, thumb_uri, full_uri
thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = await asyncio.to_thread(
_create_thumbnails_and_encode, image, flat_filters
thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = (
await asyncio.to_thread(_create_thumbnails_and_encode, image, flat_filters)
)
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
@@ -543,10 +603,11 @@ async def test_picture_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}")
logger.error("Engine error during picture source test: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
except Exception as e:
logger.error(f"Failed to test picture source: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to test picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
finally:
if stream:
try:
@@ -602,12 +663,23 @@ async def test_picture_source_ws(
# Video sources: use VideoCaptureLiveStream for test preview
if isinstance(raw_stream, VideoCaptureSource):
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
from wled_controller.api.dependencies import get_asset_store as _get_asset_store2
asset_store = _get_asset_store2()
video_path = (
asset_store.get_file_path(raw_stream.video_asset_id)
if raw_stream.video_asset_id
else None
)
if not video_path:
await websocket.close(code=4004, reason="Video asset not found or missing file")
return
await websocket.accept()
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
video_stream = VideoCaptureLiveStream(
url=raw_stream.url,
url=str(video_path),
loop=raw_stream.loop,
playback_speed=raw_stream.playback_speed,
start_time=raw_stream.start_time,
@@ -619,6 +691,7 @@ async def test_picture_source_ws(
def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI."""
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
if pw:
image = resize_down(image, pw)
h, w = image.shape[:2]
@@ -627,6 +700,7 @@ async def test_picture_source_ws(
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
@@ -638,37 +712,51 @@ async def test_picture_source_ws(
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,
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 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,
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),
}
)
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:
logger.debug("Video source test WebSocket disconnected for %s", stream_id)
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:
except Exception as e2:
logger.debug("Failed to send error to video test WS: %s", e2)
pass
finally:
video_stream.stop()
@@ -687,7 +775,9 @@ async def test_picture_source_ws(
return
if capture_template.engine_type not in EngineRegistry.get_available_engines():
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
await websocket.close(
code=4003, reason=f"Engine '{capture_template.engine_type}' not available"
)
return
# Resolve postprocessing filters (if any)
@@ -697,7 +787,8 @@ async def test_picture_source_ws(
try:
pp_template = pp_store.get_template(pp_template_ids[0])
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
except ValueError:
except ValueError as e:
logger.debug("PP template not found for picture source test: %s", e)
pass
# Engine factory — creates + initializes engine inside the capture thread
@@ -716,11 +807,14 @@ async def test_picture_source_ws(
try:
await stream_capture_test(
websocket, engine_factory, duration,
websocket,
engine_factory,
duration,
pp_filters=pp_filters,
preview_width=preview_width or None,
)
except WebSocketDisconnect:
logger.debug("Picture source test WebSocket disconnected for %s", stream_id)
pass
except Exception as e:
logger.error(f"Picture source test WS error for {stream_id}: {e}")
@@ -2,7 +2,6 @@
import time
import httpx
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
@@ -87,8 +86,8 @@ async def create_pp_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create postprocessing template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create postprocessing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"])
@@ -130,8 +129,8 @@ async def update_pp_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update postprocessing template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update postprocessing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/postprocessing-templates/{template_id}", status_code=204, tags=["Postprocessing Templates"])
@@ -162,8 +161,8 @@ async def delete_pp_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete postprocessing template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete postprocessing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"])
@@ -197,28 +196,21 @@ async def test_pp_template(
from wled_controller.utils.image_codec import (
encode_jpeg_data_uri,
load_image_bytes,
load_image_file,
thumbnail as make_thumbnail,
)
if isinstance(raw_stream, StaticImagePictureSource):
# Static image: load directly
from pathlib import Path
# Static image: load from asset
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
source = raw_stream.image_source
start_time = time.perf_counter()
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
image = load_image_bytes(resp.content)
else:
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
image = load_image_file(path)
image = load_image_file(image_path)
actual_duration = time.perf_counter() - start_time
frame_count = 1
@@ -330,13 +322,14 @@ async def test_pp_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Postprocessing template test failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Postprocessing template test failed: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
finally:
if stream:
try:
stream.cleanup()
except Exception:
except Exception as e:
logger.debug("PP test capture stream cleanup: %s", e)
pass
@@ -434,6 +427,7 @@ async def test_pp_template_ws(
preview_width=preview_width or None,
)
except WebSocketDisconnect:
logger.debug("PP template test WebSocket disconnected for %s", template_id)
pass
except Exception as e:
logger.error(f"PP template test WS error for {template_id}: {e}")
+109 -28
View File
@@ -10,10 +10,12 @@ import sys
from datetime import datetime, timezone
from typing import Optional
import os
import psutil
from fastapi import APIRouter, Depends, HTTPException, Query
from wled_controller import __version__
from wled_controller import __version__, REPO_URL, DONATE_URL
from wled_controller.api.auth import AuthRequired, is_auth_enabled
from wled_controller.api.dependencies import (
get_audio_source_store,
@@ -21,8 +23,9 @@ from wled_controller.api.dependencies import (
get_automation_store,
get_color_strip_store,
get_device_store,
get_ha_manager,
get_ha_store,
get_output_target_store,
get_pattern_template_store,
get_picture_source_store,
get_pp_template_store,
get_processor_manager,
@@ -50,11 +53,17 @@ from wled_controller.api.routes.system_settings import load_external_url # noqa
logger = get_logger(__name__)
# Prime psutil CPU counter (first call always returns 0.0)
# Prime psutil CPU counters (first call always returns 0.0)
psutil.cpu_percent(interval=None)
_process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter
# 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 # noqa: E402
from wled_controller.utils.gpu import ( # noqa: E402
nvml_available as _nvml_available,
nvml as _nvml,
nvml_handle as _nvml_handle,
)
def _get_cpu_name() -> str | None:
@@ -77,9 +86,7 @@ def _get_cpu_name() -> str | None:
return line.split(":")[1].strip()
elif platform.system() == "Darwin":
return (
subprocess.check_output(
["sysctl", "-n", "machdep.cpu.brand_string"]
)
subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"])
.decode()
.strip()
)
@@ -107,6 +114,8 @@ async def health_check():
version=__version__,
demo_mode=get_config().demo,
auth_required=is_auth_enabled(),
repo_url=REPO_URL,
donate_url=DONATE_URL,
)
@@ -130,17 +139,28 @@ async def get_version():
async def list_all_tags(_: AuthRequired):
"""Get all tags used across all entities."""
all_tags: set[str] = set()
from wled_controller.api.dependencies import get_asset_store
store_getters = [
get_device_store, get_output_target_store, get_color_strip_store,
get_picture_source_store, get_audio_source_store, get_value_source_store,
get_sync_clock_store, get_automation_store, get_scene_preset_store,
get_template_store, get_audio_template_store, get_pp_template_store,
get_pattern_template_store,
get_device_store,
get_output_target_store,
get_color_strip_store,
get_picture_source_store,
get_audio_source_store,
get_value_source_store,
get_sync_clock_store,
get_automation_store,
get_scene_preset_store,
get_template_store,
get_audio_template_store,
get_pp_template_store,
get_asset_store,
]
for getter in store_getters:
try:
store = getter()
except RuntimeError:
except RuntimeError as e:
logger.debug("Store not available during entity count: %s", e)
continue
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
@@ -207,15 +227,11 @@ async def get_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:
logger.error(f"Failed to get displays: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve display information: {str(e)}"
)
logger.error("Failed to get displays: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/system/processes", response_model=ProcessListResponse, tags=["Config"])
@@ -232,11 +248,8 @@ async def get_running_processes(_: AuthRequired):
sorted_procs = sorted(processes)
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
except Exception as e:
logger.error(f"Failed to get processes: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve process list: {str(e)}"
)
logger.error("Failed to get processes: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get(
@@ -253,20 +266,37 @@ def get_system_performance(_: AuthRequired):
"""
mem = psutil.virtual_memory()
# App-level metrics
proc_mem = _process.memory_info()
# Process.cpu_percent() is per-core (0N*100%); normalize to 0100% scale
app_cpu = _process.cpu_percent(interval=None) / (psutil.cpu_count(logical=True) or 1)
app_ram_mb = round(proc_mem.rss / 1024 / 1024, 1)
gpu = None
if _nvml_available:
try:
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
mem_info = _nvml.nvmlDeviceGetMemoryInfo(_nvml_handle)
temp = _nvml.nvmlDeviceGetTemperature(
_nvml_handle, _nvml.NVML_TEMPERATURE_GPU
)
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
# App GPU memory: sum memory used by this process on the GPU
app_gpu_mem: float | None = None
try:
pid = os.getpid()
for proc_info in _nvml.nvmlDeviceGetComputeRunningProcesses(_nvml_handle):
if proc_info.pid == pid and proc_info.usedGpuMemory:
app_gpu_mem = round(proc_info.usedGpuMemory / 1024 / 1024, 1)
break
except Exception:
pass # not all drivers support per-process queries
gpu = GpuInfo(
name=_nvml.nvmlDeviceGetName(_nvml_handle),
utilization=float(util.gpu),
memory_used_mb=round(mem_info.used / 1024 / 1024, 1),
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
temperature_c=float(temp),
app_memory_mb=app_gpu_mem,
)
except Exception as e:
logger.debug("NVML query failed: %s", e)
@@ -277,6 +307,8 @@ def get_system_performance(_: AuthRequired):
ram_used_mb=round(mem.used / 1024 / 1024, 1),
ram_total_mb=round(mem.total / 1024 / 1024, 1),
ram_percent=mem.percent,
app_cpu_percent=app_cpu,
app_ram_mb=app_ram_mb,
gpu=gpu,
timestamp=datetime.now(timezone.utc),
)
@@ -300,7 +332,56 @@ 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 "****"}
{"label": label, "masked": 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/integrations-status", tags=["System"])
async def get_integrations_status(
_: AuthRequired,
ha_store=Depends(get_ha_store),
ha_manager=Depends(get_ha_manager),
):
"""Return connection status for external integrations (MQTT, Home Assistant).
Used by the dashboard to show connectivity indicators.
"""
from wled_controller.core.devices.mqtt_client import get_mqtt_service
# MQTT status
mqtt_service = get_mqtt_service()
mqtt_config = get_config().mqtt
mqtt_status = {
"enabled": mqtt_config.enabled,
"connected": mqtt_service.is_connected if mqtt_service else False,
"broker": (
f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None
),
}
# Home Assistant status
ha_sources = ha_store.get_all_sources()
ha_connections = ha_manager.get_connection_status()
ha_status_map = {s["source_id"]: s for s in ha_connections}
ha_items = []
for source in ha_sources:
status = ha_status_map.get(source.id)
ha_items.append(
{
"source_id": source.id,
"name": source.name,
"connected": status["connected"] if status else False,
"entity_count": status["entity_count"] if status else 0,
}
)
return {
"mqtt": mqtt_status,
"home_assistant": {
"sources": ha_items,
"total": len(ha_sources),
"connected": sum(1 for s in ha_items if s["connected"]),
},
}
@@ -191,8 +191,10 @@ async def logs_ws(
except Exception:
break
except WebSocketDisconnect:
logger.debug("Log stream WebSocket disconnected")
pass
except Exception:
except Exception as e:
logger.debug("Log stream WebSocket error: %s", e)
pass
finally:
log_broadcaster.unsubscribe(queue)
@@ -287,6 +289,7 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
_validate_adb_address(address)
adb = _get_adb_path()
logger.info(f"Disconnecting ADB device: {address}")
@@ -76,8 +76,8 @@ async def list_templates(
)
except Exception as e:
logger.error(f"Failed to list templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/capture-templates", response_model=TemplateResponse, tags=["Templates"], status_code=201)
@@ -115,8 +115,8 @@ async def create_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
@@ -180,8 +180,8 @@ async def update_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/capture-templates/{template_id}", status_code=204, tags=["Templates"])
@@ -222,8 +222,8 @@ async def delete_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
@@ -252,8 +252,8 @@ async def list_engines(_auth: AuthRequired):
return EngineListResponse(engines=engines, count=len(engines))
except Exception as e:
logger.error(f"Failed to list engines: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list engines: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
@@ -365,10 +365,11 @@ def test_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}")
logger.error("Engine error during template test: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
except Exception as e:
logger.error(f"Failed to test template: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to test template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
finally:
if stream:
try:
@@ -432,6 +433,7 @@ async def test_template_ws(
try:
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
except WebSocketDisconnect:
logger.debug("Capture template test WebSocket disconnected")
pass
except Exception as e:
logger.error(f"Capture template test WS error: {e}")
@@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_update_service
from wled_controller.api.schemas.update import (
DismissRequest,
@@ -20,6 +21,7 @@ router = APIRouter(prefix="/api/v1/system/update", tags=["update"])
@router.get("/status", response_model=UpdateStatusResponse)
async def get_update_status(
_: AuthRequired,
service: UpdateService = Depends(get_update_service),
):
return service.get_status()
@@ -27,6 +29,7 @@ async def get_update_status(
@router.post("/check", response_model=UpdateStatusResponse)
async def check_for_updates(
_: AuthRequired,
service: UpdateService = Depends(get_update_service),
):
return await service.check_now()
@@ -34,6 +37,7 @@ async def check_for_updates(
@router.post("/dismiss")
async def dismiss_update(
_: AuthRequired,
body: DismissRequest,
service: UpdateService = Depends(get_update_service),
):
@@ -43,6 +47,7 @@ async def dismiss_update(
@router.post("/apply")
async def apply_update(
_: AuthRequired,
service: UpdateService = Depends(get_update_service),
):
"""Download (if needed) and apply the available update."""
@@ -59,11 +64,12 @@ async def apply_update(
return {"ok": True, "message": "Update applied, server shutting down"}
except Exception as exc:
logger.error("Failed to apply update: %s", exc, exc_info=True)
return JSONResponse(status_code=500, content={"detail": str(exc)})
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@router.get("/settings", response_model=UpdateSettingsResponse)
async def get_update_settings(
_: AuthRequired,
service: UpdateService = Depends(get_update_service),
):
return service.get_settings()
@@ -71,6 +77,7 @@ async def get_update_settings(
@router.put("/settings", response_model=UpdateSettingsResponse)
async def update_update_settings(
_: AuthRequired,
body: UpdateSettingsRequest,
service: UpdateService = Depends(get_update_service),
):
@@ -1,9 +1,9 @@
"""Value source routes: CRUD for value sources."""
import asyncio
from typing import Optional
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -13,15 +13,43 @@ from wled_controller.api.dependencies import (
get_value_source_store,
)
from wled_controller.api.schemas.value_sources import (
AdaptiveSceneValueSourceResponse,
AdaptiveTimeColorValueSourceResponse,
AdaptiveTimeValueSourceResponse,
AnimatedColorValueSourceResponse,
AnimatedValueSourceResponse,
AudioValueSourceResponse,
CSSExtractValueSourceResponse,
DaylightValueSourceResponse,
GradientMapValueSourceResponse,
HAEntityValueSourceResponse,
StaticColorValueSourceResponse,
StaticValueSourceResponse,
SystemMetricsValueSourceResponse,
ValueSourceCreate,
ValueSourceListResponse,
ValueSourceResponse,
ValueSourceUpdate,
)
from wled_controller.storage.value_source import ValueSource
from wled_controller.storage.value_source import (
AdaptiveTimeColorValueSource,
AdaptiveValueSource,
AnimatedColorValueSource,
AnimatedValueSource,
AudioValueSource,
CSSExtractValueSource,
DaylightValueSource,
GradientMapValueSource,
HAEntityValueSource,
StaticColorValueSource,
StaticValueSource,
SystemMetricsValueSource,
ValueSource,
)
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.value_stream import ValueStream
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
@@ -29,40 +57,194 @@ logger = get_logger(__name__)
router = APIRouter()
# Maps storage class to the response builder for that type.
_RESPONSE_MAP = {
StaticValueSource: lambda s: StaticValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
value=s.value,
),
AnimatedValueSource: lambda s: AnimatedValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
waveform=s.waveform,
speed=s.speed,
min_value=s.min_value,
max_value=s.max_value,
),
AudioValueSource: lambda s: AudioValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
audio_source_id=s.audio_source_id,
mode=s.mode,
sensitivity=s.sensitivity,
smoothing=s.smoothing,
min_value=s.min_value,
max_value=s.max_value,
auto_gain=s.auto_gain,
),
DaylightValueSource: lambda s: DaylightValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
speed=s.speed,
use_real_time=s.use_real_time,
latitude=s.latitude,
min_value=s.min_value,
max_value=s.max_value,
),
StaticColorValueSource: lambda s: StaticColorValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
color=list(s.color),
),
AnimatedColorValueSource: lambda s: AnimatedColorValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
colors=[list(c) for c in s.colors],
speed=s.speed,
easing=s.easing,
),
AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
schedule=s.schedule,
),
HAEntityValueSource: lambda s: HAEntityValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
ha_source_id=s.ha_source_id,
entity_id=s.entity_id,
attribute=s.attribute,
min_ha_value=s.min_ha_value,
max_ha_value=s.max_ha_value,
smoothing=s.smoothing,
),
GradientMapValueSource: lambda s: GradientMapValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
value_source_id=s.value_source_id,
gradient_id=s.gradient_id,
easing=s.easing,
),
CSSExtractValueSource: lambda s: CSSExtractValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
color_strip_source_id=s.color_strip_source_id,
led_start=s.led_start,
led_end=s.led_end,
),
SystemMetricsValueSource: lambda s: SystemMetricsValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
metric=s.metric,
min_value=s.min_value,
max_value=s.max_value,
max_rate=s.max_rate,
disk_path=s.disk_path,
sensor_label=s.sensor_label,
poll_interval=s.poll_interval,
smoothing=s.smoothing,
),
}
def _to_response(source: ValueSource) -> ValueSourceResponse:
"""Convert a ValueSource to a ValueSourceResponse."""
d = source.to_dict()
return ValueSourceResponse(
id=d["id"],
name=d["name"],
source_type=d["source_type"],
value=d.get("value"),
waveform=d.get("waveform"),
speed=d.get("speed"),
min_value=d.get("min_value"),
max_value=d.get("max_value"),
audio_source_id=d.get("audio_source_id"),
mode=d.get("mode"),
sensitivity=d.get("sensitivity"),
smoothing=d.get("smoothing"),
auto_gain=d.get("auto_gain"),
schedule=d.get("schedule"),
picture_source_id=d.get("picture_source_id"),
scene_behavior=d.get("scene_behavior"),
use_real_time=d.get("use_real_time"),
latitude=d.get("latitude"),
description=d.get("description"),
tags=d.get("tags", []),
created_at=source.created_at,
updated_at=source.updated_at,
)
"""Convert a ValueSource dataclass to the matching response schema."""
# AdaptiveValueSource covers both adaptive_time and adaptive_scene
if isinstance(source, AdaptiveValueSource):
if source.source_type == "adaptive_scene":
return AdaptiveSceneValueSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
picture_source_id=source.picture_source_id,
scene_behavior=source.scene_behavior,
sensitivity=source.sensitivity,
smoothing=source.smoothing,
min_value=source.min_value,
max_value=source.max_value,
)
return AdaptiveTimeValueSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
schedule=source.schedule,
min_value=source.min_value,
max_value=source.max_value,
)
builder = _RESPONSE_MAP.get(type(source))
if builder is None:
# Fallback for unknown types — return as static
return StaticValueSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
value=getattr(source, "value", 1.0),
)
return builder(source)
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
async def list_value_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(None, description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene"),
source_type: Optional[str] = Query(
None,
description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene",
),
store: ValueSourceStore = Depends(get_value_source_store),
):
"""List all value sources, optionally filtered by type."""
@@ -75,34 +257,27 @@ async def list_value_sources(
)
@router.post("/api/v1/value-sources", response_model=ValueSourceResponse, status_code=201, tags=["Value Sources"])
@router.post(
"/api/v1/value-sources",
response_model=ValueSourceResponse,
status_code=201,
tags=["Value Sources"],
)
async def create_value_source(
data: ValueSourceCreate,
data: Annotated[ValueSourceCreate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
):
"""Create a new value source."""
try:
# Extract all fields from the discriminated union body
fields = data.model_dump(exclude={"source_type", "name", "description", "tags"})
source = store.create_source(
name=data.name,
source_type=data.source_type,
value=data.value,
waveform=data.waveform,
speed=data.speed,
min_value=data.min_value,
max_value=data.max_value,
audio_source_id=data.audio_source_id,
mode=data.mode,
sensitivity=data.sensitivity,
smoothing=data.smoothing,
description=data.description,
schedule=data.schedule,
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
**fields,
)
fire_entity_event("value_source", "created", source.id)
return _to_response(source)
@@ -113,7 +288,9 @@ async def create_value_source(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
@router.get(
"/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"]
)
async def get_value_source(
source_id: str,
_auth: AuthRequired,
@@ -127,37 +304,21 @@ async def get_value_source(
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
@router.put(
"/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"]
)
async def update_value_source(
source_id: str,
data: ValueSourceUpdate,
data: Annotated[ValueSourceUpdate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
pm: ProcessorManager = Depends(get_processor_manager),
):
"""Update an existing value source."""
try:
source = store.update_source(
source_id=source_id,
name=data.name,
value=data.value,
waveform=data.waveform,
speed=data.speed,
min_value=data.min_value,
max_value=data.max_value,
audio_source_id=data.audio_source_id,
mode=data.mode,
sensitivity=data.sensitivity,
smoothing=data.smoothing,
description=data.description,
schedule=data.schedule,
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
)
# Extract all fields, excluding None values and the discriminator
fields = data.model_dump(exclude={"source_type"}, exclude_none=True)
source = store.update_source(source_id=source_id, **fields)
# Hot-reload running value streams
pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id)
@@ -180,12 +341,11 @@ async def delete_value_source(
try:
# Check if any targets reference this value source
from wled_controller.storage.wled_output_target import WledOutputTarget
for target in target_store.get_all_targets():
if isinstance(target, WledOutputTarget):
if getattr(target, "brightness_value_source_id", "") == source_id:
raise ValueError(
f"Cannot delete: referenced by target '{target.name}'"
)
raise ValueError(f"Cannot delete: referenced by target '{target.name}'")
store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id)
@@ -211,6 +371,7 @@ async def test_value_source_ws(
and streams {value: float} JSON to the client.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -239,12 +400,33 @@ async def test_value_source_ws(
await websocket.accept()
logger.info(f"Value source test WebSocket connected for {source_id}")
# Detect if this stream produces colors
_is_color_stream = (
hasattr(stream, "get_color") and type(stream).get_color is not ValueStream.get_color
)
try:
while True:
value = stream.get_value()
await websocket.send_json({"value": round(value, 4)})
msg: dict = {"value": round(value, 4)}
if _is_color_stream:
try:
r, g, b = stream.get_color()
msg["color"] = [int(r), int(g), int(b)]
except NotImplementedError:
pass
if hasattr(stream, "get_input_value"):
msg["input_value"] = round(stream.get_input_value(), 4)
if hasattr(stream, "get_raw_value"):
raw = stream.get_raw_value()
if raw is not None:
msg["raw_value"] = round(raw, 4)
if hasattr(stream, "_min_ha"):
msg["raw_range"] = [stream._min_ha, stream._max_ha]
await websocket.send_json(msg)
await asyncio.sleep(0.05)
except WebSocketDisconnect:
logger.debug("Value source test WebSocket disconnected for %s", source_id)
pass
except Exception as e:
logger.error(f"Value source test WebSocket error for {source_id}: {e}")
@@ -6,6 +6,7 @@ automations that have a webhook condition. No API-key auth is required —
the secret token itself authenticates the caller.
"""
import secrets
import time
from collections import defaultdict
@@ -43,6 +44,12 @@ def _check_rate_limit(client_ip: str) -> None:
)
_rate_hits[client_ip].append(now)
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth
if len(_rate_hits) > 100:
stale = [ip for ip, ts in _rate_hits.items() if not ts or ts[-1] < window_start]
for ip in stale:
del _rate_hits[ip]
class WebhookPayload(BaseModel):
action: str = Field(description="'activate' or 'deactivate'")
@@ -68,7 +75,7 @@ async def handle_webhook(
# Find the automation that owns this token
for automation in store.get_all_automations():
for condition in automation.conditions:
if isinstance(condition, WebhookCondition) and condition.token == token:
if isinstance(condition, WebhookCondition) and secrets.compare_digest(condition.token, token):
active = body.action == "activate"
await engine.set_webhook_state(token, active)
logger.info(
@@ -61,12 +61,6 @@ from .postprocessing import (
PostprocessingTemplateUpdate,
PPTemplateTestRequest,
)
from .pattern_templates import (
PatternTemplateCreate,
PatternTemplateListResponse,
PatternTemplateResponse,
PatternTemplateUpdate,
)
from .picture_sources import (
ImageValidateRequest,
ImageValidateResponse,
@@ -0,0 +1,37 @@
"""Asset schemas (CRUD)."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class AssetUpdate(BaseModel):
"""Request to update asset metadata."""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name")
description: Optional[str] = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags")
class AssetResponse(BaseModel):
"""Asset response."""
id: str = Field(description="Asset ID")
name: str = Field(description="Display name")
filename: str = Field(description="Original upload filename")
mime_type: str = Field(description="MIME type")
asset_type: str = Field(description="Asset type: sound, image, video, other")
size_bytes: int = Field(description="File size in bytes")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class AssetListResponse(BaseModel):
"""List of assets."""
assets: List[AssetResponse] = Field(description="List of assets")
count: int = Field(description="Number of assets")
@@ -0,0 +1,53 @@
"""Audio processing template schemas."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from .filters import FilterInstanceSchema
class AudioProcessingTemplateCreate(BaseModel):
"""Request to create an audio 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 audio 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 AudioProcessingTemplateUpdate(BaseModel):
"""Request to update an audio 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 audio filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
class AudioProcessingTemplateResponse(BaseModel):
"""Audio processing template information response."""
id: str = Field(description="Template ID")
name: str = Field(description="Template name")
filters: List[FilterInstanceSchema] = Field(
description="Ordered list of audio 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 AudioProcessingTemplateListResponse(BaseModel):
"""List of audio processing templates response."""
templates: List[AudioProcessingTemplateResponse] = Field(
description="List of audio processing templates"
)
count: int = Field(description="Number of templates")
@@ -1,67 +1,122 @@
"""Audio source schemas (CRUD)."""
"""Audio source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import List, Literal, Optional
from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class AudioSourceCreate(BaseModel):
"""Request to create an audio source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["multichannel", "mono", "band_extract"] = Field(description="Source type")
# multichannel fields
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
# mono fields
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
# band_extract fields
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class AudioSourceUpdate(BaseModel):
"""Request to update an audio source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class AudioSourceResponse(BaseModel):
"""Audio source response."""
class _AudioSourceResponseBase(BaseModel):
"""Shared fields for all audio source responses."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type: multichannel, mono, or band_extract")
device_index: Optional[int] = Field(None, description="Audio device index")
is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)")
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class CaptureAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["capture"] = "capture"
device_index: int = Field(description="Audio device index (-1 = default)")
is_loopback: bool = Field(description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["processed"] = "processed"
audio_source_id: str = Field(description="Input audio source ID")
audio_processing_template_id: str = Field(description="Audio processing template ID")
AudioSourceResponse = Annotated[
Union[
Annotated[CaptureAudioSourceResponse, Tag("capture")],
Annotated[ProcessedAudioSourceResponse, Tag("processed")],
],
Discriminator("source_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _AudioSourceCreateBase(BaseModel):
"""Shared fields for all audio source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class CaptureAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["capture"] = "capture"
device_index: int = Field(-1, description="Audio device index (-1 = default)")
is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["processed"] = "processed"
audio_source_id: str = Field(description="Input audio source ID")
audio_processing_template_id: str = Field(description="Audio processing template ID")
AudioSourceCreate = Annotated[
Union[
Annotated[CaptureAudioSourceCreate, Tag("capture")],
Annotated[ProcessedAudioSourceCreate, Tag("processed")],
],
Discriminator("source_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _AudioSourceUpdateBase(BaseModel):
"""Shared fields for all audio source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["capture"] = "capture"
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["processed"] = "processed"
audio_source_id: Optional[str] = Field(None, description="Input audio source ID")
audio_processing_template_id: Optional[str] = Field(
None, description="Audio processing template ID"
)
AudioSourceUpdate = Annotated[
Union[
Annotated[CaptureAudioSourceUpdate, Tag("capture")],
Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
],
Discriminator("source_type"),
]
# =====================================================================
# List response
# =====================================================================
class AudioSourceListResponse(BaseModel):
"""List of audio sources."""
@@ -6,27 +6,50 @@ from typing import List, Optional
from pydantic import BaseModel, Field
class ConditionSchema(BaseModel):
"""A single condition within an automation."""
class RuleSchema(BaseModel):
"""A single rule within an automation."""
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
# Application condition fields
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
# Time-of-day condition fields
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day condition)")
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
# System idle condition fields
idle_minutes: Optional[int] = Field(None, description="Idle timeout in minutes (for system_idle condition)")
when_idle: Optional[bool] = Field(None, description="True=active when idle (for system_idle condition)")
# Display state condition fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
# MQTT condition fields
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)")
# Webhook condition fields
token: Optional[str] = Field(None, description="Secret token for webhook URL (for webhook condition)")
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
# Application rule fields
apps: Optional[List[str]] = Field(None, description="Process names (for application rule)")
match_type: Optional[str] = Field(
None, description="'running' or 'topmost' (for application rule)"
)
# Time-of-day rule fields
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day rule)")
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day rule)")
# System idle rule fields
idle_minutes: Optional[int] = Field(
None, description="Idle timeout in minutes (for system_idle rule)"
)
when_idle: Optional[bool] = Field(
None, description="True=active when idle (for system_idle rule)"
)
# Display state rule fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)")
# MQTT rule fields
mqtt_source_id: Optional[str] = Field(None, description="MQTT source ID (for mqtt rule)")
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt rule)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)")
match_mode: Optional[str] = Field(
None, description="'exact', 'contains', or 'regex' (for mqtt rule)"
)
# Webhook rule fields
token: Optional[str] = Field(
None, description="Secret token for webhook URL (for webhook rule)"
)
# Home Assistant rule fields
ha_source_id: Optional[str] = Field(
None, description="Home Assistant source ID (for home_assistant rule)"
)
entity_id: Optional[str] = Field(
None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
)
# Backward-compatible alias
ConditionSchema = RuleSchema
class AutomationCreate(BaseModel):
@@ -34,11 +57,15 @@ class AutomationCreate(BaseModel):
name: str = Field(description="Automation name", min_length=1, max_length=100)
enabled: bool = Field(default=True, description="Whether the automation is enabled")
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
rule_logic: str = Field(default="or", description="How rules combine: 'or' or 'and'")
rules: List[RuleSchema] = Field(default_factory=list, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
deactivation_mode: str = Field(
default="none", description="'none', 'revert', or 'fallback_scene'"
)
deactivation_scene_preset_id: Optional[str] = Field(
None, description="Scene preset for fallback deactivation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -47,11 +74,15 @@ class AutomationUpdate(BaseModel):
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
rule_logic: Optional[str] = Field(None, description="How rules combine: 'or' or 'and'")
rules: Optional[List[RuleSchema]] = Field(None, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: Optional[str] = Field(None, description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
deactivation_mode: Optional[str] = Field(
None, description="'none', 'revert', or 'fallback_scene'"
)
deactivation_scene_preset_id: Optional[str] = Field(
None, description="Scene preset for fallback deactivation"
)
tags: Optional[List[str]] = None
@@ -61,16 +92,22 @@ class AutomationResponse(BaseModel):
id: str = Field(description="Automation ID")
name: str = Field(description="Automation name")
enabled: bool = Field(description="Whether the automation is enabled")
condition_logic: str = Field(description="Condition combination logic")
conditions: List[ConditionSchema] = Field(description="List of conditions")
rule_logic: str = Field(description="Rule combination logic")
rules: List[RuleSchema] = Field(description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
webhook_url: Optional[str] = Field(None, description="Webhook URL for the first webhook condition (if any)")
webhook_url: Optional[str] = Field(
None, description="Webhook URL for the first webhook rule (if any)"
)
is_active: bool = Field(default=False, description="Whether the automation is currently active")
last_activated_at: Optional[datetime] = Field(None, description="Last time this automation was activated")
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this automation was deactivated")
last_activated_at: Optional[datetime] = Field(
None, description="Last time this automation was activated"
)
last_deactivated_at: Optional[datetime] = Field(
None, description="Last time this automation was deactivated"
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -1,26 +1,44 @@
"""Color strip source schemas (CRUD)."""
"""Color strip source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
from wled_controller.api.schemas.devices import Calibration
# =====================================================================
# Helper models (unchanged)
# =====================================================================
class AppSoundOverride(BaseModel):
"""Per-application sound override for notification sources."""
sound_asset_id: Optional[str] = Field(
None, description="Asset ID for the sound (None = mute this app)"
)
volume: Optional[float] = Field(
None, ge=0.0, le=1.0, description="Volume override (None = use global)"
)
class AnimationConfig(BaseModel):
"""Procedural animation configuration for static/gradient color strip sources."""
enabled: bool = True
type: str = "breathing" # breathing | color_cycle | gradient_shift | wave
speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.110.0)")
speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1-10.0)")
class ColorStop(BaseModel):
"""A single color stop in a gradient."""
position: float = Field(description="Relative position along the strip (0.01.0)", ge=0.0, le=1.0)
color: List[int] = Field(description="Primary RGB color [R, G, B] (0255 each)")
position: float = Field(
description="Relative position along the strip (0.0-1.0)", ge=0.0, le=1.0
)
color: List[int] = Field(description="Primary RGB color [R, G, B] (0-255 each)")
color_right: Optional[List[int]] = Field(
None,
description="Optional right-side RGB color for a hard edge (bidirectional stop)",
@@ -31,13 +49,21 @@ class CompositeLayer(BaseModel):
"""A single layer in a composite color strip source."""
source_id: str = Field(description="ID of the layer's color strip source")
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen|override")
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")
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"
)
start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)")
end: int = Field(default=0, ge=0, description="Last LED index exclusive for range (0 = full strip)")
end: int = Field(
default=0, ge=0, description="Last LED index exclusive for range (0 = full strip)"
)
reverse: bool = Field(default=False, description="Reverse layer output within its range")
@@ -50,221 +76,572 @@ class MappedZone(BaseModel):
reverse: bool = Field(default=False, description="Reverse zone output")
class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed", "weather"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora|rain|comet|bouncing_ball|fireworks|sparkle_rain|lava_lamp|wave_interference")
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice) or 'custom'")
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor/comet)")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops [[pos,R,G,B],...]")
# gradient entity reference (effect, gradient, audio types)
gradient_id: Optional[str] = Field(None, description="Gradient entity ID (overrides palette/inline stops)")
# gradient-type easing
easing: Optional[str] = Field(None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic")
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# mapped-type fields
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
# shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0)
# notification-type fields
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB) for notifications")
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color (#RRGGBB)")
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0)
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
wind_strength: Optional[float] = Field(None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0)
candle_type: Optional[str] = Field(None, description="Candle type preset: default|taper|votive|bonfire")
# 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)")
# weather-type fields
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID (for weather type)")
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0)
# 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")
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class ColorStripSourceUpdate(BaseModel):
"""Request to update a color strip source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
# picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops [[pos,R,G,B],...]")
# gradient entity reference (effect, gradient, audio types)
gradient_id: Optional[str] = Field(None, description="Gradient entity ID (overrides palette/inline stops)")
# gradient-type easing
easing: Optional[str] = Field(None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic")
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# mapped-type fields
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
# shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0)
# notification-type fields
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)")
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color")
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0)
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
wind_strength: Optional[float] = Field(None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0)
candle_type: Optional[str] = Field(None, description="Candle type preset: default|taper|votive|bonfire")
# 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)")
# weather-type fields
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID (for weather type)")
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0)
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
tags: Optional[List[str]] = None
class ColorStripSourceResponse(BaseModel):
"""Color strip source response."""
class _CSSResponseBase(BaseModel):
"""Shared fields for all color strip source responses."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type")
# picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B]")
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
intensity: Optional[float] = Field(None, description="Effect intensity")
scale: Optional[float] = Field(None, description="Spatial scale")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
# gradient-type easing
easing: Optional[str] = Field(None, description="Gradient interpolation easing")
# composite-type fields
layers: Optional[List[dict]] = Field(None, description="Layers for composite type")
# mapped-type fields
zones: Optional[List[dict]] = Field(None, description="Zones for mapped type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity")
color_peak: Optional[List[int]] = Field(None, description="Peak color [R,G,B]")
# shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)")
# notification-type fields
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
duration_ms: Optional[int] = Field(None, description="Effect duration in milliseconds")
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)")
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color")
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
longitude: Optional[float] = Field(None, description="Longitude for daylight timing")
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources")
wind_strength: Optional[float] = Field(None, description="Wind simulation strength")
candle_type: Optional[str] = Field(None, description="Candle type preset")
# 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")
# weather-type fields
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength")
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
led_count: int = Field(0, description="Total LED count (0 = auto)")
overlay_active: bool = Field(
False, description="Whether the screen overlay is currently active"
)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class PictureCSSResponse(_CSSResponseBase):
source_type: Literal["picture"] = "picture"
picture_source_id: str = Field(description="Picture source ID")
smoothing: Any = Field(description="Temporal smoothing")
interpolation_mode: str = Field(description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
class PictureAdvancedCSSResponse(_CSSResponseBase):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(description="Temporal smoothing")
interpolation_mode: str = Field(description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
class StaticCSSResponse(_CSSResponseBase):
source_type: Literal["static"] = "static"
color: Any = Field(description="Static RGB color")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
class GradientCSSResponse(_CSSResponseBase):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
easing: str = Field(description="Gradient interpolation easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSResponse(_CSSResponseBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: List[List[int]] = Field(description="List of [R,G,B] colors to cycle")
class EffectCSSResponse(_CSSResponseBase):
source_type: Literal["effect"] = "effect"
effect_type: str = Field(description="Effect algorithm")
palette: str = Field(description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
color: Any = Field(description="Primary color")
intensity: Any = Field(description="Effect intensity")
scale: Any = Field(description="Spatial scale")
mirror: bool = Field(description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
class CompositeCSSResponse(_CSSResponseBase):
source_type: Literal["composite"] = "composite"
layers: List[dict] = Field(default_factory=list, description="Composite layers")
class MappedCSSResponse(_CSSResponseBase):
source_type: Literal["mapped"] = "mapped"
zones: List[dict] = Field(default_factory=list, description="Mapped zones")
class AudioCSSResponse(_CSSResponseBase):
source_type: Literal["audio"] = "audio"
visualization_mode: str = Field(description="Audio visualization mode")
audio_source_id: str = Field(description="Mono audio source ID")
sensitivity: Any = Field(description="Audio sensitivity")
smoothing: Any = Field(description="Temporal smoothing")
palette: str = Field(description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
color: Any = Field(description="Primary color")
color_peak: Any = Field(description="Peak color")
mirror: bool = Field(description="Mirror mode")
beat_decay: Any = Field(default=0.15, description="Beat pulse decay rate (music modes)")
class ApiInputCSSResponse(_CSSResponseBase):
source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(description="Fallback RGB color")
timeout: Any = Field(description="Timeout before fallback")
interpolation: str = Field(description="LED count interpolation mode")
class NotificationCSSResponse(_CSSResponseBase):
source_type: Literal["notification"] = "notification"
notification_effect: str = Field(description="Notification effect")
duration_ms: Any = Field(description="Effect duration in milliseconds")
default_color: Any = Field(None, description="Default color")
app_colors: Dict[str, str] = Field(default_factory=dict, description="Per-app hex colors")
app_filter_mode: str = Field(description="App filter mode")
app_filter_list: List[str] = Field(default_factory=list, description="App names for filter")
os_listener: bool = Field(description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(description="Global notification sound volume")
app_sounds: Dict[str, dict] = Field(default_factory=dict, description="Per-app sound overrides")
class DaylightCSSResponse(_CSSResponseBase):
source_type: Literal["daylight"] = "daylight"
speed: Any = Field(description="Cycle speed multiplier")
use_real_time: bool = Field(description="Use wall-clock time")
latitude: float = Field(description="Latitude for daylight timing")
longitude: float = Field(description="Longitude for daylight timing")
class CandlelightCSSResponse(_CSSResponseBase):
source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(description="Candle color")
intensity: Any = Field(description="Candle intensity")
num_candles: int = Field(description="Number of independent candle sources")
speed: Any = Field(description="Flicker speed multiplier")
wind_strength: Any = Field(description="Wind simulation strength")
candle_type: str = Field(description="Candle type preset")
class ProcessedCSSResponse(_CSSResponseBase):
source_type: Literal["processed"] = "processed"
input_source_id: str = Field(description="Input color strip source ID")
processing_template_id: str = Field(description="Color strip processing template ID")
class WeatherCSSResponse(_CSSResponseBase):
source_type: Literal["weather"] = "weather"
weather_source_id: str = Field(description="Weather source entity ID")
speed: Any = Field(description="Speed multiplier")
temperature_influence: Any = Field(description="Temperature color shift strength")
class KeyColorsCSSResponse(_CSSResponseBase):
source_type: Literal["key_colors"] = "key_colors"
picture_source_id: str = Field(description="Picture source ID")
rectangles: List[dict] = Field(default_factory=list, description="Named screen regions")
interpolation_mode: str = Field(description="Interpolation mode")
smoothing: Any = Field(description="Temporal smoothing")
brightness: Any = Field(description="Brightness")
class MathWaveCSSResponse(_CSSResponseBase):
source_type: Literal["math_wave"] = "math_wave"
waves: List[dict] = Field(description="Wave layer definitions")
speed: Any = Field(description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
ColorStripSourceResponse = Annotated[
Union[
Annotated[PictureCSSResponse, Tag("picture")],
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
Annotated[StaticCSSResponse, Tag("static")],
Annotated[GradientCSSResponse, Tag("gradient")],
Annotated[ColorCycleCSSResponse, Tag("color_cycle")],
Annotated[EffectCSSResponse, Tag("effect")],
Annotated[CompositeCSSResponse, Tag("composite")],
Annotated[MappedCSSResponse, Tag("mapped")],
Annotated[AudioCSSResponse, Tag("audio")],
Annotated[ApiInputCSSResponse, Tag("api_input")],
Annotated[NotificationCSSResponse, Tag("notification")],
Annotated[DaylightCSSResponse, Tag("daylight")],
Annotated[CandlelightCSSResponse, Tag("candlelight")],
Annotated[ProcessedCSSResponse, Tag("processed")],
Annotated[WeatherCSSResponse, Tag("weather")],
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
Annotated[MathWaveCSSResponse, Tag("math_wave")],
],
Discriminator("source_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _CSSCreateBase(BaseModel):
"""Shared fields for all color strip source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
led_count: int = Field(default=0, description="Total LED count (0 = auto)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class PictureCSSCreate(_CSSCreateBase):
source_type: Literal["picture"] = "picture"
picture_source_id: str = Field(default="", description="Picture source ID")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: str = Field(default="average", description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
class PictureAdvancedCSSCreate(_CSSCreateBase):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: str = Field(default="average", description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
class StaticCSSCreate(_CSSCreateBase):
source_type: Literal["static"] = "static"
color: Any = Field(default=None, description="Static RGB color [R,G,B]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
class GradientCSSCreate(_CSSCreateBase):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSCreate(_CSSCreateBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
class EffectCSSCreate(_CSSCreateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
class CompositeCSSCreate(_CSSCreateBase):
source_type: Literal["composite"] = "composite"
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
class MappedCSSCreate(_CSSCreateBase):
source_type: Literal["mapped"] = "mapped"
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
class AudioCSSCreate(_CSSCreateBase):
source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode")
beat_decay: Any = Field(
default=None, description="Beat pulse decay rate (music modes, 0.01-0.5)"
)
class ApiInputCSSCreate(_CSSCreateBase):
source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]")
timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode")
class NotificationCSSCreate(_CSSCreateBase):
source_type: Literal["notification"] = "notification"
notification_effect: Optional[str] = Field(None, description="Notification effect")
duration_ms: Any = Field(default=None, description="Effect duration in milliseconds")
default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field(
None, description="Default color"
)
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(default=None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(
None, description="Per-app sound overrides"
)
class DaylightCSSCreate(_CSSCreateBase):
source_type: Literal["daylight"] = "daylight"
speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
)
class CandlelightCSSCreate(_CSSCreateBase):
source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]")
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
num_candles: Optional[int] = Field(
None, description="Number of candle sources (1-20)", ge=1, le=20
)
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
candle_type: Optional[str] = Field(None, description="Candle type preset")
class ProcessedCSSCreate(_CSSCreateBase):
source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID")
class WeatherCSSCreate(_CSSCreateBase):
source_type: Literal["weather"] = "weather"
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
class KeyColorsCSSCreate(_CSSCreateBase):
source_type: Literal["key_colors"] = "key_colors"
picture_source_id: str = Field(default="", description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions")
interpolation_mode: str = Field(default="average", description="Interpolation mode")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field(
None, description="Dynamic brightness value source ID"
)
class MathWaveCSSCreate(_CSSCreateBase):
source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable, 0.1-10.0)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
ColorStripSourceCreate = Annotated[
Union[
Annotated[PictureCSSCreate, Tag("picture")],
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
Annotated[StaticCSSCreate, Tag("static")],
Annotated[GradientCSSCreate, Tag("gradient")],
Annotated[ColorCycleCSSCreate, Tag("color_cycle")],
Annotated[EffectCSSCreate, Tag("effect")],
Annotated[CompositeCSSCreate, Tag("composite")],
Annotated[MappedCSSCreate, Tag("mapped")],
Annotated[AudioCSSCreate, Tag("audio")],
Annotated[ApiInputCSSCreate, Tag("api_input")],
Annotated[NotificationCSSCreate, Tag("notification")],
Annotated[DaylightCSSCreate, Tag("daylight")],
Annotated[CandlelightCSSCreate, Tag("candlelight")],
Annotated[ProcessedCSSCreate, Tag("processed")],
Annotated[WeatherCSSCreate, Tag("weather")],
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
Annotated[MathWaveCSSCreate, Tag("math_wave")],
],
Discriminator("source_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _CSSUpdateBase(BaseModel):
"""Shared fields for all color strip source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: Optional[List[str]] = None
class PictureCSSUpdate(_CSSUpdateBase):
source_type: Literal["picture"] = "picture"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
class PictureAdvancedCSSUpdate(_CSSUpdateBase):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
class StaticCSSUpdate(_CSSUpdateBase):
source_type: Literal["static"] = "static"
color: Any = Field(default=None, description="Static RGB color [R,G,B]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
class GradientCSSUpdate(_CSSUpdateBase):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSUpdate(_CSSUpdateBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
class EffectCSSUpdate(_CSSUpdateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
class CompositeCSSUpdate(_CSSUpdateBase):
source_type: Literal["composite"] = "composite"
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
class MappedCSSUpdate(_CSSUpdateBase):
source_type: Literal["mapped"] = "mapped"
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
class AudioCSSUpdate(_CSSUpdateBase):
source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode")
beat_decay: Any = Field(default=None, description="Beat pulse decay rate (music modes)")
class ApiInputCSSUpdate(_CSSUpdateBase):
source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]")
timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode")
class NotificationCSSUpdate(_CSSUpdateBase):
source_type: Literal["notification"] = "notification"
notification_effect: Optional[str] = Field(None, description="Notification effect")
duration_ms: Any = Field(default=None, description="Effect duration in milliseconds")
default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field(
None, description="Default color"
)
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(default=None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(
None, description="Per-app sound overrides"
)
class DaylightCSSUpdate(_CSSUpdateBase):
source_type: Literal["daylight"] = "daylight"
speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
)
class CandlelightCSSUpdate(_CSSUpdateBase):
source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]")
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
num_candles: Optional[int] = Field(
None, description="Number of candle sources (1-20)", ge=1, le=20
)
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
candle_type: Optional[str] = Field(None, description="Candle type preset")
class ProcessedCSSUpdate(_CSSUpdateBase):
source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID")
class WeatherCSSUpdate(_CSSUpdateBase):
source_type: Literal["weather"] = "weather"
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
class KeyColorsCSSUpdate(_CSSUpdateBase):
source_type: Literal["key_colors"] = "key_colors"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field(
None, description="Dynamic brightness value source ID"
)
class MathWaveCSSUpdate(_CSSUpdateBase):
source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
ColorStripSourceUpdate = Annotated[
Union[
Annotated[PictureCSSUpdate, Tag("picture")],
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
Annotated[StaticCSSUpdate, Tag("static")],
Annotated[GradientCSSUpdate, Tag("gradient")],
Annotated[ColorCycleCSSUpdate, Tag("color_cycle")],
Annotated[EffectCSSUpdate, Tag("effect")],
Annotated[CompositeCSSUpdate, Tag("composite")],
Annotated[MappedCSSUpdate, Tag("mapped")],
Annotated[AudioCSSUpdate, Tag("audio")],
Annotated[ApiInputCSSUpdate, Tag("api_input")],
Annotated[NotificationCSSUpdate, Tag("notification")],
Annotated[DaylightCSSUpdate, Tag("daylight")],
Annotated[CandlelightCSSUpdate, Tag("candlelight")],
Annotated[ProcessedCSSUpdate, Tag("processed")],
Annotated[WeatherCSSUpdate, Tag("weather")],
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
],
Discriminator("source_type"),
]
# =====================================================================
# List response
# =====================================================================
class ColorStripSourceListResponse(BaseModel):
"""List of color strip sources."""
@@ -272,6 +649,11 @@ class ColorStripSourceListResponse(BaseModel):
count: int = Field(description="Number of sources")
# =====================================================================
# Request-only schemas (unchanged)
# =====================================================================
class SegmentPayload(BaseModel):
"""A single segment for segment-based LED color updates."""
@@ -279,7 +661,9 @@ class SegmentPayload(BaseModel):
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],...]")
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":
@@ -310,8 +694,12 @@ class ColorPushRequest(BaseModel):
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")
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":
@@ -0,0 +1,194 @@
"""Pydantic schemas for game integration API endpoints."""
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ── Event Mapping ──────────────────────────────────────────────────────────
class EventMappingSchema(BaseModel):
"""Maps a standard game event type to a visual effect."""
event_type: str = Field(description="Standard event type (e.g. 'health', 'kill')")
effect: str = Field(default="flash", description="Effect name (flash, pulse, gradient)")
color: List[int] = Field(
default=[255, 0, 0],
description="RGB color [R, G, B] (0-255 each)",
min_length=3,
max_length=3,
)
duration_ms: int = Field(default=500, ge=0, le=60000, description="Effect duration in ms")
intensity: float = Field(default=1.0, ge=0.0, le=1.0, description="Effect intensity 0.0-1.0")
priority: int = Field(default=0, ge=0, le=100, description="Priority for effect stacking")
# ── CRUD Schemas ───────────────────────────────────────────────────────────
class GameIntegrationCreate(BaseModel):
"""Request to create a game integration config."""
name: str = Field(description="Integration name", min_length=1, max_length=100)
adapter_type: str = Field(description="Adapter type identifier", min_length=1)
enabled: bool = Field(default=True, description="Whether integration is active")
adapter_config: Dict[str, Any] = Field(
default_factory=dict, description="Adapter-specific settings"
)
event_mappings: List[EventMappingSchema] = Field(
default_factory=list, description="Event-to-effect mappings"
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class GameIntegrationUpdate(BaseModel):
"""Request to update a game integration config."""
name: Optional[str] = Field(None, description="Integration name", min_length=1, max_length=100)
adapter_type: Optional[str] = Field(None, description="Adapter type identifier", min_length=1)
enabled: Optional[bool] = Field(None, description="Whether integration is active")
adapter_config: Optional[Dict[str, Any]] = Field(None, description="Adapter-specific settings")
event_mappings: Optional[List[EventMappingSchema]] = Field(
None, description="Event-to-effect mappings"
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: Optional[List[str]] = Field(None, description="User-defined tags")
class GameIntegrationResponse(BaseModel):
"""Game integration config response."""
id: str = Field(description="Integration ID")
name: str = Field(description="Integration name")
adapter_type: str = Field(description="Adapter type identifier")
enabled: bool = Field(description="Whether integration is active")
adapter_config: Dict[str, Any] = Field(description="Adapter-specific settings")
event_mappings: List[EventMappingSchema] = Field(description="Event-to-effect mappings")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Integration description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class GameIntegrationListResponse(BaseModel):
"""List of game integration configs."""
integrations: List[GameIntegrationResponse] = Field(
description="List of game integration configs"
)
count: int = Field(description="Number of integrations")
# ── Event Ingestion ────────────────────────────────────────────────────────
class GameEventPayload(BaseModel):
"""Incoming game event payload from a game client.
The shape depends on the adapter — this is a generic envelope.
The adapter's parse_payload() extracts standardized events.
"""
data: Dict[str, Any] = Field(description="Raw game event data")
# ── Adapter Metadata ───────────────────────────────────────────────────────
class AdapterInfoResponse(BaseModel):
"""Metadata for a registered game adapter."""
adapter_type: str = Field(description="Adapter type identifier")
display_name: str = Field(description="Human-readable adapter name")
game_name: str = Field(description="Game this adapter supports")
supported_events: List[str] = Field(description="Standard event types supported")
config_schema: List[Dict[str, Any]] = Field(description="Flat list of config fields")
setup_instructions: str = Field(description="Markdown setup guide")
supports_auto_setup: bool = Field(
default=False, description="Whether this adapter supports automatic config setup"
)
class AdapterListResponse(BaseModel):
"""List of available game adapters."""
adapters: List[AdapterInfoResponse] = Field(description="Available adapters")
count: int = Field(description="Number of adapters")
# ── Status / Diagnostics ──────────────────────────────────────────────────
class GameIntegrationStatusResponse(BaseModel):
"""Runtime status for a game integration."""
integration_id: str = Field(description="Integration ID")
enabled: bool = Field(description="Whether integration is active")
connected: bool = Field(description="Whether adapter is currently receiving data")
last_event_time: Optional[float] = Field(None, description="Monotonic timestamp of last event")
event_count: int = Field(default=0, description="Total events received")
event_counts_by_type: Dict[str, int] = Field(
default_factory=dict, description="Event counts per event type"
)
class GameEventResponse(BaseModel):
"""A single game event for diagnostics."""
adapter_id: str = Field(description="Adapter that produced this event")
event_type: str = Field(description="Standard event type")
value: float = Field(description="Normalized value 0.0-1.0")
timestamp: float = Field(description="Monotonic timestamp")
raw_data: Dict[str, Any] = Field(default_factory=dict, description="Original game data")
class RecentEventsResponse(BaseModel):
"""Recent events for a game integration."""
integration_id: str = Field(description="Integration ID")
events: List[GameEventResponse] = Field(description="Recent events (newest last)")
count: int = Field(description="Number of events returned")
# ── Presets ──────────────────────────────────────────────────────────────
class EffectPresetResponse(BaseModel):
"""A built-in effect preset."""
key: str = Field(description="Unique preset key")
name: str = Field(description="Display name")
description: str = Field(description="One-line description")
target_game_types: List[str] = Field(description="Genre tags (fps, moba, racing, any)")
event_mappings: List[EventMappingSchema] = Field(description="Pre-configured mappings")
class PresetListResponse(BaseModel):
"""List of available effect presets."""
presets: List[EffectPresetResponse] = Field(description="Available presets")
count: int = Field(description="Number of presets")
class ApplyPresetRequest(BaseModel):
"""Request to apply a preset to an integration."""
preset_key: str = Field(description="Key of the preset to apply")
replace: bool = Field(
default=False,
description="If true, replace existing mappings; if false, append",
)
class AutoSetupResponse(BaseModel):
"""Result of an auto-setup operation."""
success: bool = Field(description="Whether the setup completed successfully")
file_path: str = Field(default="", description="Path to the config file written")
message: str = Field(description="Human-readable result message")
token_generated: bool = Field(
default=False, description="Whether a new auth token was auto-generated"
)
@@ -0,0 +1,97 @@
"""Home Assistant source schemas (CRUD + test + entities)."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class HomeAssistantSourceCreate(BaseModel):
"""Request to create a Home Assistant source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
host: str = Field(description="HA host:port (e.g. '192.168.1.100:8123')", min_length=1)
token: str = Field(description="Long-Lived Access Token", min_length=1)
use_ssl: bool = Field(default=False, description="Use wss:// instead of ws://")
entity_filters: List[str] = Field(
default_factory=list, description="Entity ID filter patterns (e.g. ['sensor.*'])"
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class HomeAssistantSourceUpdate(BaseModel):
"""Request to update a Home Assistant source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
host: Optional[str] = Field(None, description="HA host:port", min_length=1)
token: Optional[str] = Field(None, description="Long-Lived Access Token", min_length=1)
use_ssl: Optional[bool] = Field(None, description="Use wss://")
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class HomeAssistantSourceResponse(BaseModel):
"""Home Assistant source response."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
host: str = Field(description="HA host:port")
use_ssl: bool = Field(description="Whether SSL is enabled")
entity_filters: List[str] = Field(default_factory=list, description="Entity filter patterns")
connected: bool = Field(default=False, description="Whether the WebSocket connection is active")
entity_count: int = Field(default=0, description="Number of cached entities")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class HomeAssistantSourceListResponse(BaseModel):
"""List of Home Assistant sources."""
sources: List[HomeAssistantSourceResponse] = Field(description="List of HA sources")
count: int = Field(description="Number of sources")
class HomeAssistantEntityResponse(BaseModel):
"""A single HA entity."""
entity_id: str = Field(description="Entity ID (e.g. 'sensor.temperature')")
state: str = Field(description="Current state value")
friendly_name: str = Field(description="Human-readable name")
domain: str = Field(description="Entity domain (e.g. 'sensor', 'binary_sensor')")
class HomeAssistantEntityListResponse(BaseModel):
"""List of entities from a HA instance."""
entities: List[HomeAssistantEntityResponse] = Field(description="List of entities")
count: int = Field(description="Number of entities")
class HomeAssistantTestResponse(BaseModel):
"""Connection test result."""
success: bool = Field(description="Whether connection and auth succeeded")
ha_version: Optional[str] = Field(None, description="Home Assistant version")
entity_count: int = Field(default=0, description="Number of entities found")
error: Optional[str] = Field(None, description="Error message if connection failed")
class HomeAssistantConnectionStatus(BaseModel):
"""Connection status for dashboard indicators."""
source_id: str
name: str
connected: bool
entity_count: int
class HomeAssistantStatusResponse(BaseModel):
"""Overall HA integration status for dashboard."""
connections: List[HomeAssistantConnectionStatus]
total_sources: int
connected_count: int
@@ -0,0 +1,83 @@
"""MQTT source schemas (CRUD + test + status)."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class MQTTSourceCreate(BaseModel):
"""Request to create an MQTT source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
broker_host: str = Field(description="MQTT broker hostname or IP", min_length=1)
broker_port: int = Field(default=1883, description="MQTT broker port", ge=1, le=65535)
username: str = Field(default="", description="Broker username (optional)")
password: str = Field(default="", description="Broker password (optional)")
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class MQTTSourceUpdate(BaseModel):
"""Request to update an MQTT source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
broker_host: Optional[str] = Field(None, description="MQTT broker hostname or IP", min_length=1)
broker_port: Optional[int] = Field(None, description="MQTT broker port", ge=1, le=65535)
username: Optional[str] = Field(None, description="Broker username")
password: Optional[str] = Field(None, description="Broker password")
client_id: Optional[str] = Field(None, description="MQTT client ID")
base_topic: Optional[str] = Field(None, description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class MQTTSourceResponse(BaseModel):
"""MQTT source response."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
broker_host: str = Field(description="Broker hostname or IP")
broker_port: int = Field(description="Broker port")
username: str = Field(default="", description="Broker username")
password_set: bool = Field(default=False, description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
connected: bool = Field(default=False, description="Whether the broker connection is active")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class MQTTSourceListResponse(BaseModel):
"""List of MQTT sources."""
sources: List[MQTTSourceResponse] = Field(description="List of MQTT sources")
count: int = Field(description="Number of sources")
class MQTTTestResponse(BaseModel):
"""Connection test result."""
success: bool = Field(description="Whether broker connection succeeded")
error: Optional[str] = Field(None, description="Error message if connection failed")
class MQTTConnectionStatus(BaseModel):
"""Connection status for dashboard indicators."""
source_id: str
name: str
connected: bool
broker: str
class MQTTStatusResponse(BaseModel):
"""Overall MQTT integration status for dashboard."""
connections: List[MQTTConnectionStatus]
total_sources: int
connected_count: int
@@ -1,12 +1,26 @@
"""Output target schemas (CRUD, processing state, metrics)."""
"""Output target schemas — discriminated unions per target type."""
from datetime import datetime
from typing import Dict, Optional, List
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
# ---------------------------------------------------------------------------
# BindableFloat — accepts plain number OR {value, source_id} dict
# ---------------------------------------------------------------------------
BindableFloatInput = Union[float, int, Dict[str, Any]]
"""API input type: a plain number (static) or {"value": float, "source_id": str}."""
class BindableFloatSchema(BaseModel):
"""Response schema for a bindable scalar property."""
value: float = Field(description="Static value (used when source_id is empty)")
source_id: str = Field(default="", description="Value source ID (empty = static)")
class KeyColorRectangleSchema(BaseModel):
"""A named rectangle for key color extraction (relative coords 0.0-1.0)."""
@@ -18,102 +32,235 @@ class KeyColorRectangleSchema(BaseModel):
height: float = Field(default=1.0, description="Height (0.0-1.0)", gt=0.0, le=1.0)
class KeyColorsSettingsSchema(BaseModel):
"""Settings for key colors extraction."""
class HALightMappingSchema(BaseModel):
"""Maps an LED range to one HA light entity."""
fps: int = Field(default=10, description="Extraction rate (1-60)", ge=1, le=60)
interpolation_mode: str = Field(default="average", description="Color mode (average, median, dominant)")
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
pattern_template_id: str = Field(default="", description="Pattern template ID for rectangle layout")
brightness: float = Field(default=1.0, description="Output brightness (0.0-1.0)", ge=0.0, le=1.0)
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
brightness_scale: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness multiplier (bindable)"
)
class ExtractedColorResponse(BaseModel):
"""A single extracted color."""
r: int = Field(description="Red (0-255)")
g: int = Field(description="Green (0-255)")
b: int = Field(description="Blue (0-255)")
hex: str = Field(description="Hex color (#rrggbb)")
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class KeyColorsResponse(BaseModel):
"""Extracted key colors for a target."""
target_id: str = Field(description="Target ID")
colors: Dict[str, ExtractedColorResponse] = Field(description="Rectangle name -> color")
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
class OutputTargetCreate(BaseModel):
"""Request to create an output target."""
name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="led", description="Target type (led, key_colors)")
# LED target fields
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
min_brightness_threshold: int = Field(default=0, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
protocol: str = Field(default="ddp", pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)")
# KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class OutputTargetUpdate(BaseModel):
"""Request to update an output target."""
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
# LED target fields
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness_value_source_id: Optional[str] = Field(None, description="Brightness value source ID")
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
min_brightness_threshold: Optional[int] = Field(None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
adaptive_fps: Optional[bool] = Field(None, description="Auto-reduce FPS when device is unresponsive")
protocol: Optional[str] = Field(None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)")
# KC target fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class OutputTargetResponse(BaseModel):
"""Output target response."""
class _OutputTargetResponseBase(BaseModel):
"""Shared fields for all output target responses."""
id: str = Field(description="Target ID")
name: str = Field(description="Target name")
target_type: str = Field(description="Target type")
# LED target fields
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
fps: Optional[int] = Field(None, description="Target send FPS")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
min_brightness_threshold: int = Field(default=0, description="Min brightness threshold (0=disabled)")
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
# KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class LedOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
class HALightOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
OutputTargetResponse = Annotated[
Union[
Annotated[LedOutputTargetResponse, Tag("led")],
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
],
Discriminator("target_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _OutputTargetCreateBase(BaseModel):
"""Shared fields for all output target create requests."""
name: str = Field(description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class LedOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
fps: Optional[BindableFloatInput] = Field(
default=30, description="Target send FPS (bindable, 1-90)"
)
keepalive_interval: float = Field(
default=1.0,
description="Keepalive send interval when screen is static (0.5-5.0s)",
ge=0.5,
le=5.0,
)
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL,
description="Device health check interval (5-600s)",
ge=5,
le=600,
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str = Field(
default="ddp",
pattern="^(ddp|http)$",
description="Send protocol: ddp (UDP) or http (JSON API)",
)
class HALightOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
default=2.0, description="Service call rate in Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
default=0.5, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
default=5, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
OutputTargetCreate = Annotated[
Union[
Annotated[LedOutputTargetCreate, Tag("led")],
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
],
Discriminator("target_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _OutputTargetUpdateBase(BaseModel):
"""Shared fields for all output target update requests."""
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class LedOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["led"] = "led"
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable, 1-90)")
keepalive_interval: Optional[float] = Field(
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
)
state_check_interval: Optional[int] = Field(
None, description="Health check interval (5-600s)", ge=5, le=600
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: Optional[bool] = Field(
None, description="Auto-reduce FPS when device is unresponsive"
)
protocol: Optional[str] = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
)
class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
OutputTargetUpdate = Annotated[
Union[
Annotated[LedOutputTargetUpdate, Tag("led")],
Annotated[HALightOutputTargetUpdate, Tag("ha_light")],
],
Discriminator("target_type"),
]
# =====================================================================
# List response & utility schemas
# =====================================================================
class OutputTargetListResponse(BaseModel):
"""List of output targets response."""
@@ -129,23 +276,33 @@ class TargetProcessingState(BaseModel):
color_strip_source_id: str = Field(default="", description="Color strip source ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
fps_potential: Optional[float] = Field(
None, description="Potential FPS (processing speed without throttle)"
)
fps_target: Optional[int] = Field(None, description="Target FPS")
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
frames_keepalive: Optional[int] = Field(
None, description="Keepalive frames sent during standby"
)
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
timing_extract_ms: Optional[float] = Field(None, description="Border pixel extraction time (ms)")
timing_extract_ms: Optional[float] = Field(
None, description="Border pixel extraction time (ms)"
)
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
timing_total_ms: Optional[float] = Field(None, description="Total processing time per frame (ms)")
timing_total_ms: Optional[float] = Field(
None, description="Total processing time per frame (ms)"
)
timing_audio_read_ms: Optional[float] = Field(None, description="Audio device read time (ms)")
timing_audio_fft_ms: Optional[float] = Field(None, description="Audio FFT analysis time (ms)")
timing_audio_render_ms: Optional[float] = Field(None, description="Audio visualization render time (ms)")
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
timing_audio_render_ms: Optional[float] = Field(
None, description="Audio visualization render time (ms)"
)
display_index: Optional[int] = Field(None, description="Current display index")
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
overlay_active: bool = Field(
default=False, description="Whether visualization overlay is active"
)
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
device_online: bool = Field(default=False, description="Whether device is reachable")
@@ -154,11 +311,17 @@ class TargetProcessingState(BaseModel):
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
device_led_type: Optional[str] = Field(
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
)
device_fps: Optional[int] = Field(
None, description="Device-reported FPS (WLED internal refresh rate)"
)
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
device_streaming_reachable: Optional[bool] = Field(None, description="Device reachable during streaming (HTTP probe)")
device_streaming_reachable: Optional[bool] = Field(
None, description="Device reachable during streaming (HTTP probe)"
)
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
@@ -186,26 +349,12 @@ class BulkTargetRequest(BaseModel):
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."""
name: str = Field(description="Rectangle name")
x: float = Field(description="Left edge (0.0-1.0)")
y: float = Field(description="Top edge (0.0-1.0)")
width: float = Field(description="Width (0.0-1.0)")
height: float = Field(description="Height (0.0-1.0)")
color: ExtractedColorResponse = Field(description="Extracted color for this rectangle")
class KCTestResponse(BaseModel):
"""Response from testing a KC target."""
image: str = Field(description="Base64 data URI of the captured frame")
rectangles: List[KCTestRectangleResponse] = Field(description="Rectangles with extracted colors")
interpolation_mode: str = Field(description="Color extraction mode used")
pattern_template_name: str = Field(description="Pattern template name")
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"
)
@@ -1,80 +1,183 @@
"""Picture source schemas."""
"""Picture source schemas — discriminated unions per stream type."""
from datetime import datetime
from typing import List, Literal, Optional
from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
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", "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", 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):
"""Request to update a picture source."""
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", 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):
"""Picture source information response."""
class _PictureSourceResponseBase(BaseModel):
"""Shared fields for all picture source responses."""
id: str = Field(description="Stream ID")
name: str = Field(description="Stream name")
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")
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID")
image_source: Optional[str] = Field(None, description="Image URL or file path")
description: Optional[str] = Field(None, description="Stream description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
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")
class RawPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["raw"] = "raw"
display_index: int = Field(description="Display index")
capture_template_id: str = Field(description="Capture template ID")
target_fps: int = Field(description="Target FPS")
class ProcessedPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: str = Field(description="Source stream ID")
postprocessing_template_id: str = Field(description="Postprocessing template ID")
class StaticImagePictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
class VideoPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, 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")
target_fps: int = Field(30, description="Target FPS")
PictureSourceResponse = Annotated[
Union[
Annotated[RawPictureSourceResponse, Tag("raw")],
Annotated[ProcessedPictureSourceResponse, Tag("processed")],
Annotated[StaticImagePictureSourceResponse, Tag("static_image")],
Annotated[VideoPictureSourceResponse, Tag("video")],
],
Discriminator("stream_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _PictureSourceCreateBase(BaseModel):
"""Shared fields for all picture source create requests."""
name: str = Field(description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class RawPictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["raw"] = "raw"
display_index: int = Field(description="Display index", ge=0)
capture_template_id: str = Field(description="Capture template ID")
target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
class ProcessedPictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: str = Field(description="Source stream ID")
postprocessing_template_id: str = Field(description="Postprocessing template ID")
class StaticImagePictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: str = Field(description="Image asset ID")
class VideoPictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["video"] = "video"
video_asset_id: str = Field(description="Video asset ID")
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")
target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
PictureSourceCreate = Annotated[
Union[
Annotated[RawPictureSourceCreate, Tag("raw")],
Annotated[ProcessedPictureSourceCreate, Tag("processed")],
Annotated[StaticImagePictureSourceCreate, Tag("static_image")],
Annotated[VideoPictureSourceCreate, Tag("video")],
],
Discriminator("stream_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _PictureSourceUpdateBase(BaseModel):
"""Shared fields for all picture source update requests."""
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
class RawPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["raw"] = "raw"
display_index: Optional[int] = Field(None, description="Display index", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
class ProcessedPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(
None, description="Postprocessing template ID"
)
class StaticImagePictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
class VideoPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
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")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
PictureSourceUpdate = Annotated[
Union[
Annotated[RawPictureSourceUpdate, Tag("raw")],
Annotated[ProcessedPictureSourceUpdate, Tag("processed")],
Annotated[StaticImagePictureSourceUpdate, Tag("static_image")],
Annotated[VideoPictureSourceUpdate, Tag("video")],
],
Discriminator("stream_type"),
]
# =====================================================================
# List response
# =====================================================================
class PictureSourceListResponse(BaseModel):
@@ -84,11 +187,23 @@ class PictureSourceListResponse(BaseModel):
count: int = Field(description="Number of streams")
# =====================================================================
# Test / Validation (unchanged)
# =====================================================================
class PictureSourceTestRequest(BaseModel):
"""Request to test a picture source."""
capture_duration: float = Field(default=5.0, ge=0.0, le=30.0, description="Duration to capture in seconds (0 = single frame)")
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview")
capture_duration: float = Field(
default=5.0,
ge=0.0,
le=30.0,
description="Duration to capture in seconds (0 = single frame)",
)
border_width: int = Field(
default=10, ge=1, le=100, description="Border width in pixels for preview"
)
class ImageValidateRequest(BaseModel):
@@ -13,7 +13,11 @@ class HealthResponse(BaseModel):
timestamp: datetime = Field(description="Current server time")
version: str = Field(description="Application version")
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
auth_required: bool = Field(default=True, description="Whether API key authentication is required")
auth_required: bool = Field(
default=True, description="Whether API key authentication is required"
)
repo_url: str = Field(default="", description="Source code repository URL")
donate_url: str = Field(default="", description="Donation page URL")
class VersionResponse(BaseModel):
@@ -60,6 +64,9 @@ class GpuInfo(BaseModel):
memory_used_mb: float | None = Field(default=None, description="GPU memory used in MB")
memory_total_mb: float | None = Field(default=None, description="GPU total memory in MB")
temperature_c: float | None = Field(default=None, description="GPU temperature in Celsius")
app_memory_mb: float | None = Field(
default=None, description="GPU memory used by this app in MB"
)
class PerformanceResponse(BaseModel):
@@ -70,6 +77,8 @@ class PerformanceResponse(BaseModel):
ram_used_mb: float = Field(description="RAM used in MB")
ram_total_mb: float = Field(description="RAM total in MB")
ram_percent: float = Field(description="RAM usage percent")
app_cpu_percent: float = Field(description="App process CPU usage percent")
app_ram_mb: float = Field(description="App process resident memory in MB")
gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
timestamp: datetime = Field(description="Measurement timestamp")
@@ -84,6 +93,7 @@ class RestoreResponse(BaseModel):
# ─── Auto-backup schemas ──────────────────────────────────────
class AutoBackupSettings(BaseModel):
"""Settings for automatic backup."""
@@ -119,6 +129,7 @@ class BackupListResponse(BaseModel):
# ─── MQTT schemas ──────────────────────────────────────────────
class MQTTSettingsResponse(BaseModel):
"""MQTT broker settings response (password is masked)."""
@@ -138,17 +149,22 @@ class MQTTSettingsRequest(BaseModel):
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)")
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.")
external_url: str = Field(
description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL."
)
class ExternalUrlRequest(BaseModel):
@@ -159,10 +175,13 @@ class ExternalUrlRequest(BaseModel):
# ─── 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)")
level: str = Field(
description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)"
)
class LogLevelRequest(BaseModel):
@@ -1,95 +1,442 @@
"""Value source schemas (CRUD)."""
"""Value source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import List, Literal, Optional
from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class ValueSourceCreate(BaseModel):
"""Request to create a value source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"] = Field(description="Source type")
# static fields
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
# adaptive fields
schedule: Optional[list] = Field(None, description="Time-of-day schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
# daylight fields
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class ValueSourceUpdate(BaseModel):
"""Request to update a value source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
# static fields
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
# adaptive fields
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
# daylight fields
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class ValueSourceResponse(BaseModel):
"""Value source response."""
class _ValueSourceResponseBase(BaseModel):
"""Shared fields for all value source responses."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type: static, animated, audio, adaptive_time, or adaptive_scene")
value: Optional[float] = Field(None, description="Static value")
waveform: Optional[str] = Field(None, description="Waveform type")
speed: Optional[float] = Field(None, description="Cycles per minute")
min_value: Optional[float] = Field(None, description="Minimum output")
max_value: Optional[float] = Field(None, description="Maximum output")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode")
sensitivity: Optional[float] = Field(None, description="Gain multiplier")
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class StaticValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["static"] = "static"
return_type: Literal["float"] = "float"
value: float = Field(description="Constant value (0.0-1.0)")
class AnimatedValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["animated"] = "animated"
return_type: Literal["float"] = "float"
waveform: str = Field(description="Waveform type")
speed: float = Field(description="Cycles per minute")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class AudioValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["audio"] = "audio"
return_type: Literal["float"] = "float"
audio_source_id: str = Field(description="Mono audio source ID")
mode: str = Field(description="Audio mode: rms|peak|beat")
sensitivity: float = Field(description="Gain multiplier")
smoothing: float = Field(description="Temporal smoothing")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
auto_gain: bool = Field(description="Auto-normalize audio levels")
class AdaptiveTimeValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
return_type: Literal["float"] = "float"
schedule: list = Field(description="Time-of-day schedule")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class AdaptiveSceneValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
return_type: Literal["float"] = "float"
picture_source_id: str = Field(description="Picture source ID")
scene_behavior: str = Field(description="Scene behavior: complement|match")
sensitivity: float = Field(description="Gain multiplier")
smoothing: float = Field(description="Temporal smoothing")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class DaylightValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["daylight"] = "daylight"
return_type: Literal["float"] = "float"
speed: float = Field(description="Simulation speed multiplier")
use_real_time: bool = Field(description="Use wall-clock time")
latitude: float = Field(description="Geographic latitude")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class StaticColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["static_color"] = "static_color"
return_type: Literal["color"] = "color"
color: List[int] = Field(description="Static RGB color [R,G,B]")
class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["animated_color"] = "animated_color"
return_type: Literal["color"] = "color"
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
speed: float = Field(description="Cycles per minute")
easing: str = Field(description="Color easing: linear|step")
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
return_type: Literal["color"] = "color"
schedule: list = Field(description="Color schedule")
class HAEntityValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["ha_entity"] = "ha_entity"
return_type: Literal["float"] = "float"
ha_source_id: str = Field(description="Home Assistant source ID")
entity_id: str = Field(description="HA entity ID (e.g. sensor.temperature)")
attribute: str = Field("", description="Optional attribute name (empty = use state)")
min_ha_value: float = Field(description="Raw HA value mapped to output 0.0")
max_ha_value: float = Field(description="Raw HA value mapped to output 1.0")
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
class GradientMapValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["gradient_map"] = "gradient_map"
return_type: Literal["color"] = "color"
value_source_id: str = Field(description="Input float value source ID")
gradient_id: str = Field(description="Gradient entity ID")
easing: str = Field(description="Interpolation mode: linear|step")
class CSSExtractValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["css_extract"] = "css_extract"
return_type: Literal["color"] = "color"
color_strip_source_id: str = Field(description="Color strip source ID")
led_start: int = Field(description="Start of LED range (0-based)")
led_end: int = Field(description="End of LED range (-1 = whole strip)")
class SystemMetricsValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["system_metrics"] = "system_metrics"
return_type: Literal["float"] = "float"
metric: str = Field(description="System metric to monitor")
min_value: float = Field(description="Min value for range-based metrics")
max_value: float = Field(description="Max value for range-based metrics")
max_rate: float = Field(description="Max rate in bytes/sec for network metrics")
disk_path: str = Field(description="Disk path for disk_usage metric")
sensor_label: str = Field(description="Sensor label for cpu_temp/fan_speed")
poll_interval: float = Field(description="Seconds between reads")
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
ValueSourceResponse = Annotated[
Union[
Annotated[StaticValueSourceResponse, Tag("static")],
Annotated[AnimatedValueSourceResponse, Tag("animated")],
Annotated[AudioValueSourceResponse, Tag("audio")],
Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")],
Annotated[DaylightValueSourceResponse, Tag("daylight")],
Annotated[StaticColorValueSourceResponse, Tag("static_color")],
Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")],
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")],
],
Discriminator("source_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _ValueSourceCreateBase(BaseModel):
"""Shared fields for all value source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class StaticValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["static"] = "static"
value: float = Field(1.0, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
class AnimatedValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["animated"] = "animated"
waveform: str = Field("sine", description="Waveform: sine|triangle|square|sawtooth")
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class AudioValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["audio"] = "audio"
audio_source_id: str = Field("", description="Mono audio source ID")
mode: str = Field("rms", description="Audio mode: rms|peak|beat")
sensitivity: float = Field(1.0, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: float = Field(0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: bool = Field(False, description="Auto-normalize audio levels to full range")
class AdaptiveTimeValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
schedule: list = Field(description="Schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class AdaptiveSceneValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
picture_source_id: str = Field("", description="Picture source ID for scene mode")
scene_behavior: str = Field("complement", description="Scene behavior: complement|match")
sensitivity: float = Field(1.0, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: float = Field(0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class DaylightValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["daylight"] = "daylight"
speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0)
use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation")
latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class StaticColorValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["static_color"] = "static_color"
color: List[int] = Field(
default_factory=lambda: [255, 255, 255],
description="Static RGB color [R,G,B]",
)
class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["animated_color"] = "animated_color"
colors: List[List[int]] = Field(
default_factory=lambda: [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
description="Color list [[R,G,B], ...]",
)
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
easing: str = Field("linear", description="Color easing: linear|step")
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
schedule: list = Field(description="Schedule: [{time: 'HH:MM', color: [R,G,B]}]")
class HAEntityValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["ha_entity"] = "ha_entity"
ha_source_id: str = Field(description="Home Assistant source ID")
entity_id: str = Field(description="HA entity ID")
attribute: str = Field("", description="Optional attribute name")
min_ha_value: float = Field(0.0, description="Raw HA value mapped to output 0.0")
max_ha_value: float = Field(100.0, description="Raw HA value mapped to output 1.0")
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
class GradientMapValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["gradient_map"] = "gradient_map"
value_source_id: str = Field(description="Input float value source ID")
gradient_id: str = Field("", description="Gradient entity ID")
easing: str = Field("linear", description="Interpolation: linear|step")
class CSSExtractValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["css_extract"] = "css_extract"
color_strip_source_id: str = Field(description="Color strip source ID")
led_start: int = Field(0, description="Start of LED range (0-based)", ge=0)
led_end: int = Field(-1, description="End of LED range (-1 = whole strip)")
class SystemMetricsValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["system_metrics"] = "system_metrics"
metric: str = Field("cpu_load", description="System metric to monitor")
min_value: float = Field(0.0, description="Min value for normalization")
max_value: float = Field(100.0, description="Max value for normalization")
max_rate: float = Field(125_000_000.0, description="Max rate bytes/sec for network")
disk_path: str = Field("", description="Disk path for disk_usage")
sensor_label: str = Field("", description="Sensor label for cpu_temp/fan_speed")
poll_interval: float = Field(1.0, description="Poll interval in seconds", ge=0.1, le=60.0)
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
ValueSourceCreate = Annotated[
Union[
Annotated[StaticValueSourceCreate, Tag("static")],
Annotated[AnimatedValueSourceCreate, Tag("animated")],
Annotated[AudioValueSourceCreate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceCreate, Tag("daylight")],
Annotated[StaticColorValueSourceCreate, Tag("static_color")],
Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")],
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")],
],
Discriminator("source_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _ValueSourceUpdateBase(BaseModel):
"""Shared fields for all value source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class StaticValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static"] = "static"
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
class AnimatedValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated"] = "animated"
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AudioValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["audio"] = "audio"
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
class AdaptiveTimeValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AdaptiveSceneValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["daylight"] = "daylight"
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class StaticColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static_color"] = "static_color"
color: Optional[List[int]] = Field(None, description="Static RGB color [R,G,B]")
class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated_color"] = "animated_color"
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: Optional[str] = Field(None, description="Color easing: linear|step")
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
schedule: Optional[list] = Field(None, description="Color schedule")
class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["ha_entity"] = "ha_entity"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
entity_id: Optional[str] = Field(None, description="HA entity ID")
attribute: Optional[str] = Field(None, description="Attribute name")
min_ha_value: Optional[float] = Field(None, description="Min HA value")
max_ha_value: Optional[float] = Field(None, description="Max HA value")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["gradient_map"] = "gradient_map"
value_source_id: Optional[str] = Field(None, description="Input value source ID")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
easing: Optional[str] = Field(None, description="Interpolation mode")
class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["css_extract"] = "css_extract"
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
led_start: Optional[int] = Field(None, description="LED range start", ge=0)
led_end: Optional[int] = Field(None, description="LED range end")
class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["system_metrics"] = "system_metrics"
metric: Optional[str] = Field(None, description="System metric")
min_value: Optional[float] = Field(None, description="Min value")
max_value: Optional[float] = Field(None, description="Max value")
max_rate: Optional[float] = Field(None, description="Max rate bytes/sec")
disk_path: Optional[str] = Field(None, description="Disk path")
sensor_label: Optional[str] = Field(None, description="Sensor label")
poll_interval: Optional[float] = Field(None, description="Poll interval", ge=0.1, le=60.0)
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
ValueSourceUpdate = Annotated[
Union[
Annotated[StaticValueSourceUpdate, Tag("static")],
Annotated[AnimatedValueSourceUpdate, Tag("animated")],
Annotated[AudioValueSourceUpdate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceUpdate, Tag("daylight")],
Annotated[StaticColorValueSourceUpdate, Tag("static_color")],
Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")],
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")],
],
Discriminator("source_type"),
]
# =====================================================================
# List response
# =====================================================================
class ValueSourceListResponse(BaseModel):
"""List of value sources."""
+15 -3
View File
@@ -15,7 +15,7 @@ class ServerConfig(BaseSettings):
host: str = "0.0.0.0"
port: int = 8080
log_level: str = "INFO"
cors_origins: List[str] = ["*"]
cors_origins: List[str] = ["http://localhost:8080"]
class AuthConfig(BaseSettings):
@@ -24,6 +24,13 @@ class AuthConfig(BaseSettings):
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
class AssetsConfig(BaseSettings):
"""Assets configuration."""
max_file_size_mb: int = 50 # Max upload size in MB
assets_dir: str = "data/assets" # Directory for uploaded asset files
class StorageConfig(BaseSettings):
"""Storage configuration."""
@@ -65,16 +72,21 @@ class Config(BaseSettings):
server: ServerConfig = Field(default_factory=ServerConfig)
auth: AuthConfig = Field(default_factory=AuthConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
assets: AssetsConfig = Field(default_factory=AssetsConfig)
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
def model_post_init(self, __context: object) -> None:
"""Override storage paths when demo mode is active."""
"""Override storage and assets paths when demo mode is active."""
if self.demo:
for field_name in self.storage.model_fields:
for field_name in StorageConfig.model_fields:
value = getattr(self.storage, field_name)
if isinstance(value, str) and value.startswith("data/"):
setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1))
for field_name in AssetsConfig.model_fields:
value = getattr(self.assets, field_name)
if isinstance(value, str) and value.startswith("data/"):
setattr(self.assets, field_name, value.replace("data/", "data/demo/", 1))
@classmethod
def from_yaml(cls, config_path: str | Path) -> "Config":
@@ -141,7 +141,8 @@ class ManagedAudioStream:
if stream is not None:
try:
stream.cleanup()
except Exception:
except Exception as e:
logger.debug("Audio stream cleanup error: %s", e)
pass
self._running = False
logger.info(
@@ -0,0 +1,31 @@
"""Audio filter system.
Provides a pluggable filter architecture for audio analysis postprocessing.
Import this package to ensure all built-in filters are registered.
"""
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.pipeline import AudioFilterPipeline
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
# Import individual filters to trigger auto-registration
import wled_controller.core.audio.filters.audio_filter_template # noqa: F401
import wled_controller.core.audio.filters.channel_extract # noqa: F401
import wled_controller.core.audio.filters.band_extract # noqa: F401
import wled_controller.core.audio.filters.peak_hold # noqa: F401
import wled_controller.core.audio.filters.gain # noqa: F401
import wled_controller.core.audio.filters.noise_gate # noqa: F401
import wled_controller.core.audio.filters.envelope_follower # noqa: F401
import wled_controller.core.audio.filters.spectral_smoothing # noqa: F401
import wled_controller.core.audio.filters.compressor # noqa: F401
import wled_controller.core.audio.filters.inverter # noqa: F401
import wled_controller.core.audio.filters.beat_gate # noqa: F401
import wled_controller.core.audio.filters.delay # noqa: F401
import wled_controller.core.audio.filters.auto_gain # noqa: F401
__all__ = [
"AudioFilter",
"AudioFilterOptionDef",
"AudioFilterPipeline",
"AudioFilterRegistry",
]
@@ -0,0 +1,39 @@
"""Audio Filter Template meta-filter -- references another audio processing template.
This filter exists in the registry for UI discovery only. It is never
instantiated at runtime: the audio processing pipeline expands it into the
referenced template's filters when building the filter chain.
"""
from typing import List
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
class AudioFilterTemplateFilter(AudioFilter):
"""Include another audio filter template's chain at this position."""
filter_id = "audio_filter_template"
filter_name = "Audio Filter Template"
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="template_id",
label="Template",
option_type="select",
default="",
min_value=None,
max_value=None,
step=None,
choices=[], # populated dynamically by GET /api/v1/audio-filters
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
# Never called -- expanded at pipeline build time.
return analysis
@@ -0,0 +1,84 @@
"""Auto-gain audio filter — automatic level normalization.
Tracks a rolling peak of the audio signal and scales all levels so the
output uses the full 0-1 range regardless of input volume. Zero-config
by default; optional parameters for tuning response speed and target level.
"""
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
class AutoGainFilter(AudioFilter):
"""Normalize audio levels against a rolling observed peak.
Tracks the maximum signal level over a configurable time window and
scales all values so the loudest recent signal maps to ``target_level``.
The rolling peak decays slowly so the gain adapts to changing volumes.
"""
filter_id = "auto_gain"
filter_name = "Auto Gain"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._target = self.options["target_level"]
# Decay factor per frame (~30 fps): controls how fast the peak forgets.
# response_time seconds → peak halves in that time.
response_time = self.options["response_time"]
# decay = 0.5^(1 / (fps * response_time)) ≈ e^(-ln2 / (fps * t))
self._decay = 0.5 ** (1.0 / max(1.0, 30.0 * response_time))
self._rolling_peak = 0.0
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="target_level",
label="Target Level",
option_type="float",
default=0.8,
min_value=0.1,
max_value=1.0,
step=0.05,
),
AudioFilterOptionDef(
key="response_time",
label="Response Time (s)",
option_type="float",
default=3.0,
min_value=0.5,
max_value=15.0,
step=0.5,
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
# Track the rolling peak from the loudest signal component
current_peak = max(analysis.rms, analysis.peak)
self._rolling_peak = max(current_peak, self._rolling_peak * self._decay)
if self._rolling_peak < 0.001:
return analysis # signal too quiet, don't amplify noise
factor = self._target / self._rolling_peak
if 0.95 <= factor <= 1.05:
return analysis # close enough, avoid jitter
return replace(
analysis,
rms=min(1.0, analysis.rms * factor),
peak=min(1.0, analysis.peak * factor),
spectrum=np.clip(analysis.spectrum * factor, 0.0, 1.0).astype(np.float32),
left_rms=min(1.0, analysis.left_rms * factor),
left_spectrum=np.clip(analysis.left_spectrum * factor, 0.0, 1.0).astype(np.float32),
right_rms=min(1.0, analysis.right_rms * factor),
right_spectrum=np.clip(analysis.right_spectrum * factor, 0.0, 1.0).astype(np.float32),
)
@@ -0,0 +1,103 @@
"""Band Extract audio filter — mask spectrum to a frequency range and recompute RMS."""
from dataclasses import replace
from typing import Any, Dict, List
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from wled_controller.core.audio.band_filter import apply_band_filter, compute_band_mask
# Preset frequency ranges
_PRESETS = {
"bass": (20.0, 250.0),
"mid": (250.0, 4000.0),
"treble": (4000.0, 20000.0),
}
@AudioFilterRegistry.register
class BandExtractFilter(AudioFilter):
"""Extract a frequency band from the spectrum.
Supports presets (bass, mid, treble) or a custom frequency range.
Zeros out-of-band spectrum bins and recomputes RMS from in-band data.
"""
filter_id = "band_extract"
filter_name = "Band Extract"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
band = self.options["band"]
if band == "custom":
freq_low = self.options["freq_low"]
freq_high = self.options["freq_high"]
else:
freq_low, freq_high = _PRESETS.get(band, (20.0, 20000.0))
self._mask = compute_band_mask(freq_low, freq_high)
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="band",
label="Band",
option_type="select",
default="bass",
min_value=None,
max_value=None,
step=None,
choices=[
{"value": "bass", "label": "Bass (20-250 Hz)"},
{"value": "mid", "label": "Mid (250-4000 Hz)"},
{"value": "treble", "label": "Treble (4000-20000 Hz)"},
{"value": "custom", "label": "Custom Range"},
],
),
AudioFilterOptionDef(
key="freq_low",
label="Low Frequency (Hz)",
option_type="float",
default=20.0,
min_value=20.0,
max_value=20000.0,
step=1.0,
),
AudioFilterOptionDef(
key="freq_high",
label="High Frequency (Hz)",
option_type="float",
default=20000.0,
min_value=20.0,
max_value=20000.0,
step=1.0,
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
filtered_spectrum, filtered_rms = apply_band_filter(
analysis.spectrum,
analysis.rms,
self._mask,
)
filtered_left, filtered_left_rms = apply_band_filter(
analysis.left_spectrum,
analysis.left_rms,
self._mask,
)
filtered_right, filtered_right_rms = apply_band_filter(
analysis.right_spectrum,
analysis.right_rms,
self._mask,
)
return replace(
analysis,
rms=filtered_rms,
spectrum=filtered_spectrum,
left_rms=filtered_left_rms,
left_spectrum=filtered_left,
right_rms=filtered_right_rms,
right_spectrum=filtered_right,
)
@@ -0,0 +1,118 @@
"""Base classes for the audio filter system."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from wled_controller.core.audio.analysis import AudioAnalysis
@dataclass
class AudioFilterOptionDef:
"""Describes a single configurable option for an audio filter."""
key: str
label: str
option_type: str # "float" | "int" | "bool" | "select" | "string"
default: Any
min_value: Any
max_value: Any
step: Any
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
max_length: Optional[int] = None # for "string" type
def to_dict(self) -> dict:
d = {
"key": self.key,
"label": self.label,
"type": self.option_type,
"default": self.default,
"min_value": self.min_value,
"max_value": self.max_value,
"step": self.step,
}
if self.choices is not None:
d["choices"] = self.choices
if self.max_length is not None:
d["max_length"] = self.max_length
return d
class AudioFilter(ABC):
"""Base class for all audio filters.
Each filter operates on an AudioAnalysis snapshot and returns
a new (possibly transformed) AudioAnalysis.
Stateful filters (e.g. peak hold, envelope follower) must override
``is_stateful`` to return True and implement ``reset()``.
"""
filter_id: str = ""
filter_name: str = ""
def __init__(self, options: Dict[str, Any]):
"""Initialize filter with validated options."""
self.options = self.validate_options(options)
@property
def is_stateful(self) -> bool:
"""Whether this filter maintains internal state across calls.
Stateful filters need per-stream instances and reset() support.
"""
return False
def reset(self) -> None:
"""Reset internal state. Override in stateful filters."""
@classmethod
@abstractmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
"""Return the list of configurable options for this filter type."""
...
@abstractmethod
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
"""Process an audio analysis snapshot.
Args:
analysis: Input AudioAnalysis snapshot.
Returns:
New AudioAnalysis with transformations applied.
"""
...
@classmethod
def validate_options(cls, options: dict) -> dict:
"""Validate and clamp options against the schema. Returns cleaned dict."""
schema = cls.get_options_schema()
cleaned = {}
for opt_def in schema:
raw = options.get(opt_def.key, opt_def.default)
if opt_def.option_type == "float":
val = float(raw)
elif opt_def.option_type == "int":
val = int(raw)
elif opt_def.option_type == "bool":
val = bool(raw) if not isinstance(raw, bool) else raw
elif opt_def.option_type == "select":
val = str(raw) if raw is not None else opt_def.default
elif opt_def.option_type == "string":
val = str(raw) if raw is not None else opt_def.default
else:
val = raw
# Clamp to range (skip for non-numeric types)
if opt_def.option_type not in ("bool", "select", "string"):
if opt_def.min_value is not None and val < opt_def.min_value:
val = opt_def.min_value
if opt_def.max_value is not None and val > opt_def.max_value:
val = opt_def.max_value
cleaned[opt_def.key] = val
return cleaned
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.options})"
@@ -0,0 +1,78 @@
"""Beat Gate audio filter — pass signal only around beat events."""
import time
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
_ZERO_SPECTRUM = np.zeros(NUM_BANDS, dtype=np.float32)
@AudioFilterRegistry.register
class BeatGateFilter(AudioFilter):
"""Pass audio signal through only when a beat is detected.
When a beat is detected, the gate opens and holds for ``hold_ms``
milliseconds, passing the signal through. Between beats (after hold
expires), rms/peak/spectrum are zeroed out. Beat fields themselves
always pass through unchanged.
"""
filter_id = "beat_gate"
filter_name = "Beat Gate"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._hold_ms = self.options["hold_ms"]
self._last_beat_time: float | None = None
@property
def is_stateful(self) -> bool:
return True
def reset(self) -> None:
self._last_beat_time = None
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="hold_ms",
label="Hold Time (ms)",
option_type="float",
default=50.0,
min_value=10.0,
max_value=500.0,
step=1.0,
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
now = time.perf_counter()
# Record beat time
if analysis.beat:
self._last_beat_time = now
# Check if we're within the hold window
if self._last_beat_time is not None:
elapsed_ms = (now - self._last_beat_time) * 1000.0
if elapsed_ms <= self._hold_ms:
return analysis
# Gate closed — zero out levels, preserve beat fields
return replace(
analysis,
rms=0.0,
peak=0.0,
spectrum=np.copy(_ZERO_SPECTRUM),
left_rms=0.0,
left_spectrum=np.copy(_ZERO_SPECTRUM),
right_rms=0.0,
right_spectrum=np.copy(_ZERO_SPECTRUM),
)
@@ -0,0 +1,70 @@
"""Channel Extract audio filter — select mono/left/right from stereo AudioAnalysis."""
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
class ChannelExtractFilter(AudioFilter):
"""Select a single channel (mono mix, left, or right) from stereo AudioAnalysis.
When 'mono' is selected, left and right are averaged into the main fields.
When 'left' or 'right' is selected, that channel's data replaces the main fields.
"""
filter_id = "channel_extract"
filter_name = "Channel Extract"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._channel = self.options["channel"]
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="channel",
label="Channel",
option_type="select",
default="mono",
min_value=None,
max_value=None,
step=None,
choices=[
{"value": "mono", "label": "Mono (L+R average)"},
{"value": "left", "label": "Left"},
{"value": "right", "label": "Right"},
],
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
channel = self._channel
if channel == "left":
return replace(
analysis,
rms=analysis.left_rms,
spectrum=np.copy(analysis.left_spectrum),
)
elif channel == "right":
return replace(
analysis,
rms=analysis.right_rms,
spectrum=np.copy(analysis.right_spectrum),
)
else:
# mono: average left and right
avg_rms = (analysis.left_rms + analysis.right_rms) / 2.0
avg_spectrum = (analysis.left_spectrum + analysis.right_spectrum) / 2.0
return replace(
analysis,
rms=avg_rms,
spectrum=avg_spectrum.astype(np.float32),
)
@@ -0,0 +1,103 @@
"""Compressor audio filter — reduce dynamic range above threshold."""
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
class CompressorFilter(AudioFilter):
"""Reduce dynamic range above a threshold.
For signals above ``threshold``, output is compressed:
``output = threshold + (input - threshold) / ratio``
Makeup gain is applied after compression to restore overall level.
Applied to rms, peak, and per-bin spectrum values.
"""
filter_id = "compressor"
filter_name = "Compressor"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._threshold = self.options["threshold"]
self._ratio = self.options["ratio"]
self._makeup_gain = self.options["makeup_gain"]
@property
def is_stateful(self) -> bool:
return True
def reset(self) -> None:
pass # Stateful for envelope tracking; minimal state for static compression
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="threshold",
label="Threshold",
option_type="float",
default=0.5,
min_value=0.0,
max_value=1.0,
step=0.01,
),
AudioFilterOptionDef(
key="ratio",
label="Ratio",
option_type="float",
default=4.0,
min_value=1.0,
max_value=20.0,
step=0.1,
),
AudioFilterOptionDef(
key="makeup_gain",
label="Makeup Gain",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.05,
),
]
def _compress_scalar(self, value: float) -> float:
"""Compress a single scalar value."""
threshold = self._threshold
if value <= threshold:
compressed = value
else:
compressed = threshold + (value - threshold) / self._ratio
return min(1.0, compressed * self._makeup_gain)
def _compress_spectrum(self, spectrum: np.ndarray) -> np.ndarray:
"""Compress spectrum array element-wise."""
threshold = self._threshold
ratio = self._ratio
makeup = self._makeup_gain
above_mask = spectrum > threshold
result = np.copy(spectrum)
result[above_mask] = threshold + (result[above_mask] - threshold) / ratio
result *= makeup
return np.clip(result, 0.0, 1.0).astype(np.float32)
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
return replace(
analysis,
rms=self._compress_scalar(analysis.rms),
peak=self._compress_scalar(analysis.peak),
spectrum=self._compress_spectrum(analysis.spectrum),
left_rms=self._compress_scalar(analysis.left_rms),
left_spectrum=self._compress_spectrum(analysis.left_spectrum),
right_rms=self._compress_scalar(analysis.right_rms),
right_spectrum=self._compress_spectrum(analysis.right_spectrum),
)
@@ -0,0 +1,83 @@
"""Delay audio filter — time-shift AudioAnalysis by a configurable amount."""
from collections import deque
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
# Assumed update rate for sizing the ring buffer
_UPDATE_RATE_HZ = 30
@AudioFilterRegistry.register
class DelayFilter(AudioFilter):
"""Buffer incoming AudioAnalysis snapshots and output the one from N ms ago.
Uses a ring buffer (deque) sized for the configured delay at ~30 Hz
update rate. Until the buffer is full, outputs a silent AudioAnalysis.
"""
filter_id = "delay"
filter_name = "Delay"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._delay_ms = self.options["delay_ms"]
self._buffer_size = max(1, int(self._delay_ms / 1000.0 * _UPDATE_RATE_HZ))
self._buffer: deque[AudioAnalysis] = deque(maxlen=self._buffer_size)
@property
def is_stateful(self) -> bool:
return True
def reset(self) -> None:
self._buffer.clear()
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="delay_ms",
label="Delay (ms)",
option_type="float",
default=100.0,
min_value=10.0,
max_value=2000.0,
step=10.0,
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
# Take a snapshot with copied arrays to avoid reference issues
snapshot = replace(
analysis,
spectrum=np.copy(analysis.spectrum),
left_spectrum=np.copy(analysis.left_spectrum),
right_spectrum=np.copy(analysis.right_spectrum),
)
if len(self._buffer) >= self._buffer_size:
# Buffer full — return the oldest entry (the delayed one)
delayed = self._buffer[0]
self._buffer.append(snapshot)
return delayed
else:
# Buffer not yet full — store and output silence
self._buffer.append(snapshot)
return replace(
analysis,
rms=0.0,
peak=0.0,
spectrum=np.zeros(NUM_BANDS, dtype=np.float32),
beat=False,
beat_intensity=0.0,
left_rms=0.0,
left_spectrum=np.zeros(NUM_BANDS, dtype=np.float32),
right_rms=0.0,
right_spectrum=np.zeros(NUM_BANDS, dtype=np.float32),
)
@@ -0,0 +1,116 @@
"""Envelope Follower audio filter — smooth amplitude with asymmetric attack/release."""
import time
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
def _time_constant_coeff(time_ms: float, dt: float) -> float:
"""Compute exponential smoothing coefficient from time constant and delta-time.
Returns a value in [0, 1] where 0 = no change, 1 = instant follow.
"""
if time_ms <= 0.0 or dt <= 0.0:
return 1.0
# Time constant: the coefficient such that we reach ~63.2% in time_ms
tau = time_ms / 1000.0
return min(1.0, 1.0 - np.exp(-dt / tau))
@AudioFilterRegistry.register
class EnvelopeFollowerFilter(AudioFilter):
"""Smooth RMS and peak with asymmetric attack/release time constants.
Fast attack + slow release produces punchy transients that fade smoothly.
Applied to rms, peak, and per-bin spectrum values.
"""
filter_id = "envelope_follower"
filter_name = "Envelope Follower"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._attack_ms = self.options["attack_ms"]
self._release_ms = self.options["release_ms"]
self._env_rms = 0.0
self._env_peak = 0.0
self._env_spectrum = np.zeros(NUM_BANDS, dtype=np.float32)
self._env_left_rms = 0.0
self._env_right_rms = 0.0
self._last_time: float | None = None
@property
def is_stateful(self) -> bool:
return True
def reset(self) -> None:
self._env_rms = 0.0
self._env_peak = 0.0
self._env_spectrum[:] = 0.0
self._env_left_rms = 0.0
self._env_right_rms = 0.0
self._last_time = None
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="attack_ms",
label="Attack (ms)",
option_type="float",
default=10.0,
min_value=1.0,
max_value=500.0,
step=1.0,
),
AudioFilterOptionDef(
key="release_ms",
label="Release (ms)",
option_type="float",
default=200.0,
min_value=10.0,
max_value=2000.0,
step=1.0,
),
]
def _smooth_scalar(self, current: float, env: float, dt: float) -> float:
"""Apply asymmetric smoothing to a single scalar value."""
if current > env:
coeff = _time_constant_coeff(self._attack_ms, dt)
else:
coeff = _time_constant_coeff(self._release_ms, dt)
return env + coeff * (current - env)
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
now = time.perf_counter()
dt = (now - self._last_time) if self._last_time is not None else 0.0
self._last_time = now
# Smooth scalars
self._env_rms = self._smooth_scalar(analysis.rms, self._env_rms, dt)
self._env_peak = self._smooth_scalar(analysis.peak, self._env_peak, dt)
self._env_left_rms = self._smooth_scalar(analysis.left_rms, self._env_left_rms, dt)
self._env_right_rms = self._smooth_scalar(analysis.right_rms, self._env_right_rms, dt)
# Smooth spectrum per-bin
attack_coeff = _time_constant_coeff(self._attack_ms, dt)
release_coeff = _time_constant_coeff(self._release_ms, dt)
rising = analysis.spectrum > self._env_spectrum
coeff = np.where(rising, attack_coeff, release_coeff).astype(np.float32)
self._env_spectrum = self._env_spectrum + coeff * (analysis.spectrum - self._env_spectrum)
return replace(
analysis,
rms=self._env_rms,
peak=self._env_peak,
spectrum=np.copy(self._env_spectrum),
left_rms=self._env_left_rms,
right_rms=self._env_right_rms,
)
@@ -0,0 +1,56 @@
"""Gain audio filter — multiply all levels by a configurable factor."""
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
class GainFilter(AudioFilter):
"""Multiply rms, peak, spectrum, and per-channel values by a factor.
Values are clamped to [0, 1] for rms/peak scalars.
Spectrum bins are clamped to [0, 1] as well.
"""
filter_id = "gain"
filter_name = "Gain"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._factor = self.options["factor"]
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="factor",
label="Gain Factor",
option_type="float",
default=1.0,
min_value=0.1,
max_value=10.0,
step=0.1,
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
factor = self._factor
if factor == 1.0:
return analysis
return replace(
analysis,
rms=min(1.0, analysis.rms * factor),
peak=min(1.0, analysis.peak * factor),
spectrum=np.clip(analysis.spectrum * factor, 0.0, 1.0).astype(np.float32),
left_rms=min(1.0, analysis.left_rms * factor),
left_spectrum=np.clip(analysis.left_spectrum * factor, 0.0, 1.0).astype(np.float32),
right_rms=min(1.0, analysis.right_rms * factor),
right_spectrum=np.clip(analysis.right_spectrum * factor, 0.0, 1.0).astype(np.float32),
)
@@ -0,0 +1,55 @@
"""Inverter audio filter — invert all audio levels (1.0 - value)."""
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
class InverterFilter(AudioFilter):
"""Invert all audio levels: ``output = 1.0 - input``.
When ``invert_spectrum`` is True (default), spectrum bins are also inverted.
Beat fields (beat, beat_intensity) are always passed through unchanged.
"""
filter_id = "inverter"
filter_name = "Inverter"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._invert_spectrum = self.options["invert_spectrum"]
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="invert_spectrum",
label="Invert Spectrum",
option_type="bool",
default=True,
min_value=None,
max_value=None,
step=None,
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
kwargs = {
"rms": 1.0 - analysis.rms,
"peak": 1.0 - analysis.peak,
"left_rms": 1.0 - analysis.left_rms,
"right_rms": 1.0 - analysis.right_rms,
}
if self._invert_spectrum:
kwargs["spectrum"] = (1.0 - analysis.spectrum).astype(np.float32)
kwargs["left_spectrum"] = (1.0 - analysis.left_spectrum).astype(np.float32)
kwargs["right_spectrum"] = (1.0 - analysis.right_spectrum).astype(np.float32)
return replace(analysis, **kwargs)
@@ -0,0 +1,87 @@
"""Noise Gate audio filter — zero signal below threshold with hysteresis."""
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
_ZERO_SPECTRUM = np.zeros(NUM_BANDS, dtype=np.float32)
@AudioFilterRegistry.register
class NoiseGateFilter(AudioFilter):
"""Zero out all audio levels when RMS falls below a threshold.
Hysteresis prevents rapid gate toggling: the gate opens when RMS rises
above ``threshold`` and closes only when RMS drops below
``threshold - hysteresis``.
"""
filter_id = "noise_gate"
filter_name = "Noise Gate"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._threshold = self.options["threshold"]
self._hysteresis = self.options["hysteresis"]
self._gate_open = False
@property
def is_stateful(self) -> bool:
return True
def reset(self) -> None:
self._gate_open = False
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="threshold",
label="Threshold",
option_type="float",
default=0.05,
min_value=0.0,
max_value=1.0,
step=0.01,
),
AudioFilterOptionDef(
key="hysteresis",
label="Hysteresis",
option_type="float",
default=0.05,
min_value=0.0,
max_value=0.2,
step=0.01,
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
rms = analysis.rms
# Update gate state with hysteresis
if self._gate_open:
if rms < (self._threshold - self._hysteresis):
self._gate_open = False
else:
if rms >= self._threshold:
self._gate_open = True
if self._gate_open:
return analysis
# Gate is closed — zero out levels, preserve beat fields and timestamp
return replace(
analysis,
rms=0.0,
peak=0.0,
spectrum=np.copy(_ZERO_SPECTRUM),
left_rms=0.0,
left_spectrum=np.copy(_ZERO_SPECTRUM),
right_rms=0.0,
right_spectrum=np.copy(_ZERO_SPECTRUM),
)
@@ -0,0 +1,104 @@
"""Peak Hold audio filter — retain peak values with configurable decay."""
import time
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
class PeakHoldFilter(AudioFilter):
"""Retain peak values and decay them over time.
For each spectrum bin (if per_bin) or for rms/peak scalars, retains the
maximum value seen and decays it at the configured rate. Output is the
maximum of the current value and the held (decaying) peak.
"""
filter_id = "peak_hold"
filter_name = "Peak Hold"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._decay_rate = self.options["decay_rate"] # dB/s
self._per_bin = self.options["per_bin"]
self._held_spectrum = np.zeros(NUM_BANDS, dtype=np.float32)
self._held_rms = 0.0
self._held_peak = 0.0
self._last_time: float | None = None
@property
def is_stateful(self) -> bool:
return True
def reset(self) -> None:
self._held_spectrum[:] = 0.0
self._held_rms = 0.0
self._held_peak = 0.0
self._last_time = None
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="decay_rate",
label="Decay Rate (dB/s)",
option_type="float",
default=10.0,
min_value=0.1,
max_value=50.0,
step=0.1,
),
AudioFilterOptionDef(
key="per_bin",
label="Per Spectrum Bin",
option_type="bool",
default=True,
min_value=None,
max_value=None,
step=None,
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
now = time.perf_counter()
if self._last_time is not None:
dt = now - self._last_time
else:
dt = 0.0
self._last_time = now
# Compute linear decay factor from dB/s
# decay_rate dB/s means the held value drops by decay_rate dB each second
# In linear: factor = 10^(-decay_rate * dt / 20)
decay_factor = 10.0 ** (-self._decay_rate * dt / 20.0) if dt > 0 else 1.0
# Decay held values
self._held_rms *= decay_factor
self._held_peak *= decay_factor
# Update held values with current maxima
self._held_rms = max(self._held_rms, analysis.rms)
self._held_peak = max(self._held_peak, analysis.peak)
new_rms = self._held_rms
new_peak = self._held_peak
if self._per_bin:
self._held_spectrum *= decay_factor
np.maximum(self._held_spectrum, analysis.spectrum, out=self._held_spectrum)
new_spectrum = np.copy(self._held_spectrum)
else:
new_spectrum = np.copy(analysis.spectrum)
return replace(
analysis,
rms=new_rms,
peak=new_peak,
spectrum=new_spectrum,
)

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