Compare commits

..

299 Commits

Author SHA1 Message Date
5fa851618b Merge feature/demo-mode: virtual hardware sandbox for testing 2026-03-20 16:17:23 +03:00
2240471b67 Add demo mode: virtual hardware sandbox for testing without real devices
Demo mode provides a complete sandbox environment with:
- Virtual capture engine (radial rainbow test pattern on 3 displays)
- Virtual audio engine (synthetic music-like audio on 2 devices)
- Virtual LED device provider (strip/60, matrix/256, ring/24 LEDs)
- Isolated data directory (data/demo/) with auto-seeded sample entities
- Dedicated config (config/demo_config.yaml) with pre-configured API key
- Frontend indicator (DEMO badge + dismissible banner)
- Engine filtering (only demo engines visible in demo mode)
- Separate entry point: python -m wled_controller.demo (port 8081)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:17:14 +03:00
81b275979b Fix stale frame in API CSS preview when source is inactive
Server: send initial frame immediately after metadata for api_input
sources so the client gets the current buffer (fallback color if
inactive) instead of never receiving a frame when push_generation
stays at 0.

Frontend: clear all test modal canvases on open to prevent stale
pixels from previous sessions. Reset FPS timestamps after metadata
so the initial bootstrap frame isn't counted in the FPS chart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:29:51 +03:00
47c696bae3 Frontend improvements: CSS foundations, accessibility, UX enhancements
CSS: Add design token variables (spacing, timing, weights, z-index layers),
migrate all hardcoded z-index to named vars, fix light theme contrast for
WCAG AA, add skeleton loading cards, mask-composite fallback, card padding.

Accessibility: aria-live on toast, aria-label on health dots, sr-only class,
graph container keyboard focusable, MQTT password wrapped in form element.

UX: Modal auto-focus on open, inline field validation with blur, undo toast
with countdown, bulk action progress indicator, API error toast on failure.

i18n: Add common.undo, validation.required, bulk.processing, api.error.*
keys in EN/RU/ZH.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:51:22 +03:00
43fbc1eff5 Fix modal-open layout shift caused by position:fixed scroll lock
Replace the body position:fixed hack with overflow:hidden on html element,
which works cleanly with scrollbar-gutter:stable to prevent layout shift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:37:10 +03:00
997ff2fd70 Migrate frontend from JavaScript to TypeScript
- Rename all 54 .js files to .ts, update esbuild entry point
- Add tsconfig.json, TypeScript devDependency, typecheck script
- Create types.ts with 25+ interfaces matching backend Pydantic schemas
  (Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource,
  AudioSource, PictureSource, ScenePreset, SyncClock, Automation, etc.)
- Make DataCache generic (DataCache<T>) with typed state instances
- Type all state variables in state.ts with proper entity types
- Type all create*Card functions with proper entity interfaces
- Type all function parameters and return types across all 54 files
- Type core component constructors (CardSection, IconSelect, EntitySelect,
  FilterList, TagInput, TreeNav, Modal) with exported option interfaces
- Add comprehensive global.d.ts for window function declarations
- Type fetchWithAuth with FetchAuthOpts interface
- Remove all (window as any) casts in favor of global.d.ts declarations
- Zero tsc errors, esbuild bundle unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:08:23 +03:00
55772b58dd Replace deploy workflow with portable Windows release build
- Remove old Docker-based deploy.yml
- Add release.yml: builds portable ZIP on tag push, uploads to Gitea
- Add build-dist.ps1: downloads embedded Python, installs deps, bundles app
- Add scrollbar-gutter: stable to prevent layout shift

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:21:55 +03:00
968046d96b HA integration: fix reload loop, parallel device fetch, WS guards, translations
- Fix indentation bug causing scenes device to not register
- Use nonlocal tracking to prevent infinite reload loops on target/scene changes
- Guard WS start/stop to avoid redundant connections
- Parallel device brightness fetching via asyncio.gather
- Route set_leds service to correct coordinator by source ID
- Remove stale pattern cache, reuse single timeout object
- Fix translations structure for light/select entities
- Unregister service when last config entry unloaded

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:21:46 +03:00
122e95545c Card bulk operations, remove expand/collapse, graph color picker fix
- Bulk selection mode: Ctrl+Click or toggle button to enter, Escape to exit
- Shift+Click for range select, bottom toolbar with SVG icon action buttons
- All CardSections wired with bulk actions: Delete everywhere,
  Start/Stop for targets, Enable/Disable for automations
- Remove expand/collapse all buttons (no collapsible sections remain)
- Fix graph node color picker overlay persisting after outside click
- Add Icons section to frontend.md conventions
- Add trash2, listChecks, circleOff icons to icon system
- Backend: processing loop performance improvements (monotonic timestamps,
  deque-based FPS tracking)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:21:27 +03:00
f4647027d2 Show actual API error details in modal save/create failures
Previously modals showed generic "Failed to add/save" messages. Now they
extract and display the actual error detail from the API response (e.g.,
"URL is required", "Name already exists"). Handles Pydantic validation
arrays by joining msg fields.

Fixed in 8 files: device-discovery, devices, calibration,
advanced-calibration, scene-presets, automations, command-palette.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:19:08 +03:00
cdba98813b Backend performance and code quality improvements
Performance (hot path):
- Fix double brightness: removed duplicate scaling from 9 device clients
  (wled, adalight, ambiled, openrgb, hue, spi, chroma, gamesense, usbhid,
  espnow) — processor loop is now the single source of brightness
- Bounded send_timestamps deque with maxlen, removed 3 cleanup loops
- Running FPS sum O(1) instead of sum()/len() O(n) per frame
- datetime.now(timezone.utc) → time.monotonic() with lazy conversion
- Device info refresh interval 30 → 300 iterations
- Composite: gate layer_snapshots copy on preview client flag
- Composite: versioned sub_streams snapshot (copy only on change)
- Composite: pre-resolved blend methods (dict lookup vs getattr)
- ApiInput: np.copyto in-place instead of astype allocation

Code quality:
- BaseJsonStore: RLock on get/delete/get_all/count (was created but unused)
- EntityNotFoundError → proper 404 responses across 15 route files
- Remove 21 defensive getattr(x,'tags',[]) — field guaranteed on all models
- Fix Dict[str,any] → Dict[str,Any] in template/audio_template stores
- Log 4 silenced exceptions (automation engine, metrics, system)
- ValueStream.get_value() now @abstractmethod
- Config.from_yaml: add encoding="utf-8"
- OutputTargetStore: remove 25-line _load override, use _legacy_json_keys
- BaseJsonStore: add _legacy_json_keys for migration support
- Remove unnecessary except Exception→500 from postprocessing list endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:06:29 +03:00
1f047d6561 KC test uses shared LiveStreamManager, tree-nav dropdown, KC card badge fix
- KC test WS now acquires from LiveStreamManager instead of creating its
  own DXGI duplicator, eliminating capture contention with running LED targets
- Tree-nav refactored to compact dropdown on click with outside-click dismiss
  (closes on click outside the trigger+panel, not just outside the container)
- KC target card badge (e.g. "Daylight Cycle") no longer wastes empty space

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:50:33 +03:00
6a31814900 Fix scroll position reset when closing modals
- Use { behavior: 'instant' } in unlockBody scrollTo to override
  CSS scroll-behavior: smooth on html element
- Use { preventScroll: true } on focus() restore in Modal.forceClose
- Add overflow-y: scroll to body.modal-open to prevent scrollbar shift

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:39:53 +03:00
ea9b05733b Dim non-related edges and flow dots when a graph node is selected
- Fix CSS specificity: .dimmed now overrides .graph-edge-active opacity/filter
- Add data-from/data-to to flow dot groups so they can be dimmed per-edge
- Dim flow dots on non-chain edges in highlightChain(), restore on clear

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:14:32 +03:00
05152a0f51 Settings tabs, log overlay, external URL, Sources tree restructure, audio fixes
- Settings modal split into 3 tabs: General, Backup, MQTT
- Log viewer moved to full-screen overlay with compact toolbar
- External URL setting: API endpoints + UI for configuring server domain
  used in webhook/WS URLs instead of auto-detected local IP
- Sources tab tree restructured: Picture Source (Screen Capture/Static/
  Processed sub-groups), Color Strip, Audio, Utility
- TreeNav extended to support nested groups (3-level tree)
- Audio tab split into Sources and Templates sub-tabs
- Fix audio template test: device picker now filters by engine type
  (was showing WASAPI indices for sounddevice templates)
- Audio template test device picker disabled during active test
- Rename "Input Source" to "Source" in CSS test preview (en/ru/zh)
- Fix i18n: log filter/level items deferred to avoid stale t() calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:16:57 +03:00
191c988cf9 Graph node FPS hover tooltip, full names, no native SVG tooltips
Graph editor:
- Floating FPS tooltip on hover over running output_target nodes (300ms delay)
- Shows errors, uptime, and FPS sparkline seeded from server metrics history
- Tooltip positioned below node with fade-in/out animation
- Uses pointerover/pointerout with relatedTarget check to prevent flicker
- Fixed-width tooltip (200px) with monospace values to prevent layout shift
- Node titles show full names (removed truncate), no native SVG <title> tooltips

Documentation:
- Added duration/numeric formatting conventions to contexts/frontend.md
- Added node hover tooltip docs to contexts/graph-editor.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:45:59 +03:00
afd4a3bc05 Override blend mode, FPS sparkline, fix api_input persistence
New features:
- Override composite blend mode: per-pixel alpha from brightness
  (black=transparent, bright=opaque). Ideal for API input over effects.
- API input test preview FPS chart uses shared createFpsSparkline
  (same look as target card charts)

Fixes:
- Fix api_input source not surviving server restart: from_dict was
  still passing removed led_count field to constructor
- Fix composite layer brightness/processing selectors not aligned:
  labels get fixed width, selects fill remaining space
- Fix CSPT input selector showing in non-CSPT CSS test mode
- Fix test modal LED/FPS controls showing for api_input sources
- Server only sends test WS frames when api_input push_generation changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:12:57 +03:00
be356f30eb Fix HAOS light color reverting after timeout
When HA sets a color via turn_on, also update the source's fallback_color
to match. This way when the api_input timeout fires (default 5s), the
stream reverts to the same color instead of black. turn_off resets
fallback to [0,0,0].

Added coordinator.update_source() for PUT /color-strip-sources/{id}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:52:50 +03:00
8a6ffca446 Rework API input CSS: segments, remove led_count, HAOS light, test preview
API Input CSS rework:
- Remove led_count field from ApiInputColorStripSource (always auto-sizes)
- Add segment-based payload: solid, per_pixel, gradient modes
- Segments applied in order (last wins on overlap), auto-grow buffer
- Backward compatible: legacy {"colors": [...]} still works
- Pydantic validation: mode-specific field requirements

Test preview:
- Enable test preview button on api_input cards
- Hide LED/FPS controls for api_input (sender controls those)
- Show input source selector for all CSS tests (preselected)
- FPS sparkline chart using shared createFpsSparkline (same as target cards)
- Server only sends frames when push_generation changes (no idle frames)

HAOS integration:
- New light.py: ApiInputLight entity per api_input source (RGB + brightness)
- turn_on pushes solid segment, turn_off pushes fallback color
- Register wled_screen_controller.set_leds service for arbitrary segments
- New services.yaml with field definitions
- Coordinator: push_colors() and push_segments() methods
- Platform.LIGHT added to platforms list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:47:42 +03:00
823cb90d2d Show captured border width overlay in picture CSS test preview
Backend: send border_width in WS metadata and frame_dims (width, height)
as a separate JSON message on first JPEG frame.

Frontend: render semi-transparent green overlay rectangles on each active
edge showing the sampling region depth, plus a small px label. Overlays
are proportionally sized based on border_width relative to frame dimensions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 02:09:29 +03:00
00c9ad3a86 Live KC test WS, sync clock fix, device card perf, camera icons, tab indicator
Key Colors test:
- New WS endpoint for live KC target test streaming (replaces REST polling)
- Auto-connect on lightbox open, auto-disconnect on close
- Uses same FPS/preview_width as CSS source test (no separate controls)
- Removed FPS selector, start/stop toggle, and updateAutoRefreshButton

Device cards:
- Fix full re-render on every poll caused by relative "Last seen" time in HTML
- Last seen label now patched in-place via data attribute (like FPS metrics)
- Remove overlay visualization button from LED target cards

Sync clocks:
- Fix card not updating start/stop icon: invalidate cache before reload

Other:
- Tab indicator respects bg-anim toggle (hidden when dynamic background off)
- Camera backend icon grid uses SVG icons instead of emoji
- Frontend context rule: no emoji in IconSelect items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 02:03:07 +03:00
bcba5f33fc OpenRGB dedup fix, device card URL badge overflow fix
- Replace threshold-based dedup with exact equality check in OpenRGB client;
  threshold dedup caused animation stutter at low software brightness
- Add brightness_control capability to OpenRGB provider (software simulated)
- Fix device card URL/COM badge overlapping close button: badge stays inside
  card-title flex container, both name and badge truncate with ellipsis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:36:36 +03:00
29b43b028d Fix automation badge overflow, dashboard crosslinks, compact numbers, icon grids, OpenRGB brightness
UI fixes:
- Automation card badge moved to flex layout — title truncates, badge stays visible
- Automation condition pills max-width increased to 280px
- Dashboard crosslinks fixed: pass correct sub-tab key (led-targets not led)
- navigateToCard only skips data load when tab already has cards in DOM
- Badge gets white-space:nowrap + flex-shrink:0 to prevent wrapping

New features:
- formatCompact() for large frame/error counters (1.2M, 45.2K) with hover title
- Log filter and log level selects replaced with IconSelect grids
- OpenRGB devices now support software brightness control

OpenRGB improvements:
- Added brightness_control capability (uses software brightness fallback)
- Change-threshold dedup compares raw pixels before brightness scaling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:29:17 +03:00
304fa24389 Comprehensive WebUI review: 41 UX/feature/CSS improvements
Safety & Correctness:
- Add confirmation dialogs to Stop All, turnOffDevice
- i18n confirm dialog (title, yes, no buttons)
- Fix duplicate tutorial-overlay ID
- Define missing CSS variables (--radius, --text-primary, --hover-bg, --input-bg)
- Fix toast z-index conflict with confirm dialog (2500 → 3000)

UX Consistency:
- Add backdrop-close to test modals
- Add device clone feature (only entity without it)
- Add sync clocks to command palette
- Replace 20+ hardcoded accent colors with CSS vars/color-mix()
- Remove dead .badge duplicate from components.css
- Make calibration elements keyboard-accessible (div → button)
- Add aria-labels to color picker swatches
- Fix pattern canvas mobile horizontal scroll
- Fix graph editor mobile bottom clipping

Polish:
- Add empty-state messages to all CardSection instances
- Convert 21 px font-sizes to rem
- Add scroll-behavior: smooth with reduced-motion override
- Add @media print styles
- Add :focus-visible to 4 missing interactive elements
- Fix settings modal close label (Cancel → Close)
- Fix api-key submit button i18n

New Features:
- Command palette actions: start/stop targets, activate scenes, enable/disable
- Bulk start/stop API endpoints (POST /output-targets/bulk/start|stop)
- OS notification history viewer modal
- Scene "used by" automation reference count on cards
- Clock elapsed time display on Streams tab cards
- Device "last seen" relative timestamp on cards
- Audio device refresh button in edit modal
- Composite layer drag-to-reorder
- MQTT settings panel (broker config with JSON persistence)
- WebSocket log viewer with level filtering and ring buffer
- Runtime log-level adjustment (GET/PUT endpoints + settings UI)
- Animated value source waveform canvas preview
- Gradient custom preset save/delete (localStorage)
- API key read-only display in settings
- Backup metadata (file size, auto/manual badges)
- Server restart button with confirm + overlay
- Partial config export/import per entity type
- Progressive disclosure in target editor (Advanced section)

CSS Architecture:
- Define radius scale tokens (--radius-sm/md/lg/pill)
- Scope .cs-filter selectors to remove 7 !important overrides
- Consolidate duplicate toggle switch (filter-list → settings-toggle)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:46:38 +03:00
a4a0e39b9b Replace bare — and generic None in selectors with descriptive None (reason) labels
All optional entity selectors now use the format "None (description)" with
i18n keys instead of hardcoded "—" or bare "None". Added common.none_no_cspt,
common.none_no_input, common.none_own_speed keys to all 3 locales. Updated
frontend context with the convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:46:37 +03:00
bbe42ee0a2 Graph editor: unified card colors, keyboard focus, color picker button
- Unified graph node colors with card color system (shared localStorage)
- Added color picker palette button to node overlay toolbar
- Auto-focus graph container for keyboard shortcuts to work immediately
- Trap Tab key to prevent focus escaping to footer
- Added mandatory bundle rebuild note to CLAUDE.md files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:34:07 +03:00
0bb4d7c3aa Add video picture source: file, URL, YouTube, sync clock, trim, test preview
Backend:
- VideoCaptureSource dataclass with url, loop, playback_speed, start/end_time,
  resolution_limit, clock_id, target_fps fields
- VideoCaptureStream: OpenCV decode thread with frame-accurate sync clock seeking,
  loop, trim range, resolution downscale at decode time
- YouTube URL resolution via yt-dlp (auto-detects youtube.com, youtu.be, shorts)
- Thumbnail extraction from first frame (GET /picture-sources/{id}/thumbnail)
- Video test WS preview: streams JPEG frames with elapsed/frame_count metadata
- Run video_stream.start() in executor to avoid blocking event loop during
  yt-dlp resolution
- Full CRUD via existing picture source API (stream_type: "video")
- Wired into LiveStreamManager for target streaming

Frontend:
- Video icon (film) in picture source type map and graph node subtypes
- Video tree nav node in Sources tab with CardSection
- Video fields in stream add/edit modal: URL, loop toggle, playback speed slider,
  target FPS, start/end trim times, resolution limit
- Video card rendering with URL, FPS, loop, speed badges
- Clone data support for video sources
- i18n keys for video source in en/ru/zh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:48:43 +03:00
0bbaf81e26 Major graph editor improvements: standalone features, touch, docking, UX
Graph standalone features:
- Clone button on all entity nodes (copy icon, watches for new entity)
- Scene preset activation button (play icon, calls /activate API)
- Automation enable/disable via start/stop toggle (PUT enabled)
- Add missing entity types: sync_clock, scene_preset, pattern_template
- Fix edit/delete handlers for cspt, sync_clock
- CSPT added to test/preview button kinds
- Bulk delete with multi-select + Delete key confirmation
- Undo/redo framework with toolbar buttons (disabled when empty)
- Keyboard shortcuts help panel (? key, draggable, anchor-persisted)
- Enhanced search: type:device, tag:production filter syntax
- Tags passed through to all graph nodes for tag-based filtering
- Filter popover with grouped checkboxes replaces flat pill row

Touch device support:
- Pinch-to-zoom with 2-finger gesture tracking
- Double-tap zoom toggle (1.0x ↔ 1.8x)
- Multi-touch pointer tracking with pinch-to-pan
- Overlay buttons and port labels visible on selected (tapped) nodes
- Larger touch targets for ports (@media pointer: coarse)
- touch-action: none on SVG canvas
- 10px dead zone for touch vs 4px for mouse

Visual improvements:
- Port pin labels shown outside node on hover/select (outlined text)
- Hybrid active edge flow: thicker + glow + animated dots
- Test/preview icon changed to flask (matching card tabs)
- Clone icon scaled down to 60% for compact overlay buttons
- Monospace font for metric values (stable-width digits)
- Hide scrollbar on graph tab (html:has override)

Toolbar docking:
- 8-position dock system (4 corners + 4 side midpoints)
- Vertical layout when docked to left/right sides
- Dock position indicators shown during drag (dots with highlight)
- Snap animation on drop
- Persisted dock position in localStorage

Resize handling:
- View center preserved on fullscreen/window resize (ResizeObserver)
- All docked panels re-anchored on container resize
- Zoom inertia for wheel and toolbar +/- buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:58:45 +03:00
50c40ed13f Frontend performance and code quality improvements
Performance: cache getBoundingClientRect in card-glare and drag-drop,
build adjacency Maps for O(1) graph BFS, batch WebGL uniform uploads,
cache matchMedia/search text in card-sections, use Map in graph-layout.

Code quality: extract shared FPS chart factory (chart-utils.js) and
FilterListManager (filter-list.js), replace 14-way CSS editor dispatch
with type handler registry, move state to state.js, fix layer violation
in api.js, add i18n for hardcoded strings, sync 53 missing locale keys,
add HTTP error logging in DataCache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:14:26 +03:00
014b4175b9 Add transient preview WS endpoint and test button in CSS editor modal
- Add /color-strip-sources/preview/ws endpoint for ad-hoc source preview
  without saving (accepts full config JSON, streams RGB frames)
- Add test preview button (flask icon) to CSS editor modal footer
- For self-contained types (static, gradient, color_cycle, effect, daylight,
  candlelight), always previews current form values via transient WS
- For non-previewable types, falls back to saved source test endpoint
- Fix route ordering: preview/ws registered before {source_id}/ws
- Fix css-test-led-control label alignment (display: inline globally)
- Add gradient onChange callback for future live-update support
- Add i18n keys for preview (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:49:22 +03:00
6c7b7ea7d7 Separate tree nodes into independent panels, remove graph local search, UI improvements
- Split Sources tab: raw/raw_templates, processed/proc_templates each get own panel
- Split Targets tab: led-devices, led-targets, kc-targets, kc-patterns each get own panel
- Remove graph local search — search button and / key open global command palette
- Add graphNavigateToNode for command palette → graph node navigation
- Add tree group expand/collapse animation (max-height + opacity transition)
- Make tree group headers visually distinct (smaller, uppercase, left border on children)
- Make CardSection collapse opt-in via collapsible flag (disabled by default)
- Move filter textbox next to section title (remove margin-left: auto)
- Fix notification bell button vertical centering in test preview
- Fix clipboard copy on non-HTTPS with execCommand fallback
- Add overlay toggle button on picture-based CSS cards
- Add CSPT to graph add-entity picker and global search
- Update all cross-link navigation paths for new panel keys
- Add i18n keys for new tree groups and search groups (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:32:13 +03:00
3292e0daaf Add graph icon grid, search-to-graph nav, overlay on CSS cards, fix clipboard copy
- Convert graph editor add-entity menu to showTypePicker icon grid with SVG icons
- Add CSPT to graph add-entity picker and ALL_CACHES watcher
- Add graphNavigateToNode() — command palette navigates to graph node when graph tab active
- Add CSPT entities to global search palette results
- Add overlay toggle button on picture-based CSS cards (toggleCSSOverlay)
- Fix clipboard copy on non-HTTPS (LAN) with execCommand fallback for all copy functions
- Fix notification bell button vertical centering in test preview strip canvas
- Add overlay.toggle, search.group.cspt i18n keys (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 11:32:55 +03:00
294d704eb0 Add CSPT entity, processed CSS source type, reverse filter, and UI improvements
- Add Color Strip Processing Template (CSPT) entity: reusable filter chains
  for 1D LED strip postprocessing (backend, storage, API, frontend CRUD)
- Add "processed" color strip source type that wraps another CSS source and
  applies a CSPT filter chain (dataclass, stream, schema, modal, cards)
- Add Reverse filter for strip LED order reversal
- Add CSPT and processed CSS nodes/edges to visual graph editor
- Add CSPT test preview WS endpoint with input source selection
- Add device settings CSPT template selector (add + edit modals with hints)
- Use icon grids for palette quantization preset selector in filter lists
- Use EntitySelect for template references and test modal source selectors
- Fix filters.css_filter_template.desc missing localization
- Fix icon grid cell height inequality (grid-auto-rows: 1fr)
- Rename "Processed" subtab to "Processing Templates"
- Localize all new strings (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 02:16:59 +03:00
7e78323c9c Add LED axis ticks and calibration labels to color strip test preview
- Add horizontal axis with tick marks and LED index labels below strip
  and composite preview canvases (first/last labels edge-aligned)
- Show actual/calibration LED count label on picture-based composite
  layers (e.g. "25/934")
- Display warning icon in orange when LED counts don't match
- Send is_picture and calibration_led_count in composite layer_infos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:47:22 +03:00
d1c8324c0f Move color strip sources from Targets tab to Sources tab
- Remove csColorStrips CardSection from targets.js, add to streams.js
- Add color_strip sub-tab with tree nav entry between Picture and Audio
- Update navigateToCard refs in target cards and command palette
- Update tutorial steps: remove led-css from targets, add color_strip to sources
- Add i18n keys for streams.group.color_strip and tour.src.color_strip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:37:06 +03:00
49c2a63d68 Bundle frontend with esbuild, serve fonts offline, fix dashboard
- Add esbuild bundling: JS (IIFE, minified, sourcemapped) and CSS into
  single dist/ files, replacing 15+ individual CSS links and CDN scripts
- Bundle Chart.js and ELK.js from npm instead of CDN (fully offline)
- Serve DM Sans and Orbitron fonts locally from static/fonts/
- Fix dashboard automation card stretching full width (max-width: 500px)
- Fix time_of_day condition not localized in automation cards
- Add Chrome browser tools context file for MCP testing workflow
- Update frontend context with bundling docs and Chrome tools reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:20:20 +03:00
46d77052ad Add GZip compression middleware for static file serving
Reduces transfer sizes by ~75% (e.g. graph-editor.js 74KB→16KB,
en.json 92KB→22KB), significantly improving page load times.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:58:33 +03:00
dd92af9913 Add graph filter by entity type/running state and fix duplicate API calls
- Add entity type toggle pills and running/stopped filter to graph filter bar
- DataCache: return cached data if fresh, skip redundant fetches on page load
- Entity events: use force-fetch instead of invalidate+fetch to avoid stale gap
- Add no-cache middleware for static JS/CSS/JSON to prevent stale browser cache
- Reduces API calls on page load from ~70 to ~30

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:56:46 +03:00
a922c6e052 Add type picker for entity creation, icon grid filter, and serial port placeholder
- Replace inline type selectors with pre-modal type picker grid for devices,
  color strip sources, and value sources
- Add filterable search to icon grid when items > 9 (no auto-focus on touch)
- Show disabled (grayed-out) filtered items instead of hiding them
- Responsive grid columns (2-5 cols based on viewport width)
- Add "Select a port..." placeholder to serial port dropdown
- Update en/ru/zh locales with new keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:44:26 +03:00
6395709bb8 Unify graph docking, fix device hot-switch, and compact UI cards
- Unify minimap/toolbar/legend drag+dock into shared _makeDraggable() helper
- Persist legend visibility and position, add active state to toggle buttons
- Show custom colors only on graph cards (entity defaults remain in legend)
- Replace emoji overlay buttons with SVG path icons
- Fix stale is_running blocking target start (auto-clear if task is done)
- Resolve device/target IDs to names in conflict error messages
- Hot-switch LED device on running target via async stop-swap-start cycle
- Compact automation dashboard cards and fix time_of_day localization
- Inline CSS source pill on target cards to save vertical space

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:12:12 +03:00
272cb69247 Add 6 new device providers, IconSelect grids, and UI fixes
New device providers: ESP-NOW, Philips Hue, USB HID, SPI Direct,
Razer Chroma SDK, and SteelSeries GameSense — each with client,
provider, full backend registration, schemas, routes, and frontend
support including discovery, form fields, and i18n.

Add IconSelect grids for SPI LED chipset selector and GameSense
peripheral type selector with new Lucide icons (cpu, keyboard,
mouse, headphones).

Replace emoji graph overlay buttons (eye, bell) with proper SVG
path icons for consistent cross-platform rendering.

Fix connection overlay causing horizontal scroll by adding
overflow: hidden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 20:32:28 +03:00
51ec0970c3 Add per-button tooltips to graph overlay and widen stream test modal
- Each graph node overlay button now has its own <title> tooltip
  (Edit, Delete, Start/Stop, Test, etc.) instead of inheriting the
  card name from the parent group
- Widen #test-stream-modal to 700px (matching CSS source test modal)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:40:00 +03:00
153972fcd5 Fix device provider kwargs, camera crash guard, target API, and graph color picker
- Refactor all device providers to use explicit kwargs.get() instead of
  fragile pop-then-passthrough (fixes WLED target start failing with
  unexpected dmx_protocol kwarg)
- Add process-wide camera index registry to prevent concurrent opens of
  the same physical camera which crashes the DSHOW backend on Windows
- Fix OutputTargetResponse validation error when brightness_value_source_id
  is None (coerce to empty string in response and from_dict)
- Replace native <input type="color"> in graph editor with the custom
  color picker popover used throughout the app, positioned via
  getScreenCTM() inside an absolute overlay on .graph-container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:36:26 +03:00
8960e7dca3 Fix anchor positions getting corrupted by fullscreen mode
Skip persisting minimap/toolbar anchor data while in fullscreen so
dragging during fullscreen doesn't overwrite normal-mode offsets.
ResizeObserver now just clamps during fullscreen instead of
re-applying normal-mode anchors to the fullscreen-sized container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:43:07 +03:00
39981fbc45 Add graph editor filter, anchor-based positioning, and context docs
- Add name/kind/subtype filter bar with keyboard shortcut (F key)
- Filtered-out nodes get dimmed styling, nearly invisible on minimap
- Add anchor-based positioning for minimap and toolbar (remembers
  which corner element is closest to, maintains offset on resize)
- Fix minimap not movable after reload (_applyMinimapAnchor undefined)
- Fix ResizeObserver to use anchor system for both minimap and toolbar
- Add graph-editor.md context file and update frontend.md with graph sync notes
- Add filter i18n keys for en/ru/zh locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:39:14 +03:00
e163575bac Fix zoomToPoint animation to smoothly fly-to target node
Interpolate both view center and zoom level together using rAF
instead of CSS transitions, so the target node smoothly slides
to screen center while zooming in simultaneously.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:06:05 +03:00
844866b489 Zoom to newly added entity in graph editor instead of just panning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:49:34 +03:00
5c7c2ad1b2 Enhance graph editor: fullscreen bg, add-entity focus, color picker fix, UI polish
- Move bg-anim canvas into graph container during fullscreen so dynamic background is visible
- Watch for new entity creation from graph add menu and auto-navigate to it after reload
- Position color picker at click coordinates instead of 0,0
- Replace test/preview play triangle with eye icon to distinguish from start/stop
- Always use port-aware bezier curves for edges instead of ELK routing
- Add fullscreen and add-entity buttons to toolbar with keyboard shortcuts (F11, +)
- Add confirmation dialog for relayout when manual positions exist
- Remove node body stroke, keep only color bar; add per-node color picker
- Clamp toolbar position on load to prevent off-screen drift
- Add graph tab to getting-started tutorial
- Add WASD/arrow spatial navigation, ESC reset, keyboard shortcuts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:48:55 +03:00
b370bb7d75 Add interactive graph editor connections: port-based edges, drag-connect, and detach
- Add visible typed ports on graph nodes (colored dots for each edge type)
- Route edges to specific port positions instead of node center
- Drag from output port to compatible input port to create/change connections
- Right-click edge context menu with Disconnect option
- Delete key detaches selected edge
- Mark nested edges (composite layers, zones) as non-editable with dotted style
- Add resolve_ref helper for empty-string sentinel to clear reference fields
- Apply resolve_ref across all storage stores for consistent detach support
- Add connection mapping module (graph-connections.js) with API field resolution
- Add i18n keys for connection operations (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:15:33 +03:00
ff24ec95e6 Add Art-Net / sACN (E1.31) DMX device support
Full-stack implementation of DMX output for stage lighting and LED controllers:
- DMXClient with Art-Net and sACN packet builders, multi-universe splitting
- DMXDeviceProvider with manual_led_count capability and URL parsing
- Device store, API schemas, routes wired with dmx_protocol/start_universe/start_channel
- Frontend: add/settings modals with DMX fields, IconSelect protocol picker
- Fix add device modal dirty check on type change (re-snapshot after switch)
- i18n keys for DMX in en/ru/zh locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:46:40 +03:00
18c886cbc5 Use primary color for running node icons and fix add device modal dirty check
- Running graph nodes show entity icon in --primary-color instead of --text-muted
- Fix AddDeviceModal always showing dirty: serialize zones array with JSON.stringify
  for proper strict equality comparison (matching DeviceSettingsModal pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:05:37 +03:00
7902d2e1f9 Add start/stop, test, and notification buttons to graph editor node overlays
- Output targets and sync clocks get start/stop (▶/■) with optimistic UI update
- Test/preview button for templates, sources, and KC targets
- Notification test button (🔔) for notification color strip sources
- Fetch batch states to show correct running status for output targets
- Sync clocks show running state from API (is_running)
- Surgical DOM patching (patchNodeRunning) preserves hover state on toggle
- Success button hover style (green) for start action

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:56:19 +03:00
a54e2ab8b0 Add rubber-band selection, multi-node drag, edge click, and keyboard shortcuts
- Shift+drag on empty space draws selection rectangle to select multiple nodes
- Multi-node drag: dragging a selected node moves all selected nodes together
- Click edge to highlight it and its connected nodes
- Delete key removes single selected node, Ctrl+A selects all
- Edges now have pointer cursor for click affordance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:30:09 +03:00
6d85385dbb Add node dragging, animated flow dots, and canvas cleanup to graph editor
- Drag nodes to reposition with dead-zone, edge re-routing, and minimap sync
- Animated flow dots trace upstream chains to running nodes
- Manual positions persist across re-renders, cleared on relayout
- Fix canvas event listener leak on re-render by calling destroy()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:21:14 +03:00
bd7a315c2c Add visual graph editor for entity interconnections
SVG-based node graph with ELK.js autolayout showing all 13 entity types
and their relationships. Features include:

- Pan/zoom canvas with bounds clamping and dead-zone click detection
- Interactive minimap with viewport rectangle, click-to-pan, drag-to-move,
  and dual resize handles (bottom-left/bottom-right)
- Movable toolbar with drag handle and inline zoom percentage indicator
- Entity-type SVG icons from Lucide icon set with subtype-specific overrides
- Command palette search (/) with keyboard navigation and fly-to
- Node selection with upstream/downstream chain highlighting
- Double-click to zoom-to-card, edit/delete overlay on hover
- Legend panel, orphan node detection, running state indicators
- Full i18n support with languageChanged re-render
- Catmull-Rom-to-cubic bezier edge routing for smooth curves

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:01:47 +03:00
42b5ecf1cd Reduce default candlelight flicker speed for more realistic candle effect
Scale speed by 0.35 so speed=1 gives ~1.3 Hz dominant flicker instead of 3.7 Hz.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:12:32 +03:00
fe7fd8d539 Truncate long card titles with ellipsis and reduce font size
- Replace flex-wrap with overflow ellipsis on .card-title and .template-name
- Reduce card title font size from 1.2rem/18px to 1.05rem
- Add title attribute (hover tooltip) on all card types for full name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:08:47 +03:00
561229a7fe Add configurable FPS to test preview and fix composite stream release race
- Add FPS control (1-60, default 20) to test preview modal next to LED count
- Server accepts fps query param, controls frame send interval
- Single Apply icon button (✓) applies both LED count and FPS
- FPS control stays visible for picture sources (LED count hidden)
- Fix composite sub-stream consumer ID collision: use unique instance ID
  to prevent old WebSocket release from killing new connection's streams

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:04:09 +03:00
e912019873 Improve CSS test preview: HD resolution, screen-only border, and refactor frontend docs
- Bump capture preview resolution from 480×360 to 960×540 (HD)
- Increase preview FPS from 2 to ~12 FPS (AUX_INTERVAL 0.5→0.08)
- Add accent-color border on screen rect only (not LED edges) via ::after
- Use dynamic aspect-ratio from decoded JPEG frames instead of fixed height
- Widen modal to 900px for picture sources
- Move frontend conventions from CLAUDE.md to contexts/frontend.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:50:23 +03:00
568a992a4e Enhance CSS test preview with live capture, brightness display, and UX fixes
- Stream live JPEG frames from picture sources into the test preview rectangle
- Add composite layer brightness display via value source streaming
- Fix missing id on css-test-rect-screen element that prevented frame display
- Preload images before swapping to eliminate flicker on frame updates
- Increase preview resolution to 480x360 and add subtle outline
- Prevent auto-focus on name field in modals on touch devices (desktopFocus)
- Fix performance chart padding, color picker clipping, and subtitle offset
- Add calibration-style ticks and source name/LED count to rectangle preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:31:37 +03:00
9b5686ac0a Reduce noise glow intensity on light theme
Lower light-theme boost from 2.2x to 1.5x for subtler background shapes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:43:46 +03:00
812d15419c Improve background effects for light theme and fix mobile color picker
- WebGL shader: theme-aware blending (tint toward accent on light, additive
  glow on dark) with u_light uniform for proper light-theme visibility
- Cards: translucent backgrounds only on entity cards when bg-anim is active,
  keeping modals/pickers/tab bars/header fully opaque
- Running card border and tab indicator: boosted contrast for light theme
- Header: backdrop-filter via pseudo-element to avoid breaking fixed tab-bar
- Color picker: move popover to document.body on mobile as centered bottom-sheet
- Add card: use --card-bg background and bolder + icon for visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:42:22 +03:00
b9c71d5bb9 Fix sticky header on mobile by using overflow-x: clip instead of hidden
overflow-x: hidden on body creates a scroll container that breaks
position: sticky. overflow-x: clip prevents horizontal overflow
without affecting the sticky context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:56:12 +03:00
97db63824e Add composite layer preview, configurable LED count, and notification fire button to CSS test modal
- Composite sources show per-layer strip canvases with composite result on top
- Server sends composite wire format with per-layer RGB data
- LED count is configurable via input field, persisted in localStorage
- Notification sources show a bell fire button on the strip preview
- Composite with notification layers shows per-layer fire buttons
- Fixed stale WS frame bug with generation counter and unique consumer IDs
- Modal width is now fixed at 700px to prevent layout jumps
- Target card composite layers now use same-height canvases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:17:54 +03:00
f2162133a8 Fix rectangle preview canvas overflow for large LED counts
Wrap edge canvases in container divs with position:relative + overflow:hidden,
and absolutely-position the canvases inside. This prevents canvas intrinsic
pixel dimensions from overriding CSS grid cell sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:25:36 +03:00
bebdfcf319 Add test preview for color strip sources with LED strip and rectangle views
New WebSocket endpoint streams real-time RGB frames from any CSS source.
Generic sources show a horizontal LED strip canvas. Picture sources show
a rectangle with per-edge canvases matching the calibration layout.

Server computes exact output indices per edge (offset + reverse + CW/CCW)
so the frontend renders edges in correct visual orientation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:18:36 +03:00
0e270685e8 Remove collapsible section header from animation type selector
Animation section only has one field, so flatten it to a simple
form group with label instead of a details/summary wrapper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:21:25 +03:00
c431cb67b6 Add IconSelect grids for animation type and protocol selectors
Replace plain dropdowns with visual icon grids:
- Animation type (static/gradient CSS sources): icons for each effect
- WLED target protocol: DDP vs HTTP with descriptions
Add i18n keys for protocol options in all 3 locales.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:18:29 +03:00
31a0f4b2ff Add subtle animated gradient background to running target cards
Uses the existing --border-angle animation to sweep a faint accent
gradient across the card background in sync with the rotating border.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:31:09 +03:00
9b0dcdb794 Use DM Sans as the base UI font
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:27:18 +03:00
b62f8b8eaa Enhance app title with Orbitron font, gradient shimmer, and text stroke
Load Orbitron from Google Fonts for the h1 title and version badge.
Add animated gradient shimmer sweep and accent-colored text outline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:51:09 +03:00
e97ef3afa6 Add semi-transparent blurred tab icon as background watermark
Large SVG icon on the right side of the viewport reflects the active tab,
crossfades on tab switch. Also removes overflow:hidden from cards to fix
color picker clipping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:43:59 +03:00
4245e81a35 Merge floating particles into WebGL shader and fix color picker clipping
Remove overflow:hidden from .card and .template-card that was clipping
the color picker popover. Combine noise field + particle glow into a
single GPU shader pass (40 drifting particles as uniforms).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:35:49 +03:00
b4ab5ffe3c Replace particle background with WebGL simplex noise shader
GPU-accelerated flowing noise field with accent-colored highlights.
Three layered noise octaves at different scales produce organic motion.
Renders at half resolution for minimal GPU load, zero color banding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:29:27 +03:00
4db7cd2d27 Replace blob background with floating particle field
Canvas-based particle system with 60 glowing dots drifting upward,
tinted with the accent color. Eliminates the gradient banding issue
from the previous CSS blur approach. Renders at native resolution
with radial gradients for perfectly smooth glow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:27:49 +03:00
012e9f5ddb Add rotating gradient border on running LED target cards
Animated conic gradient spins around the card edge using CSS Houdini
@property for smooth angle interpolation. Skips the left edge when
a custom card color stripe is assigned (data-has-color attribute).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:18:19 +03:00
ff7b595032 Add cursor-tracking card glare and accent-linked background blobs
- Subtle radial glare follows cursor over card/template-card elements
  using a single document-level mousemove listener (event delegation)
- Ambient background blob colors now derive from the selected accent
  color with hue-shifted variants
- Glare intensity kept very subtle (3.5% dark / 12% light theme)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:55 +03:00
40ea2e3b99 Add optional ambient animated background with toggle
Three blurred color blobs (green, blue, purple) slowly drift behind
the UI for atmosphere. Toggled via cloud icon in header, persisted
in localStorage, off by default. Works with both dark and light themes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:06:56 +03:00
2f221f6219 Reduce button padding in card action bars to prevent overflow
Targets with screen capture sources show 5 action buttons which
overflowed the card width. Scoped smaller padding to .card-actions
.btn-icon where target cards render.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:00:11 +03:00
2a73b92d4a Add per-layer LED preview for composite color strip sources
When a target uses a composite CSS source, the LED preview now shows
individual layer strips below the blended composite result. Backend
stores per-layer color snapshots and sends an extended binary wire
format; frontend renders separate canvases with hover labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:51:35 +03:00
f6f428515a Update TODO.md and add task tracking rule to CLAUDE.md
Remove completed/deferred items from TODO.md and add instruction
to use TODO.md as the primary task tracker instead of TodoWrite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:40:16 +03:00
bf910204a9 Fix UI review issues: accessibility, i18n, duplicate IDs, URL overflow
- Rename duplicate id="settings-error" to "device-settings-error"
- Add missing i18n key value_source.scene_sensitivity.hint (en/ru/zh)
- Add accessible label to password-toggle and Stop All buttons
- Add aria-hidden toggle on connection overlay
- Fix static image URL overflow with ellipsis truncation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:37:56 +03:00
6a22757755 Add manual ping/health check button to device cards
Adds a refresh icon button on each device card that triggers an immediate
health check via POST /devices/{id}/ping, showing online status with
latency or offline result as a toast notification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:51:40 +03:00
27884282a7 Add Linux D-Bus notification listener support
Refactor OS notification listener into platform backends:
- Windows: winsdk toast polling (unchanged behavior)
- Linux: dbus-next monitoring of org.freedesktop.Notifications
Add [notifications] optional dependency group in pyproject.toml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:27:48 +03:00
99d8c4b8fb Add connection overlay and Gitea CI/CD workflow
Show full-screen overlay with spinner when server is unreachable,
with periodic health checks that auto-hide on reconnect.
Add Gitea Actions workflow for auto-deploy on release tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:07:13 +03:00
bf212c86ec Add OS notification listener for Windows toast capture
Polls Windows notifications via winsdk UserNotificationListener API
and fires matching notification CSS streams with os_listener=True.
Includes history API endpoint for tracking captured notifications.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:57:29 +03:00
42280f094a Fix WLED target start failing with unexpected zone_mode argument
WLEDProvider.create_client() was not filtering out the zone_mode kwarg
before passing to WLEDClient, causing a 409 error on target start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:30:33 +03:00
d498bb72a9 Add per-layer brightness source to composite CSS and enhance selectors
- Add optional brightness_source_id per composite layer using ValueStreamManager
- Use EntitySelect for composite layer source and brightness dropdowns
- Use IconSelect for composite blend mode and notification filter mode
- Add i18n keys for blend mode and filter mode descriptions (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:03:58 +03:00
c78797ba09 Add test button to notification CSS cards and fix header stability
- Bell icon button on notification source cards triggers POST /notify
- Shows success/warning/error toast based on response
- Fix header shifting when sticky by moving it outside .container
- i18n keys added for en, ru, zh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:36:27 +03:00
2d847beefa Fix header position shift when becoming sticky
Move header outside .container so it spans full viewport width
with its own padding, eliminating layout shift on scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:49:00 +03:00
155bbdbc24 Update ast-index instructions in CLAUDE.md
Add subagent guidance, version check, and additional commands
(hierarchy, changed, update).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:38:12 +03:00
040a5bbdaa Add multi-column grid for dashboard target cards
Target cards use responsive grid based on 500px min-width,
automatically adapting column count to screen width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:32:20 +03:00
3e7b64664a Improve dashboard layout for wide screens
- Perf charts: 3 equal columns filling full width, single column on mobile
- Chart height increased from 60px to 100px
- Fix Chart.js canvas not shrinking with min-width: 0 and overflow: hidden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:39:04 +03:00
6349e91e0f Sticky header with centered tabs, scroll-spy, and full-width layout
- Move tab bar into header (centered between title and toolbar)
- Make entire header sticky with border-bottom separator
- Remove container max-width for full-width layout
- Add scroll-spy: tree sidebar tracks visible section on scroll
- Remember scroll position per tab when switching
- Remove sticky section headers, use scroll-margin-top instead
- Update sticky offsets to use --sticky-top CSS variable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:29:37 +03:00
304c4703b9 Add manual backup trigger endpoint
POST /api/v1/system/auto-backup/trigger creates a backup on demand
and returns the created file info (filename, size, timestamp).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:27:44 +03:00
ac10b53064 Add ast-index as primary code search tool in CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:15:26 +03:00
09b4a1b182 Fix tutorial tooltip flash at 0,0 and slow step transitions
- Hide tooltip/ring with visibility:hidden until first step is positioned
- Hide tooltip between steps when scrolling to prevent stale position flash
- Replace scrollend event (poor browser support, 500ms fallback) with
  requestAnimationFrame polling that resolves in ~50ms when scroll settles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:48:13 +03:00
d8e73cb2b5 Fix card grid layout on narrow viewports with tree sidebar
The tree-layout used align-items: flex-start for the desktop sidebar,
but when switching to column direction at <900px this prevented children
from stretching to full width. Add align-items: stretch in the media
query and lower grid minmax values so cards use 2+ columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:28:14 +03:00
b0a769b781 Move tags input under name field in all entity editor modals
Remove the separate tags form-group (label, hint toggle, hint text)
from all 14 editor modals and place the tags container directly
below the name input for a cleaner, more compact layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:15:46 +03:00
1559440a21 Add scroll-to-top button
Fixed button in bottom-right corner appears after scrolling 300px,
fades in with slide-up animation, smooth-scrolls to page top on click.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:06:08 +03:00
3915573514 Replace flat sub-tab bars with tree sidebar navigation
Add TreeNav component that groups related entity types into a
collapsible hierarchy for Targets and Sources tabs. Targets tree
shows section-level leaves (Devices, Color Strips, LED Targets,
KC Targets, Pattern Templates) with scroll-to-section on click.
Sources tree groups into Picture, Audio, and Utility categories.

Also fixes missing csAudioTemplates in stream section expand/collapse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:03:31 +03:00
ee40d99067 Add Daylight Cycle value source type
New value source that outputs brightness (0-1) based on the daylight
color LUT, computing BT.601 luminance from the simulated sky color.
Supports real-time wall-clock mode or configurable simulation speed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:27:36 +03:00
73562cd525 Add entity CRUD events over WebSocket with auto-refresh
Broadcast entity_changed and device_health_changed events via the event
bus so the frontend can auto-refresh cards without polling. Adds
exponential backoff on WS reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:09:09 +03:00
1ce25caa35 Clean up TODO.md: remove completed items, add new P1 tasks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:07:44 +03:00
7b4b455c7d Fix IconSelect grid overflow and scroll jump
- Set maxHeight dynamically based on available viewport space
- Clamp popup horizontally to stay within viewport
- Remove max-height CSS transition that caused scroll jumps
- Auto-close popup on ancestor scroll to prevent stale positioning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:07:38 +03:00
37c80f01af Add Daylight Cycle and Candlelight CSS source types
Full-stack implementation of two new color strip source types:
- Daylight: simulates day/night color cycle with real-time or speed-based mode, latitude support
- Candlelight: multi-candle fire simulation with Gaussian falloff, layered-sine flicker, warm color shift

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:07:30 +03:00
954e37c2ca Add auto-restart for crashed processing loops, remove sync clock badge
- Auto-restart: ProcessorManager detects fatal task crashes via done
  callback and restarts with exponential backoff (2s-30s, max 5 attempts
  in 5 min window). Manual stop disables auto-restart. Restart state
  exposed in target state API and via WebSocket events.
- Remove "Running"/"Paused" badge label from sync clock dashboard cards
  (pause/play button already conveys state).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:53:04 +03:00
30fa107ef7 Add tags to all entity types with chip-based input and autocomplete
- Add `tags: List[str]` field to all 13 entity types (devices, output targets,
  CSS sources, picture sources, audio sources, value sources, sync clocks,
  automations, scene presets, capture/audio/PP/pattern templates)
- Update all stores, schemas, and route handlers for tag CRUD
- Add GET /api/v1/tags endpoint aggregating unique tags across all stores
- Create TagInput component with chip display, autocomplete dropdown,
  keyboard navigation, and API-backed suggestions
- Display tag chips on all entity cards (searchable via existing text filter)
- Add tag input to all 14 editor modals with dirty check support
- Add CSS styles and i18n keys (en/ru/zh) for tag UI
- Also includes code review fixes: thread safety, perf, store dedup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:20:19 +03:00
2712c6682e Add EntitySelect/IconSelect UI improvements across modals
- Portal IconSelect popups to document.body with position:fixed to prevent
  clipping by modal overflow-y:auto
- Replace custom scene selectors in automation editor with EntitySelect
  command-palette pickers (main scene + fallback scene)
- Add IconSelect grid for automation deactivation mode (none/revert/fallback)
- Add IconSelect grid for automation condition type and match type
- Replace mapped zone source dropdowns with EntitySelect pickers
- Replace scene target selector with EntityPalette.pick() pattern
- Remove effect palette preview bar from CSS editor
- Remove sensitivity badge from audio color strip source cards
- Clean up unused scene-selector CSS and scene-target-add-row CSS
- Add locale keys for all new UI elements across en/ru/zh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:00:30 +03:00
186940124c Optimize OpenRGB client: background sender thread, raw packets, change-threshold dedup
- Decouple processing loop from blocking TCP writes via single-slot buffer sender thread
- Build raw UpdateZoneLeds packets with struct.pack instead of RGBColor objects (reduces GC pressure)
- Add change-threshold frame dedup to minimize GPU I2C/SMBus writes that cause system stalls
- Set TCP_NODELAY and reduce socket timeout for lower latency
- Cache zone IDs for direct packet construction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:38:43 +03:00
32e0f0eb5c Improve calibration UI: animated config sections, always-visible tick labels, zoom-independent fonts, smooth line selection
- Replace <details> with grid-template-rows animated expand for template config sections
- Always show edge boundary tick labels in both simple and advanced calibration
- Make tick labels, monitor names, and tick marks zoom-independent in advanced calibration
- Place new monitors next to existing ones and fit view on add
- Fix layout jump on line selection: toggle class in-place instead of DOM rebuild
- Use transparent border-left on all line items to prevent content shift

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:10:29 +03:00
353a1c2d85 Rename picture-targets to output-targets across entire codebase
Rename all Python modules, classes, API endpoints, config keys, frontend
fetch URLs, and Home Assistant integration URLs from picture-targets to
output-targets. Store loads both new and legacy JSON keys for backward
compatibility with existing data files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:55:36 +03:00
5b4813368b Add visual selectors to automation and KC target editors
Automation editor:
- IconSelect grid for condition logic (OR/AND) with descriptions

KC target editor:
- IconSelect for color mode (average/median/dominant) with SVG previews
- EntitySelect palette for picture source, pattern template, brightness source

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:12:57 +03:00
8061c26bef Fix autorestore logic and protocol badge per device type
Autorestore fixes:
- Snapshot WLED state before connect() mutates it (lor, AudioReactive)
- Gate restore on auto_shutdown setting (was unconditional)
- Remove misleading auto_restore capability from serial provider
- Default auto_shutdown to false for all new devices

Protocol badge fixes:
- Show correct protocol per device type (OpenRGB SDK, MQTT, WebSocket)
- Was showing "Serial" for all non-WLED devices

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:04:40 +03:00
96bd3bd0f0 Add name auto-generation for value source modal
Auto-generates name from type + key config (waveform, audio mode, picture source)
for new value sources. Skips when editing or after manual name input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:43:24 +03:00
0984a3b639 Add IconSelect for filter types, audio modes, engine descriptions; fix scroll flash
- Filter type picker: IconSelect with 3-column grid, auto-add on select, removed redundant + button
- Audio mode picker: IconSelect with SVG visualizations for RMS/Peak/Beat
- Capture engine grid: added per-engine icons and localized descriptions
- Fixed scroll flash during icon grid open animation (settled class after transitionend)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:01:49 +03:00
be91e74c6e Add visual IconSelect grid for filter type picker in PP template editor
Replace plain filter type dropdown with icon grid showing each filter
with its icon and description. Selecting a filter immediately adds it
to the template (no separate "+" click needed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:45:43 +03:00
a728c75113 Add visual IconSelect selectors for effect, palette, gradient, waveform dropdowns
Replace plain <select> dropdowns with rich visual selectors:
- Effect type: icon grid with descriptions
- Effect/audio palette: gradient strip previews from color data
- Gradient preset: gradient strip previews (13 presets)
- Audio visualization: icon grid with descriptions
- Notification effect: icon grid with descriptions
- Waveform (value source): inline SVG shape previews

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:41:05 +03:00
dc4495a117 Add collapsible pipeline metrics and error indicator to target cards
FPS chart stays always visible; timing, frames, keepalive, errors, and
uptime are collapsed behind an animated toggle. Error warning icon
appears next to target name when errors_count > 0. Uses CSS grid
0fr→1fr transition for smooth expand/collapse animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:27:08 +03:00
6fc0e20e1d Add command palette entity selector for all editor dropdowns
Replace plain <select> dropdowns with a searchable command palette modal
for 16 entity selectors across 6 editors (targets, streams, CSS sources,
value sources, audio sources, pattern templates). Unified EntityPalette
singleton + EntitySelect wrapper in core/entity-palette.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:17:44 +03:00
b4d89e271d Apply IconSelect to all type selectors across the app
- Value source type (5 types, static icons)
- Device type (7 types, new wifi/usb icon paths + device icon map)
- Capture engine (dynamic from API, uses getEngineIcon)
- Audio engine (dynamic from API, new getAudioEngineIcon)
- Add i18n description keys for value source and device types
- Fix trigger button styling to match native input height

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:57:37 +03:00
d95eb683e1 Add reusable icon-grid type selector for CSS source editor
Replaces the plain <select> dropdown with a visual grid popup showing
icon, label, and description for each source type. The IconSelect
component is generic and reusable for other type selectors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:15:39 +03:00
d6bda9afed Unify process picker, improve notification CSS editor, remove notification led_count
- Extract shared process picker module (core/process-picker.js) used by
  both automation conditions and notification CSS app filter
- Remove led_count property from notification CSS source (backend + frontend)
- Replace comma-separated app filter with newline-separated textarea + browse
- Inline color cycle add button (+) into the color row
- Fix notification app color layout to horizontal rows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:58:36 +03:00
a330a8c0f0 Add clone support for scene and automation cards, update sync clock descriptions
- Scene clone: opens capture modal with prefilled name/description/targets
  instead of server-side duplication; removed backend clone endpoint
- Automation clone: opens editor with prefilled conditions, scene, logic,
  deactivation mode (webhook tokens stripped for uniqueness)
- Updated sync clock i18n descriptions to reflect speed-only-on-clock model
- Added entity card clone pattern documentation to server/CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:47:11 +03:00
bc5d8fdc9b Remove led_count from static, gradient, color_cycle, and effect CSS sources
These types always auto-size from the connected device — the explicit
led_count override was unused clutter. Streams now use getattr fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:21:00 +03:00
de04872fdc Add notification reactive color strip source with webhook trigger
New source_type "notification" fires one-shot visual effects (flash, pulse, sweep)
triggered via POST webhook. Designed as a composite layer for overlay on persistent
sources. Includes app color mapping, whitelist/blacklist filtering, and auto-sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:10:32 +03:00
80b48e3618 Add clone support for scene presets and update TODO
- Add clone_preset() to ScenePresetStore with deep copy of target snapshots
- Add POST /scene-presets/{id}/clone API endpoint
- Add clone button to scene preset cards in Automations tab
- Add i18n keys for clone feedback in all 3 locales
- Add TODO items for dashboard stats collapse and protocol badge review

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:17:09 +03:00
fddbd771f2 Replace auto-start with startup automation, add card colors to dashboard
- Add `startup` automation condition type that activates on server boot,
  replacing the per-target `auto_start` flag
- Remove `auto_start` field from targets, scene snapshots, and all API layers
- Remove auto-start UI section and star buttons from dashboard and target cards
- Remove `color` field from scene presets (backend, API, modal, frontend)
- Add card color support to scene preset cards (color picker + border style)
- Show localStorage-backed card colors on all dashboard cards (targets,
  automations, sync clocks, scene presets)
- Fix card color picker updating wrong card when duplicate data attributes
  exist by using closest() from picker wrapper instead of global querySelector
- Add sync clocks step to Sources tab tutorial
- Bump SW cache v9 → v10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 01:09:27 +03:00
f08117eb7b Add sync clock cards to dashboard and match FPS chart colors
Sync clocks now appear as compact cards on the dashboard with
pause/resume/reset controls and click-to-navigate. Dashboard FPS
sparkline charts use the same blue/green colors as target card charts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:26:29 +03:00
39e41dfce7 Remove per-source speed, fix device dirty check, and add frontend caching
Speed is now exclusively controlled via sync clocks — CSS sources no longer
carry their own speed/cycle_speed fields. Streams default to 1.0× when no
clock is assigned. Also fixes false-positive dirty check on the device
settings modal (array reference comparison) and converts several frontend
modules to use DataCache for consistent API response caching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:07:54 +03:00
aa1e4a6afc Add sync clock entity for synchronized animation timing
Introduces Synchronization Clocks — shared, controllable time bases
that CSS sources can optionally reference for synchronized animation.

Backend:
- New SyncClock dataclass, JSON store, Pydantic schemas, REST API
- Runtime clock with thread-safe pause/resume/reset and speed control
- Ref-counted runtime pool with eager creation for API control
- clock_id field on all ColorStripSource types
- Stream integration: clock time/speed replaces source-local values
- Paused clock skips rendering (saves CPU + stops frame pushes)
- Included in backup/restore via STORE_MAP

Frontend:
- Sync Clocks tab in Streams section with cards and controls
- Clock dropdown in CSS editor (hidden speed slider when clock set)
- Clock crosslink badge on CSS source cards (replaces speed badge)
- Targets tab uses DataCache for picture/audio sources and sync clocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:46:55 +03:00
52ee4bdeb6 Add OpenRGB per-zone LED control with separate/combined modes and zone preview
- Zone picker UI in device add/settings modals with per-zone checkbox selection
- Combined mode: pixels distributed sequentially across zones
- Separate mode: full effect resampled independently to each zone via linear interpolation
- Per-zone LED preview in target cards: one canvas strip per zone with hover overlay labels
- Zone badges on device cards enriched with actual LED counts from OpenRGB API
- Fix stale led_count by using device_led_count discovered at connect time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:35:51 +03:00
aafcf83896 Add webhook trigger condition for automations
Per-automation webhook URL with auto-generated 128-bit hex token.
External services (Home Assistant, IFTTT, curl) can POST to
/api/v1/webhooks/{token} with {"action": "activate"|"deactivate"}
to control automation state — no API key required (token is auth).

Backend: WebhookCondition model, engine state tracking with
immediate evaluation, webhook endpoint, schema/route updates.
Frontend: webhook option in condition editor, URL display with
copy button, card badge, i18n for en/ru/zh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:28:31 +03:00
01104acad1 Fix service worker caching root page without auth
Remove '/' from precache list (requires API key, caching it stores an
error page). Bump cache to v2 to purge stale caches. Replace offline
navigation fallback with a friendly retry page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:31:31 +03:00
6366b0b317 Fix mobile color picker popup clipping and locale update for tabs/sections
Color picker popover now uses fixed positioning on small screens to
escape the header toolbar overflow container. Section titles, sub-tab
labels, and filter placeholders use data-i18n attributes so they update
automatically on language change. Display picker title switches to
"Select a Device" for engine-owned display lists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:26:15 +03:00
ddfa7637d6 Speed up camera source modal with cached enumeration and instant open
Cache camera enumeration results for 30s and limit probe range using
WMI camera count on Windows. Open source modal instantly with a loading
spinner while dropdowns are populated asynchronously.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:35:26 +03:00
9ee6dcf94a Add PWA support and mobile responsive layout
- PWA manifest, service worker (stale-while-revalidate for static assets,
  network-only for API), and app icons for installability
- Root-scoped /manifest.json and /sw.js routes in FastAPI
- New mobile.css with responsive breakpoints at 768/600/400px:
  fixed bottom tab bar on phones, single-column cards, full-screen modals,
  compact header toolbar, touch-friendly targets
- Fix modal-content-wide min-width overflow on small screens
- Update README with Camera, OpenRGB, and PWA features

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:20:21 +03:00
8fe9c6489b Add camera/webcam capture engine with engine-aware display picker
- New CameraEngine using OpenCV VideoCapture for webcam capture
- HAS_OWN_DISPLAYS class attribute on CaptureEngine base to distinguish
  engines with their own device lists from desktop monitor engines
- Display picker renders device list for cameras/scrcpy, spatial layout
  for desktop monitors
- Engine-aware display label formatting (camera name vs monitor index)
- Stream modal properly loads engine-specific displays on template change,
  edit, and clone
- Camera backend config rendered as dropdown (auto/dshow/msmf/v4l2)
- Remove offline label from device cards (healthcheck indicator suffices)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:46:28 +03:00
b9ec509f56 Add OpenRGB device support for PC peripheral ambient lighting
New device type enabling control of keyboards, mice, RAM, GPU, and fans
via the OpenRGB SDK server (TCP port 6742). Includes auto-discovery,
health monitoring, state snapshot/restore, and fast synchronous pixel
send with brightness scaling. Also updates TODO.md with complexity notes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:30:02 +03:00
bf2fd5ca69 Add noise gate, palette quantization filters and drag-and-drop filter ordering
- Add noise gate filter: suppresses per-pixel color flicker below threshold
  using stateful frame comparison with pre-allocated int16 buffers
- Add palette quantization filter: maps pixels to nearest color in preset
  or custom hex palette, using chunked processing for memory efficiency
- Add "string" option type to filter schema system (base, API, frontend)
- Replace up/down buttons with pointer-event drag-and-drop in PP template
  filter list, with clone/placeholder feedback and modal auto-scroll
- Add frame_interpolation locale keys (was missing from all 3 locales)
- Update TODO.md: mark completed processing pipeline items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:58:02 +03:00
62b3d44e63 Add stop-all buttons to target sections, perf chart color reset, and TODO
- Add stop-all buttons to LED targets and KC targets section headers
  (visible only when targets are running, uses headerExtra on CardSection)
- Add reset ability to performance chart color pickers (removes custom
  color from localStorage and reverts to default)
- Remove CODEBASE_REVIEW.md
- Add prioritized TODO.md with P1/P2/P3 feature roadmap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:46:58 +03:00
90acae5207 Fix test endpoints reporting pre-filter image dimensions
- WebSocket test: move w,h capture to after PP filter application
  so downscaler effect is reflected in reported resolution
- HTTP test: read actual thumbnail dimensions from filtered image
  instead of using pre-computed values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:10:23 +03:00
ec58282c19 Eliminate tab reload animation after saving card properties
- CardSection._animateEntrance: skip after first render to prevent
  card fade-in replaying on every data refresh
- automations: use reconcile() on subsequent renders instead of full
  innerHTML replacement that destroyed and recreated all cards
- streams: same reconcile() approach for all 9 CardSections
- targets/dashboard/streams: only show setTabRefreshing loading bar
  on first render when the tab is empty

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:07:18 +03:00
cb779e10d3 Add running target indicator to command palette
Fetch /picture-targets/batch/states alongside entity data and show a
small green glowing dot next to targets that are currently processing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:49:26 +03:00
32a54b7d3c Fix language dropdown background on dark theme, add palette color indicators
- Change .header-locale background from transparent to var(--card-bg)
  to prevent white flash on dark theme when leaving the dropdown
- Show card color as border-left on command palette items when a
  custom color is assigned via the card color picker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:41:35 +03:00
6a7826e550 Fix tutorial spotlight behind sticky header and crosslink nav attributes
Tutorial: account for sticky header height in needsScroll check so
the spotlight doesn't render behind the header at narrow viewports.

Crosslinks: fix data attribute mismatches in two navigateToCard calls
— capture template used 'data-id' instead of 'data-template-id', and
PP template used 'data-id' instead of 'data-pp-template-id'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:15:40 +03:00
a89b3a25d0 Fix header toolbar wrapping at narrow widths
Merge the 900px and 768px breakpoints so the header switches to
vertical layout (column) at 900px instead of awkwardly wrapping
toolbar items into two rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:13:57 +03:00
493d96d604 Show backend error details in toast notifications
Use error.detail from API responses instead of generic i18n messages
so users see specific reasons for failures (e.g. "Device is referenced
by target(s): ...").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:11:24 +03:00
9b2ccde8a7 Add card color system with wrapCard helper and reset support
Introduce localStorage-backed card color assignment for all card types
with a reusable wrapCard() helper that provides consistent card shell
structure (top actions, bottom actions with color picker). Move color
picker from top-right to bottom-right action bar. Add color reset
button to clear card color back to default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:55:29 +03:00
fa81d6a608 Add WebSocket device type, capability-driven settings, hide filter on collapse
- New WS device type: broadcaster singleton + LEDClient that sends binary
  frames to connected WebSocket clients during processing
- FastAPI WS endpoint at /api/v1/devices/{device_id}/ws with token auth
- Frontend: add/edit WS devices, connection URL with copy button in settings
- Add health_check and auto_restore capabilities to WLED and Serial providers;
  hide health interval and auto-restore toggle for devices without them
- Skip health check loop for virtual devices (Mock, MQTT, WS) — set always-online
- Copy buttons and labels for API CSS push endpoints (REST POST / WebSocket)
- Hide mock:// and ws:// URLs in target device dropdown
- Hide filter textbox when card section is collapsed (cs-collapsed CSS class)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:55:09 +03:00
175a2c6c10 Fix SVG markup in select options, add missing name placeholders
Remove SVG icon function calls from <option> textContent — native
select elements render markup as literal text. Capture template options
now show "name (engine_type)", source options show just the name.

Add i18n placeholders to automation and scene editor name inputs.
Rename HAOS Scenes device from "{server_name} Scenes" to "Scenes".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:08:06 +03:00
252db09145 Add HAOS scene preset buttons and smooth tutorial scrolling
Expose scene presets as button entities in the HA integration under a
dedicated "Scenes" device. Each button activates its scene via the API.
The coordinator now fetches scene presets alongside other data, and the
integration reloads when the scene list changes.

Also animate tutorial autoscroll with smooth behavior and wait for
scrollend before positioning the spotlight overlay.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:53:47 +03:00
a34edf9650 Add reusable DataCache class, unify frontend cache patterns
- Create DataCache class with fetch deduplication, invalidation, subscribers
- Instantiate 10 cache instances in state.js (streams, templates, sources, etc.)
- Replace inline fetch+parse+set patterns with cache.fetch() calls across modules
- Eliminate dual _scenesCache/_presetsCache sync via shared scenePresetsCache
- Remove 9 now-unused setter functions from state.js
- Clean up unused setter imports from audio-sources, value-sources, displays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:35:20 +03:00
ff4e7f8adb Simplify scenes to capture only target state, add target selector
- Remove DeviceBrightnessSnapshot and AutomationSnapshot from scene data model
- Simplify capture_current_snapshot and apply_scene_state to targets only
- Remove device/automation dependencies from scene preset API routes
- Add target selector (combobox + add/remove) to scene capture modal
- Fix stale profiles reference bug in scene_preset_store recapture
- Update automation engine call sites for simplified scene functions
- Sync scene presets cache between automations and scene-presets modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:55:11 +03:00
0eb0f44ddb Remove all migration logic, scroll tutorial targets into view, mock URL uses device ID
- Remove legacy migration code: profiles→automations key fallbacks, segments array
  fallback, standby_interval compat, profile_id compat, wled→led type mapping,
  legacy calibration field, audio CSS migration, default template migration,
  loadTargets alias, wled sub-tab mapping
- Scroll tutorial step targets into view when off-screen
- Mock device URL changed from mock://{led_count} to mock://{device_id},
  hide mock URL badge on device cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:31:41 +03:00
39b31aec34 Move Scenes into Automations tab, smaller Capture button, scene crosslinks
- Merge Scenes tab into Automations tab as a second CardSection below automations
- Make dashboard Capture button match Stop All sizing
- Dashboard scene cards navigate to automations tab on click (crosslink)
- Add scene steps to automations tutorial
- Fix tour.tgt.devices to say "LED controllers" instead of "WLED controllers"
- Update command palette and navigation for new scene location

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:16:21 +03:00
21248e2dc9 Rename profiles to automations across backend and frontend
Rename the "profiles" entity to "automations" throughout the entire
codebase for clarity. Updates Python models, storage, API routes/schemas,
engine, frontend JS modules, HTML templates, CSS classes, i18n keys
(en/ru/zh), dashboard, tutorials, and command palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:01:39 +03:00
da3e53e1f1 Replace profile targets with scene activation and searchable scene selector
Profiles now activate scene presets instead of individual targets, with
configurable deactivation behavior (none/revert/fallback scene). The
target checklist UI is replaced by a searchable combobox for scene
selection that scales well with many scenes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:29:02 +03:00
2e747b5ece Add profile conditions, scene presets, MQTT integration, and Scenes tab
Feature 1 — Profile Conditions: time-of-day, system idle (Win32
GetLastInputInfo), and display state (GUID_CONSOLE_DISPLAY_STATE)
condition types for automatic profile activation.

Feature 2 — Scene Presets: snapshot/restore system that captures target
running states, device brightness, and profile enables. Server-side
capture with 5-step activation order. Dedicated Scenes tab with
CardSection-based card grid, command palette integration, and dashboard
quick-activate section.

Feature 3 — MQTT Integration: MQTTService singleton with aiomqtt,
MQTTLEDClient device provider for pixel output, MQTT profile condition
type with topic/payload matching, and frontend support for MQTT device
type and condition editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:57:42 +03:00
bd8d7a019f Codebase review: stability, performance, usability, and i18n fixes
Stability:
- Fix race condition: set _is_running before create_task in target processors
- Await probe task after cancel in wled_target_processor
- Replace raw fetch() with fetchWithAuth() across devices, kc-targets, pattern-templates
- Add try/catch to showTestTemplateModal in streams.js
- Wrap blocking I/O in asyncio.to_thread (picture_targets, system restore)
- Fix dashboardStopAll to filter only running targets with ok guard

Performance:
- Vectorize fire effect spark loop with numpy in effect_stream
- Vectorize FFT band binning with cumulative sum in analysis.py
- Rewrite pixel_processor with vectorized numpy (accept ndarray or list)
- Add httpx.AsyncClient connection pooling with lock in wled_provider
- Optimize _send_pixels_http to avoid np.hstack allocation in wled_client
- Mutate chart arrays in-place in dashboard, perf-charts, targets
- Merge dashboard 2-batch fetch into single Promise.all
- Hoist frame_time outside loop in mapped_stream

Usability:
- Fix health check interval load/save in device settings
- Swap confirm modal button classes (No=secondary, Yes=danger)
- Add aria-modal to audio/value source editors, fix close button aria-labels
- Add modal footer close button to settings modal
- Add dedicated calibration LED count validation error keys

i18n:
- Replace ~50 hardcoded English strings with t() calls across 12 JS files
- Add 50 new keys to en.json, ru.json, zh.json
- Localize inline toasts in index.html with window.t fallback
- Add data-i18n to command palette footer
- Add localization policy to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:12:37 +03:00
c95c6e9a44 Add Linux support: cross-platform restart, nvidia-ml-py dep, README update
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:47:05 +03:00
5f90336edd Update FPS chart colors dynamically when accent color changes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:00:01 +03:00
bd6c072adf Use contrast text color for tutorial buttons on accent backgrounds
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:50:40 +03:00
49c985e5c5 Filter audio devices by engine type and update tutorial steps
- Add enumerate_devices_by_engine() returning per-engine device lists
  without cross-engine dedup so frontend can filter correctly
- API /audio-devices now includes by_engine dict alongside flat list
- Frontend caches per-engine data, filters device dropdown by selected
  template's engine_type, refreshes on template change
- Reorder getting-started tutorial: add API docs and accent color steps
- Fix tutorial trigger button focus outline persisting on step 2
- Use accent color variable for tutorial pulse ring animation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:43:37 +03:00
efb05eba77 Use flat buttons and power icon for dashboard start/stop actions
- Replace btn-icon with transparent flat dashboard-action-btn style
- Use Lucide power icon instead of square for stop/turn-off buttons
- Add accent-tinted hover backgrounds for start (green) and stop (amber)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:17:00 +03:00
6a7ba3d0b7 Add CPU/GPU names on perf charts, reusable color picker, and header toolbar redesign
- Show CPU and GPU model names as overlays on performance chart cards
- Add cpu_name field to performance API with cross-platform detection
- Extract reusable color-picker popover module (9 presets + custom picker)
- Per-chart color customization for CPU/RAM/GPU performance charts
- Redesign header: compact toolbar container with icon-only buttons
- Compact language dropdown (EN/RU/ZH), icon-only login/logout
- Use accent color for FPS charts, range slider accent, dashboard icons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:12:13 +03:00
2bca119ad4 Auto-compute contrast text color for accent backgrounds
Add --primary-contrast CSS variable that auto-switches between white and
dark text based on accent color luminance (WCAG relative luminance).
Replace all hardcoded #fff/white on primary-color backgrounds with
var(--primary-contrast) so light accent colors like yellow remain readable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:22:45 +03:00
46a2ebf61e Add accent color to card title and badge icons, remove subtab separator
- Color SVG icons in card titles (.card-title, .template-name) with accent
- Color SVG icons in property badges (.stream-card-prop, .card-meta) with accent
- Revert badge icon to white on crosslink hover
- Remove border-bottom separator from subtab bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:19:38 +03:00
c262ec0775 Replace all emoji icons with Lucide SVGs, add accent color picker
- Replace all emoji characters across WebUI with inline Lucide SVG icons
  for cross-platform consistency (icon paths in icon-paths.js)
- Add accent color picker popover with 9 preset colors + custom picker,
  persisted to localStorage, updates all CSS custom properties
- Remove subtab separator line for cleaner look
- Color badge icons with accent color for visual pop
- Remove processing badge from target cards
- Fix hardcoded #4CAF50 in FPS labels and active badges to use CSS vars
- Replace CSS content emoji (▶) with pure CSS triangle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:14:18 +03:00
efb6cf7ce6 Add per-tab tutorials, profile expand/collapse, and fix card animation
- Add sub-tutorials for Dashboard, Targets, Sources, and Profiles tabs
  with ? trigger buttons, en/ru/zh translations, and hidden-ancestor
  skip via offsetParent check
- Add expand/collapse all buttons to Profiles tab toolbar
- Move dashboard poll slider from section header to toolbar
- Fix cardEnter animation forcing opacity:1 on disabled profile cards
- Use data-card-section selectors instead of data-cs-toggle to avoid
  z-index misalignment during tutorial spotlight
- Add tutorial sync convention to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:15:41 +03:00
111bfe743a Add interactive getting-started tutorial for first-time users
Auto-starts on first visit with a 9-step walkthrough covering header,
tabs, settings, search, theme, and language controls. Stores completion
in localStorage; restart via ? button in the header. Includes en/ru/zh
translations for all tour steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:45:43 +03:00
f6977105b8 Fix card-enter animation re-trigger and drag hover suppression
Remove card-enter class after entrance animation completes to prevent
re-triggering when card-highlight is removed. Change fill-mode from
both to backwards so stale transforms don't block hover effects.
Suppress hover globally during drag via body.cs-drag-active class.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 01:10:09 +03:00
9194b978e0 Add dashboard crosslinks and card drag-and-drop reordering
Dashboard cards (targets, auto-start, profiles) are now clickable,
navigating to the full entity card on the appropriate tab. Card
sections support drag-and-drop reordering via grip handles with
localStorage persistence. Fix crosslink navigation scoping to avoid
matching dashboard cards, and fix highlight race on rapid clicks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:40:37 +03:00
88abd31c1c Add smooth animations across WebUI for modern feel
- Tab panels: fade-in with subtle translateY on switch
- Cards: hover lift (translateY -2px), staggered entrance animation
- Modals: spring-curve entrance with backdrop blur
- Buttons: press feedback (scale down on :active)
- Toggle switches: spring overshoot on knob transition
- Toast: smooth bounce-in replaces jarring shake
- Sections: animated height collapse/expand with chevron rotation
- Command palette: slide-down entrance animation
- Theme switch: smooth color transitions on key elements
- Dashboard: section collapse animation, target row hover
- Respects prefers-reduced-motion globally

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:03:47 +03:00
d33d70cfe8 Fix keepalive not sent during zero-brightness suppression
When brightness source (e.g. Audio Volume) returned 0 during silence,
the zero-brightness suppression path skipped all frames without sending
DDP keepalive packets, causing WLED to exit live mode after ~2.5s.
Now sends periodic keepalive even when suppressing zero-brightness frames.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:45:26 +03:00
fccf50c62a Pre-allocate PixelMapper buffers to eliminate GC-induced map_leds spikes
Reduces map_leds_ms timing spikes from 4ms to ~1.5ms by eliminating
~540KB/frame of numpy temporary allocations:

- Pre-allocate _led_buf (reused instead of np.zeros per call)
- Pre-compute offset-adjusted segment indices (eliminates np.roll copy)
- Lazy-cache per-edge cumsum and mean buffers with np.mean/cumsum out=
- Pre-compute Phase 3 skip resampling arrays in __init__

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:37:40 +03:00
6f5bda6d8f Optimize processing pipeline and fix multi-target crash
Performance optimizations across 5 phases:
- Saturation filter: float32 → int32 integer math (~2-3x faster)
- Frame interpolation: pre-allocated uint16 scratch buffers
- Color correction: single-pass cv2.LUT instead of 3 channel lookups
- DDP: numpy vectorized color reorder + pre-allocated RGBW buffer
- Calibration boundaries: vectorized with np.arange + np.maximum
- wled_client: vectorized pixel validation and HTTP pixel list
- _fit_to_device: cached linspace arrays (now per-instance)
- Diagnostic lists: bounded deque(maxlen=...) instead of unbounded list
- Health checks: adaptive intervals (10s streaming, 60s idle)
- Profile engine: poll interval 3s → 1s

Bug fixes:
- Fix deque slicing crash killing targets when multiple run in parallel
  (deque doesn't support [-1:] or [:5] slice syntax unlike list)
- Fix numpy array boolean ambiguity in send_pixels() validation
- Persist fatal processing loop errors to metrics for API visibility
- Move _fit_to_device cache from class-level to instance-level to
  prevent cross-target cache thrashing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:28:17 +03:00
fda040ae18 Add per-target protocol selection (DDP/HTTP) and reorganize target editor
- Add protocol field (ddp/http) to storage, API schemas, routes, processor
- WledTargetProcessor passes protocol to create_led_client(use_ddp=...)
- Target editor: protocol dropdown + keepalive in collapsible Specific Settings
- FPS, brightness threshold, adaptive FPS moved to main form area
- Hide Specific Settings section for serial devices (protocol is WLED-only)
- Card badge: show DDP/HTTP for WLED devices, Serial for serial devices

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:52:03 +03:00
cadef971e7 Add adaptive FPS and honest device reachability during streaming
DDP uses fire-and-forget UDP, so when a WiFi device becomes overwhelmed
by sustained traffic, sends appear successful while the device is
actually unreachable. This adds:

- HTTP liveness probe (GET /json/info, 2s timeout) every 10s during
  streaming, exposed as device_streaming_reachable in target state
- Adaptive FPS (opt-in): exponential backoff when device is unreachable,
  gradual recovery when it stabilizes — finds sustainable send rate
- Honest health checks: removed the lie that forced device_online=true
  during streaming; now runs actual health checks regardless
- Target editor toggle, FPS display shows effective rate when throttled,
  health dot reflects streaming reachability, red highlight when
  unreachable
- Auto-backup scheduling support in settings modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:22:58 +03:00
f8656b72a6 Add configuration backup/restore with settings modal
Backend: GET /api/v1/system/backup bundles all 11 store JSON files into a
single downloadable backup with metadata envelope. POST /api/v1/system/restore
validates and writes stores atomically, then schedules a delayed server restart
via detached restart.ps1 subprocess.

Frontend: Settings modal (gear button in header) with Download Backup and
Restore from Backup buttons. Restore shows confirm dialog, uploads via
multipart FormData, then displays fullscreen restart overlay that polls
/health until the server comes back and reloads the page.

Locales: en, ru, zh translations for all settings.* keys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:23:18 +03:00
9cfe628cc5 Codebase review fixes: stability, performance, quality improvements
Stability: Add outer try/except/finally with _running=False cleanup to all 6
processing loop methods (live, color_strip, effect, audio, composite, mapped).
Add exponential backoff on consecutive capture errors in live_stream. Move
audio stream.stop() outside lock scope.

Performance: Replace per-pixel Python loop with np.array().tobytes() in
ddp_client. Vectorize pixelate filter with cv2.resize down+up. Vectorize
gradient rendering with np.searchsorted.

Frontend: Add lockBody/unlockBody re-entrancy counter. Add {once:true} to
fetchWithAuth abort listener. Null ws.onclose before ws.close() in LED preview.

Backend: Remove auth token prefix from log messages. Add atomic_write_json
helper (tempfile + os.replace) and update all 10 stores. Add name uniqueness
checks to all update methods. Fix DELETE status codes to 204 in audio_sources
and value_sources. Fix get_source() silent bug in color_strip_sources.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:23:04 +03:00
bafd8b4130 Add value source card crosslinks and fix scene initial value bias
- Audio value source cards link to the referenced audio source
- Adaptive scene cards link to the referenced picture source
- Fix SceneValueStream starting at 0.5 regardless of actual scene;
  first frame now skips smoothing to avoid artificial bias
- Add crosslinks guidance to CLAUDE.md card appearance section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:56:26 +03:00
dac0c2d418 Hide immutable type field in color strip source edit modal
Type is set at creation and cannot be changed, so hide the selector
when editing (same pattern already used in value source editor).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:45:51 +03:00
8fa89903e9 Add mono audio source crosslink and missing animation locale key
- Make parent audio source badge on mono cards a clickable crosslink
  that navigates to the multichannel source card
- Add missing color_strip.animation.type.none.desc locale key in
  en/ru/zh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:42:59 +03:00
b8bfdac36b Add live preview streaming for capture tests via WebSocket
Replace blocking REST-based capture tests with WebSocket endpoints that
stream intermediate frame thumbnails at ~100ms intervals, giving real-time
visual feedback during capture. Preview resolution adapts dynamically to
the client viewport size and device pixel ratio.

- New shared helper (_test_helpers.py) with engine_factory pattern to
  avoid MSS thread-affinity issues
- WS endpoints for stream, capture template, and PP template tests
- Enhanced overlay spinner with live preview image and stats
- Frontend _runTestViaWS shared helper replaces three REST test runners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:34:30 +03:00
3c35bf0c49 Hide immutable type field in value source edit modal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:03:35 +03:00
88b3ecd5e1 Add value source test modal, auto-gain, brightness always-show, shared value streams
- Add real-time value source test: WebSocket endpoint streams get_value() at
  ~20Hz, frontend renders scrolling time-series chart with min/max/current stats
- Add auto-gain for audio value sources: rolling peak normalization with slow
  decay, sensitivity range increased to 0.1-20.0
- Always show brightness overlay on LED preview when brightness source is set
- Refactor ValueStreamManager to shared ref-counted streams (value streams
  produce scalars, not LED-count-dependent, so sharing is correct)
- Simplify acquire/release API: remove consumer_id parameter since streams
  are no longer consumer-dependent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:48:45 +03:00
a164abe774 Add min brightness threshold to LED targets
New per-target property: when effective output brightness
(max pixel value × device/source brightness) falls below
the threshold, LEDs turn off completely. Useful for cutting
dim flicker in audio-reactive and ambient setups.

Threshold slider (0–254) in target editor, badge on card,
hot-swap to running processors, persisted in storage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:03:53 +03:00
c2deef214e Add brightness overlay and enlarge LED preview on target cards
Show effective brightness percentage on the LED preview when a
value source dims below 100%. Prepend a brightness byte to the
preview WebSocket wire format. Increase preview height to 32px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:44:03 +03:00
a0c9cb0039 Add Chinese locale, fix audio device duplicates, remove display lock restriction
- Add Simplified Chinese (中文) locale with all 906 translation keys
- Fix duplicate audio devices: WASAPI two-pass enumerate avoids double-listing
  loopback endpoints; cross-engine dedup by (name, is_loopback) prefers
  higher-priority engine
- Remove redundant display lock check from capture template, picture source,
  and postprocessing template test endpoints — screen capture is read-only
  and concurrent access is safe

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:34:24 +03:00
147ef3b4eb Add real-time audio spectrum test for audio sources and templates
- Add WebSocket endpoints for live audio spectrum streaming at ~20Hz
- Audio source test: resolves device/channel, shares stream via ref-counting
- Audio template test: includes device picker dropdown for selecting input
- Canvas-based 64-band spectrum visualizer with falling peaks and beat flash
- Channel-aware: mono sources show left/right/mixed spectrum correctly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:19:41 +03:00
4806f5020c Hide audio source type selector — type is determined by add button context
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:57:41 +03:00
bae2166bc2 Add audio capture engine template system with multi-backend support
Introduces an engine+template abstraction for audio capture, mirroring the
existing screen capture engine pattern. This enables multiple audio backends
(WASAPI for Windows, sounddevice for cross-platform) with per-source
engine configuration via reusable templates.

Backend:
- AudioCaptureEngine ABC with WasapiEngine and SounddeviceEngine implementations
- AudioEngineRegistry for engine discovery and factory creation
- AudioAnalyzer class decouples FFT/RMS/beat analysis from engine-specific capture
- ManagedAudioStream wraps engine stream + analyzer in background thread
- AudioCaptureTemplate model and AudioTemplateStore with JSON CRUD
- AudioCaptureManager keyed by (engine_type, device_index, is_loopback)
- Auto-migration: default template created on startup, assigned to existing sources
- Full REST API: CRUD for audio templates + engine listing with availability flags
- audio_template_id added to MultichannelAudioSource model and API schemas

Frontend:
- Audio template cards in Streams > Audio tab with engine badge and config details
- Audio template editor modal with engine selector and dynamic config fields
- Audio template dropdown in multichannel audio source editor
- Template name crosslink badge on multichannel audio source cards
- Confirm modal z-index fix (always stacks above editor modals)
- i18n keys for EN and RU

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:55:46 +03:00
cbbaa852ed Fix target metrics showing --- by scoping querySelector to targets panel
The dashboard panel appears before the targets panel in the DOM and both
use data-target-id. document.querySelector was finding the dashboard
element (which has no data-tm children) instead of the target card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 00:49:44 +03:00
3bfa9062f9 Add autostart toggle button to dashboard target items
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 00:43:13 +03:00
f0e8f0ef33 Add crosslink navigation for picture source and audio source on CSS cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 00:21:32 +03:00
1dc43f1259 Show both fps_current and fps_actual in WebUI charts and labels
- Charts: blue filled area for fps_actual (rolling avg), green line for
  fps_current (real-time sends/sec)
- Labels: fps_current/fps_target as primary, avg fps_actual as secondary
- Track fps_current in metrics history for dashboard chart preload
- Applied to both LED targets page and dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 00:08:32 +03:00
847ac38d8a Replace HAOS light entity with select entities, add zero-brightness optimization
- Remove light.py platform (static color control via HA light entity)
- Add select.py with CSS Source and Brightness Source dropdowns for LED targets
- Coordinator now fetches color-strip-sources and value-sources lists
- Add generic update_target() method for partial target updates
- Clean up stale device registry entries on integration reload
- Skip frame sends when effective brightness is ~0 (suppresses unnecessary
  UDP/HTTP traffic while LEDs are dark)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:33:25 +03:00
7a4d7149a6 Debounce tab refresh indicator to prevent green line flash
The refreshing bar was briefly visible during quick tab switches and
auto-refreshes. Delay adding the .refreshing class by 400ms so loads
that complete quickly never show the bar at all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:21:37 +03:00
f507a6cf11 Fix gamma correction, frame interpolation flicker, and target card redraws
- Fix inverted gamma formula: use (i/255)^gamma instead of (i/255)^(1/gamma)
  so gamma>1 correctly darkens midtones (standard LED gamma correction)
- Fix frame interpolation flicker: move interp buffer update after temporal
  smoothing so idle-tick output is consistent with new-frame output
- Fix target card hover/animation reset: use stable placeholder values in
  card HTML for volatile metrics (data-tm attributes), patch real values
  in-place after reconcile instead of replacing entire card DOM element

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:35:05 +03:00
7f80faf8be Frontend polish: loading states, CSS variables, focus indicators, scroll lock
- Add tab refresh loading bar animation for all 4 tab loaders
- Add profiles loading guard to prevent concurrent fetches
- Centralize theme colors into CSS variables (--text-secondary, --text-muted,
  --bg-secondary, --success-color, --shadow-color) for both dark/light themes
- Replace hardcoded gray values across 10 CSS files with variables
- Fix duplicate .btn-sm definition in modal.css
- Fix z-index: toast 2001→2500 to safely clear modals at 2000
- Add :focus-visible keyboard navigation indicators for all interactive elements
- Add responsive breakpoints for tab bar and header on narrow screens
- Prevent background page scroll when command palette is open

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:09:42 +03:00
82e12ffaac Fix critical frontend issues: race conditions, memory leaks, silent failures
- Add loading guard to loadPictureSources to prevent concurrent fetches
- Pause perf chart polling and uptime timer when browser tab is hidden
- Disconnect KC and LED preview WebSockets when leaving targets tab
- Add error toasts to loadCaptureTemplates and saveKCBrightness
- Skip auto-refresh polling when document is hidden
- Widen auto-start dashboard cards for better text display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:29:47 +03:00
b51839ef3c Centralize icon resolution into core/icons.js, fix auto-start row alignment
- Create core/icons.js with type-resolution getters and icon constants
- Replace inline emoji literals across 11 feature files with imports
- Remove duplicate icon maps (getEngineIcon, _vsTypeIcons, typeIcons, etc.)
- Fix dashboard auto-start row missing metrics placeholder div

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:28:01 +03:00
d05b4b78f4 Add auto-start targets feature with dashboard section
- Add auto_start boolean field to PictureTarget model (persisted per-target)
- Wire auto_start through API schemas, routes, and store
- Auto-start targets on server boot in main.py lifespan
- Add star toggle button on target cards (next to delete button)
- Add auto-start section on dashboard between performance and profiles
- Remove auto-start section from profiles tab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:08:01 +03:00
701eac19e5 Add "Always" condition type to profiles
- Add AlwaysCondition model and evaluation (always returns true)
- Add condition type selector (Always/Application) in profile editor
- Show condition type pill on profile cards
- Fix misleading empty-conditions text (was "never activate", actually always active)
- Add i18n keys for Always condition (en + ru)
- Add CSS for condition type selector and description

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 14:38:25 +03:00
466527bd4a Add clone buttons, fix card navigation highlight, UI polish
- Add clone buttons to Audio Source and Value Source cards
- Fix command palette navigation destroying card highlight by skipping
  redundant data reload (skipLoad option on switchTab)
- Convert value source modal sliders to value-in-label pattern
- Change audio/value source modal footers to icon-only buttons
- Remove separator lines between card sections
- Add UI conventions to CLAUDE.md (card appearance, modal footer, sliders)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 03:10:36 +03:00
2b6bc22fc8 Sticky header, dim overlay on card navigation, fix sticky stacking
Make header sticky so search button stays visible on scroll. Section
headers stick below it using a JS-measured --header-height variable.
Add dim overlay behind highlighted cards for better focus effect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:55:23 +03:00
83800e71fa Fix mock device RGBW badge, add icons to audio/value source card badges
Fall back to stored device rgbw field when health check doesn't report
it (mock devices have no hardware to query). Add emoji icons to all
property badges on audio source and value source cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:49:47 +03:00
7b07f38ce5 Fix command palette selection mismatch and card highlight z-index
Sort filtered items by group order so display indices match the array,
preventing wrong entity selection. Raise card-highlight z-index above
sticky section headers so the outline isn't clipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:45:41 +03:00
8bf40573f1 Add search button in header for touchscreen command palette access
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:42:14 +03:00
f67936c977 Add WebUI navigation improvements: keyboard shortcuts, hash routing, command palette, cross-entity links
- Keyboard shortcuts: Ctrl+1-4 for tab switching
- URL hash routing: #tab/subtab format with browser back/forward support
- Tab count badges: running targets and active profiles counts
- Cross-entity quick links: clickable references navigate to related cards
- Command palette (Ctrl+K): global search across all entities with keyboard navigation
- Expand/collapse all sections: buttons in sub-tab bars
- Sticky section headers: headers pin while scrolling long card grids
- Improved section filter: better styling with reset button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:40:24 +03:00
a82eec7a06 Enhance card section filter: multi-term OR/AND, filtered count badge
Space-separated terms use OR logic, comma-separated use AND.
Count badge shows visible/total when filter is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:06:21 +03:00
d4a7c81296 Search all card text content in section filter, not just title
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 01:46:13 +03:00
e0e744095e Unify value source icons across dropdowns and card badges
Extract getValueSourceIcon() into value-sources.js and use it for
brightness source dropdowns and card badges in both LED and KC targets.
Icons now match value source cards: 📊 static, 🔄 animated, 🎵 audio,
🕐 adaptive_time, 🌤️ adaptive_scene.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 01:41:13 +03:00
16f29bee30 Fix nonlocal scoping in CSS processing, move brightness source emoji to dropdown items
Add nonlocal declarations for _u16_a, _u16_b, _i32 in nested functions
_blend_u16 and _apply_corrections — Python treats augmented assignments
(*=, +=, >>=) as local variable bindings, causing UnboundLocalError
that prevented any frames from being sent to devices.

Move 🔢 emoji from brightness source label to dropdown option items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 01:34:35 +03:00
359f33fdbb Fix filter_template expansion in test routes and select defaults
filter_template references were silently ignored in PP template test,
picture source test, and KC target test routes — they created a no-op
FilterTemplateFilter instead of expanding into the referenced template's
filters. Centralized expansion logic into PostprocessingTemplateStore.
resolve_filter_instances() and use it in all test routes + live stream
manager.

Also fixed empty template_id when adding filter_template filters: the
select dropdown showed the first template visually but onchange never
fired, saving "" instead. Now initializes with first choice's value and
auto-corrects stale/empty values at render time.

Other fixes: ScreenCapture dimensions now use actual image shape after
filter processing; brightness source label emoji updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 01:27:46 +03:00
68ce394ccc Move overlay toggle into calibration visual editor, add tutorial step
Place the overlay button inside the preview screen as a pill toggle,
add it as a tutorial step that auto-skips in device calibration mode.
Tutorial engine now skips hidden/missing targets in both directions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:50:39 +03:00
f2f67493b1 Fix LED overlay tick positions and reverse handling
Use i/(count-1) fraction (matching calibration dialog) so LEDs span
the full edge, and apply seg.reverse flag for correct numbering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:50:33 +03:00
04ee2e5830 Optimize audio capture and render loop performance
audio_capture.py:
- Move _fft_bands from inner function to method (avoid per-frame closure)
- Pre-allocate channel split buffers and RMS scratch arrays
- Use in-place numpy ops (np.copyto, np.multiply) instead of copies
- In-place FFT smoothing instead of temp array allocation
- Cache loop-invariant values as locals
- Fix energy index to wrap-around instead of unbounded increment

audio_stream.py:
- Pre-compute interpolation arrays (band_x, led_x, full_amp, indices_buf,
  vu_gradient) once on LED count change instead of every frame
- Pre-compute VU meter base/peak float arrays in _update_from_source
- Reuse full_amp and indices_buf buffers across frames
- In-place spectrum smoothing to avoid temp allocations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:36:51 +03:00
0cd8304004 Resend frames when dynamic brightness changes on static CSS
The processing loop skipped resending when the CSS frame was
unchanged (static source). With a dynamic brightness value source
(e.g. audio), brightness can change every frame while colors stay
the same. Now compare effective brightness against previous value
and resend when it differs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:45:44 +03:00
468cfa2022 Add brightness source badge to target cards, clean up FPS badge
Show brightness value source name on LED and KC target cards when
configured. Remove redundant 'fps' text from FPS badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:23 +03:00
d45e59b0e6 Add min/max value range to audio value sources
Add min_value and max_value fields to AudioValueSource so audio
brightness can be mapped to a configurable range (e.g. silence =
30% brightness floor instead of fully black).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:49 +03:00
f96cd5f367 Allow multichannel audio sources as direct CSS and value source input
Add resolve_audio_source() that accepts both MultichannelAudioSource
(defaults to mono mix) and MonoAudioSource. Update CSS and brightness
value source dropdowns to show all audio sources with type badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:42 +03:00
a5d855f469 Fix provider kwargs leak for mock device fields
Pop send_latency_ms and rgbw from kwargs in WLED, Adalight, and
AmbiLED providers so mock-only fields don't leak through to
non-mock client constructors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:36 +03:00
34d9495eb3 Add audio capture timing metrics to target pipeline
Instrument AudioCaptureStream with read/FFT timing and
AudioColorStripStream with render timing. Display audio-specific
timing segments (read/fft/render/send) in the target card
breakdown bar when an audio source is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:29 +03:00
a39dc1b06a Add mock LED device type for testing without hardware
Virtual device with configurable LED count, RGB/RGBW mode, and simulated
send latency. Includes full provider/client implementation, API schema
support, and frontend add/settings modal integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:22:53 +03:00
dc12452bcd Fix section toggle firing on filter input drag
Changed header collapse from click to mousedown so dragging from the
filter input to outside no longer triggers a toggle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:22:45 +03:00
0b89731d0c Add palette type badge to audio color strip source cards
Shows palette name for spectrum and beat_pulse visualization modes,
matching the existing pattern on effect cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:22:40 +03:00
858a8e3ac2 Rework root README to reflect current project state
Rewritten from scratch as "LED Grab" with organized feature sections,
full architecture tree, actual config examples, and comprehensive API listing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:22:34 +03:00
e4c4301a7b Add dirty check to all remaining editor modals
Subclass Modal with snapshotValues() for: value source editor, audio
source editor, add device, profile editor, capture template, stream
editor, and PP template modals. Close/cancel now triggers discard
confirmation when form has unsaved changes. Document the convention
in CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:12:30 +03:00
053a56eed3 Add live LED strip preview via WebSocket on target cards
Stream real-time LED colors from running WLED targets to the browser via
binary WebSocket (RGB bytes, throttled to ~15 fps). Toggle button on
target card opens a compact canvas strip that renders each frame using
ImageData. Cached last frame is re-rendered after card reconciliation to
prevent flicker during auto-refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:47:40 +03:00
a6253e8d96 Add overlay toggle to calibration dialog, fix serial reconnect on edge test
Add a 💡 button in the calibration modal header (CSS mode only) that
toggles the LED overlay visualization. Auto-stops overlay on modal close
if started from the dialog. Checks and reflects current overlay status
on modal open.

Fix serial devices creating a new connection on every edge test toggle,
which triggered Arduino bootloader resets. Now reuses the cached idle
client for all device types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:22:15 +03:00
67a15776b2 Add API Input color strip source type with REST and WebSocket push
New source_type "api_input" allows external clients to push raw LED
color arrays ([R,G,B] per LED) via REST POST or WebSocket. Includes
configurable fallback color and timeout for automatic revert when no
data is received. Stream auto-sizes LED count from the target device.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:07:47 +03:00
1e4a7a067f Split adaptive value source into explicit adaptive_time and adaptive_scene types
Replace single "adaptive" type with adaptive_mode sub-selector by two
distinct source types in the dropdown. Removes the adaptive_mode field
entirely — the source_type itself carries the mode. Clearer UX with
"Adaptive (Time of Day)" and "Adaptive (Scene)" as separate options.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:23:50 +03:00
d339dd3f90 Add adaptive brightness value source with time-of-day and scene modes
New "adaptive" value source type that automatically adjusts brightness
based on external conditions. Two sub-modes: time-of-day (schedule-based
interpolation with midnight wrap) and scene brightness (frame luminance
analysis via numpy BT.601 subsampling with EMA smoothing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:14:30 +03:00
48651f0a4e Show uptime in target cards, fix dashboard uptime stale after tab switch
Add uptime metric to both LED and KC target cards in the targets tab.
Move formatUptime() to shared ui.js module. Fix dashboard uptime freezing
when switching tabs by re-caching DOM element refs on early return paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:36:14 +03:00
425deb9570 Add server-side metrics ring buffer, seed dashboard charts from server history
Background task samples system (CPU/RAM/GPU) and per-target (FPS/timing) metrics
every 1s into a 120-sample ring buffer (~2 min). New API endpoint
GET /system/metrics-history returns the buffer. Dashboard charts now seed from
server history on load instead of sessionStorage, surviving page refreshes.

Also removes emoji from brightness source labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:21:37 +03:00
8f79b77fe4 Add dynamic brightness value source support for KC targets, fix subtab selector collision
Extend value source brightness modulation to Key Colors targets (matching LED target support).
Also fix stream subtab CSS selector collision that broke target subtab selection, and use 🔢 emoji
for value source UI elements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:42:00 +03:00
ef474fe275 Add value sources for dynamic brightness control on LED targets
Introduces a new Value Source entity that produces a scalar float (0.0-1.0)
for dynamic brightness modulation. Three subtypes: Static (constant),
Animated (sine/triangle/square/sawtooth waveform), and Audio-reactive
(RMS/peak/beat from mono audio source). Value sources can be optionally
attached to LED targets to control brightness each frame.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:19:40 +03:00
27720e51aa Add incremental card reconciliation to prevent full DOM rebuild on auto-refresh
CardSection now diffs cards by key attributes instead of rebuilding innerHTML,
preserving DOM elements, filter input focus, scroll position, and Chart.js
instances across the 2s targets tab auto-refresh cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:58:38 +03:00
166ec351b1 Add collapsible card sections with name filtering
Introduces CardSection class that wraps each card grid with a collapsible
header and inline filter input. Collapse state persists in localStorage,
filter value survives auto-refresh re-renders. When filter is active the
add-card button is hidden. Applied to all 13 sections across Targets,
Sources, and Profiles tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:46:14 +03:00
808037775f Remove target segments, use single color strip source per target
Segments are redundant now that the "mapped" CSS type handles spatial
multiplexing internally. Each target now references one color_strip_source_id
instead of an array of segments with start/end/reverse ranges.

Backward compat: existing targets with old segments format are migrated
on load by extracting the first segment's CSS source ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:00:26 +03:00
9efb08acb6 Add audio sources as first-class entities, add mapped CSS type, simplify target editor for mapped sources
- Audio sources moved to separate tab with dedicated CRUD API, store, and editor modal
- New "mapped" color strip source type: assigns different CSS sources to distinct LED sub-ranges (zones)
- Mapped stream runtime with per-zone sub-streams, auto-sizing, hot-update support
- Target editor auto-collapses segments UI when mapped CSS is selected
- Delete protection for CSS sources referenced by mapped zones
- Compact header/footer layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:35:58 +03:00
199039326b Add ADB-based Android screen capture engine with display picker integration
New scrcpy/ADB capture engine that captures Android device screens over
ADB using screencap polling. Supports USB and WiFi ADB connections with
device auto-discovery. Engine-aware display picker shows Android devices
when scrcpy engine is selected, with inline ADB connect form for WiFi
devices.

Key changes:
- New scrcpy_engine.py using adb screencap polling (~1-2 FPS over WiFi)
- Engine-aware GET /config/displays?engine_type= API
- ADB connect/disconnect API endpoints (POST /adb/connect, /adb/disconnect)
- Display picker supports engine-specific device lists
- Stream/test modals pass engine type to display picker
- Test template handler changed to sync def to prevent event loop blocking
- Restart script merges registry PATH for newly-installed tools
- All engines (including unavailable) shown in engine list with status flag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 18:06:15 +03:00
cc08bb1c19 Add clone support for all entity types
Clone button on every card opens the editor in create mode pre-filled
with copied data and a "(Copy)" name suffix. Cancelling discards the
clone — entity is only persisted on Save.

Supported: LED targets, color strip sources, KC targets, pattern
templates, picture sources, capture templates, PP templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:38:40 +03:00
f15ff8fea0 Add audio channel selection (mono/left/right), show device LED count in target editor
Audio capture now produces per-channel FFT spectrum and RMS alongside
the existing mono mix. Each audio color strip source can select which
channel to visualize via a new "Channel" dropdown. This enables stereo
setups with separate left/right segments on the same LED strip.

Also shows the device LED count under the device selector in the target
editor for quick reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:05:15 +03:00
9d593379b8 Add multi-segment LED targets, replace single color strip source + skip fields
Each target now has a segments list where each segment maps a color strip
source to a pixel range (start/end) on the device with optional reverse.
This enables composing multiple visualizations on a single LED strip.
Old targets auto-migrate from the single source format on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:49:26 +03:00
bbd2ac9910 Add audio-reactive color strip sources, improve delete error messages
Add new "audio" color strip source type with three visualization modes
(spectrum analyzer, beat pulse, VU meter) supporting WASAPI loopback and
microphone input via PyAudioWPatch. Includes shared audio capture with
ref counting, real-time FFT spectrum analysis, and beat detection.

Improve all referential integrity 409 error messages across delete
endpoints to include specific names of referencing entities instead of
generic "one or more" messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:56:54 +03:00
2657f46e5d Add composite color strip source type with layer blending
Composite sources stack multiple existing color strip sources as layers
with configurable blend modes (Normal, Add, Multiply, Screen) and per-layer
opacity. Includes full CRUD, hot-reload, delete protection for referenced
layers, and pre-allocated integer blend math at 30 FPS.

Also eliminates per-frame numpy allocations in color_strip_stream,
effect_stream, and wled_target_processor (buffer pre-allocation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:01:44 +03:00
e5a6eafd09 Make count-dependent streams non-sharable, each target gets own instance
Static, gradient, color cycle, and effect streams depend on LED count
and were being reconfigured per-consumer when shared. Now only picture
streams (expensive capture) are shared; count-dependent sources get
per-consumer instances keyed by css_id:target_id.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 02:31:08 +03:00
e32bfab888 Add LED skip start/end, rename standby_interval to keepalive_interval, remove migrations
LED skip: set first N and last M LEDs to black on a target. Color sources
(static, gradient, effect, color cycle) render across only the active
(non-skipped) LEDs. Processor pads with blacks before sending to device.

Rename standby_interval → keepalive_interval across all Python, API
schemas, and JS. from_dict falls back to old key for existing configs.

Remove legacy migration functions (_migrate_devices_to_targets,
_migrate_targets_to_color_strips) and legacy fields from target model.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 02:15:29 +03:00
f9a5fb68ed Add effect palette preview bar in CSS editor
Show a gradient color bar below the effect type description, giving
users a visual preview of palette colors before applying. Updates
live when switching effect type, palette, or meteor head color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:59:25 +03:00
9e555cef2e Add composable filter templates, skip keepalive for serial devices
Filter Template meta-filter: reference existing PP templates inside others
for composable, DRY filter chains. Filters are recursively expanded at
pipeline build time with cycle detection. New `select` option type with
dynamic choices populated by the API.

Keepalive optimization: serial devices (Adalight, AmbiLED) don't need
keepalive — they hold last frame indefinitely. Check `standby_required`
capability at processor start, skip keepalive sends for serial targets,
and hide keepalive metrics in the UI. Rename "Standby Interval" to
"Keep Alive Interval" throughout the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:48:23 +03:00
a4083764fb Add 5 procedural LED effects, gradient presets, auto-crop min aspect ratio, static source polling optimization
New features:
- Procedural effect source type with fire, meteor, plasma, noise, and aurora algorithms
  using palette LUT system and 1D value noise generator
- 12 predefined gradient presets (rainbow, sunset, ocean, forest, fire, lava, aurora,
  ice, warm, cool, neon, pastel) selectable from a dropdown in the gradient editor
- Auto-crop filter: min aspect ratio parameter to prevent false-positive cropping
  in dark scenes on ultrawide displays

Optimization:
- Static/gradient sources without animation: stream thread sleeps 0.25s instead of
  frame_time; processor repolls at frame_time instead of 5ms (~40x fewer iterations)
- Inverted isinstance checks in routes to test for PictureColorStripSource only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:03:16 +03:00
9392741f08 Batch API endpoints, reduce frontend polling by ~75%, fix resource leaks
Backend: add batch endpoints for target states, metrics, and device
health to replace O(N) individual API calls per poll cycle.
Frontend: use batch endpoints in dashboard/targets/profiles tabs,
fix Chart.js instance leaks, debounce server event reloads, add
i18n active-tab guards, clean up ResizeObserver on pattern editor
close, cache uptime timer DOM refs, increase KC auto-refresh to 2s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:55:09 +03:00
d4a0f3a7f5 Add max HW FPS line on sparkline chart, fix button click race with polling
- Draw dashed orange line on target FPS sparkline showing hardware max FPS
- Prevent loadTargetsTab polling from rebuilding DOM while a button action
  (start/stop/overlay/delete) is in flight; add reentry guard on the
  refresh function itself

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:35:31 +03:00
1d5f542603 Show max FPS hint in target editor, fix gradient sharing for multi-target
- Add dynamic "Hardware max ≈ N fps" recommendation below FPS slider,
  computed from LED count (WLED: protocol timing) or baud rate (serial).
  Reuses shared _computeMaxFps from devices.js with named constants.
- Fix gradient looking different across targets sharing the same stream:
  configure() now uses max LED count across all consumers; _fit_to_device
  uses np.interp linear interpolation instead of truncate/tile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:27:57 +03:00
27575930b8 Drift-compensating frame throttle, fix FPS startup spike
Replace per-frame sleep(remaining) with absolute next_frame_time
tracking so asyncio.sleep() overshoots are recovered in subsequent
frames, keeping average FPS on target. Skip first FPS sample to
avoid ~2000+ spike from near-zero init interval.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:09:43 +03:00
2a01c2947a Add dynamic FPS to static, gradient, and color cycle streams
All three non-picture color strip stream types had their animation
loops hardcoded at 30 FPS and lacked set_capture_fps(), so target
FPS changes had no effect. Now each stream reads self._fps per
iteration and exposes set_capture_fps() for the stream manager.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:52:19 +03:00
ee52e2d98f Animation None option, FPS min 1, serial COM lifecycle fixes
- Replace animation Enable checkbox with None option in effect selector;
  show effect description tooltip; disable speed slider when None selected
- Allow target FPS range 1-90 (was 10-90) across UI and backend validation
- Scope serial COM connections to target lifetime (no idle caching);
  use temporary connections for power-off/test mode
- Fix serial black frame on stop: flush after write, delay after task
  cancel to prevent race with in-flight thread pool write

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:33:56 +03:00
8a0730d91b Remove idle color feature, simplify power to turn-off only, fix settings serial port bug
- Remove static/idle color from entire stack (storage, API, processing, UI, CSS, locales)
- Simplify device power button to turn-off only (send black frame, no toggle)
- Send black frame on serial port close (AdalightClient.close)
- Fix settings modal serial port dropdown showing WLED devices due to stale deviceType

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:04:28 +03:00
1f6c913343 Move FPS from color strip source to target; dynamic capture rate
FPS is a consumption property (how fast to send to a device), not a
production property. Two targets sharing the same source may need
different FPS. This moves the fps field from PictureColorStripSource
to WledPictureTarget across the full stack.

The capture stream now auto-adjusts its rate to max(all connected
target FPS values) via ColorStripStreamManager tracking per-consumer
FPS. UI updates: FPS slider in target editor, FPS badge on target
cards, LED count repositioned in CSS editor, consistent speed icons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 03:46:08 +03:00
1204676c30 Fix serial send bloat when sharing CSS stream with higher-LED device
When two targets share the same color strip source, configure() resizes
the stream to the last caller's LED count. If a WLED device (934 LEDs)
starts after a serial device (fewer LEDs), the serial target sends the
full 934-LED frame over serial, massively inflating send time.

Add _fit_to_device() to truncate/tile colors to the target's actual
device LED count before sending, so each consumer only transmits what
its device needs regardless of the shared stream's current size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 03:12:45 +03:00
6d33686b79 Add FPS sparkline chart to target cards, move timing breakdown inline
Replace the three FPS text labels (actual/current/target) with a
Chart.js sparkline chart + compact label, matching the dashboard style.
FPS history (30 samples) persists across poll rebuilds. Pipeline timing
breakdown moved inside the metrics grid directly under the FPS chart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 03:06:18 +03:00
67d141b75b Show pipeline timing breakdown for non-picture source targets
Non-picture sources (static, gradient, color_cycle) returned empty
timing from get_last_timing(), causing timing_total_ms to be null and
hiding the entire timing section in the UI. Now timing_total_ms falls
back to send_ms when no CSS pipeline timing exists. Frontend timing
bar/legend segments are conditionally rendered to avoid null labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 02:55:02 +03:00
7c0c064453 Fix FPS drops caused by brightness endpoint polling WLED device
The GET /devices/{id}/brightness endpoint was making an HTTP request to
the ESP32 over WiFi on every frontend poll (~3s), causing 150ms async
event loop jitter that froze the LED processing loop. Cache brightness
server-side after first fetch/set, add frontend dedup guard, reduce
get_device_info() frequency, and add processing loop timing diagnostics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 02:43:03 +03:00
b14da85f3b Fix event loop blocking from perf endpoint and profile detection
- Change /api/v1/system/performance from async def to def so FastAPI
  runs the blocking psutil + NVML GPU queries in a thread pool instead
  of freezing the event loop (polled every 2s by dashboard)
- Batch profile engine's 3 separate run_in_executor detection calls
  into a single _detect_all_sync() call, reducing event loop wake-ups

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 02:06:59 +03:00
55a9662234 Add animation effects + double-buffered FPS optimization
- Add 5 new animation effects (strobe, sparkle, pulse, candle, rainbow
  fade) to both static and gradient color strip streams
- Fix FPS drops (30→25) by using 5ms re-poll on frame skip instead of
  full frame_time, preventing synchronization misses between animation
  thread and processing loop
- Double-buffer animation output arrays to eliminate per-frame numpy
  allocations and reduce GC pressure
- Use uint16 integer math for gradient brightness scaling instead of
  float32 intermediates
- Update animation type dropdowns and locale strings (en + ru)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:57:43 +03:00
84f063eee9 WGC capture fixes + high-resolution timer pacing for all loops
- Fix WGC capture_frame() returning stale frames (80k "frames" in 2s)
  by tracking new-frame events; return None when no new frame arrived
- Add draw_border config passthrough with Win11 22H2+ platform check
- Add high_resolution_timer() utility (timeBeginPeriod/EndPeriod)
- Switch all processing loops from time.time() to time.perf_counter()
- Wrap all loops with high_resolution_timer() for ~1ms sleep precision
- Add animation speed badges to static/gradient color strip cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:23:56 +03:00
5004992f26 Auto-recover DXGI capture after duplication interface loss
BetterCam/DXcam engines now detect when the DXGI Desktop Duplication
interface is lost (display mode change, sleep/wake, UAC prompt, etc.)
and automatically reinitialize the camera with a 3-second cooldown
between attempts, instead of error-looping indefinitely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:47:54 +03:00
0a000cc44c Fix Toggle All button state, stop icons, and Disable tooltip
- Fix Toggle All button always showing Start: /picture-targets list
  endpoint does not include processing state; now fetches
  /picture-targets/{id}/state per-target in parallel in both
  loadProfiles() and toggleProfileTargets()
- Replace pause icons (⏸) with stop icons (⏹) in dashboard
- Change profile automation toggle tooltip from 'Disabled' (status)
  to 'Disable' (action); add profiles.action.disable i18n key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:35:24 +03:00
8cf7678e2b UI fixes: modal vertical scroll, hide overlay btn for non-picture CSS
- Modal content now constrained to viewport height with scrollable body,
  preventing dialogs from overflowing on small screens
- Overlay (👁️) button hidden for targets using static/gradient/color_cycle
  sources — calibration overlay only applies to picture-type sources

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:22:58 +03:00
1604855935 Fix ColorCycleColorStripStream not auto-sizing to device LED count
configure() was only called for Static and Gradient streams, leaving
ColorCycle at its default led_count=1 — all other LEDs sent as black.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:16:48 +03:00
c31818a20d Add color_cycle as standalone source type; UI polish
- color_cycle is now a top-level source type (alongside picture/static/gradient)
  with a configurable color list and cycle_speed; defaults to full rainbow spectrum
- ColorCycleColorStripSource + ColorCycleColorStripStream: smooth 30 fps interpolation
  between user-defined colors, one full cycle every 20s at speed=1.0
- Removed color_cycle animation sub-type from StaticColorStripStream
- Color cycle editor: compact horizontal swatch layout, proper module-scope fix
  (colorCycleAdd/Remove now exposed on window, DOM-synced before mutations)
- Animation enabled + Frame interpolation checkboxes use toggle-switch style
- Removed Potential FPS metric from targets and KC targets metric grids

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:14:42 +03:00
872949a7e1 Add frame interpolation postprocessing filter + KC hot-settings
Frame interpolation filter (frame_interpolation):
- New PostprocessingFilter with supports_idle_frames = True
- Backward-blend algorithm: blends frame N-1 → N over one capture
  interval, producing smooth output on idle ticks at ≤1 frame of lag
- Detects new vs idle frames via cheap 64-byte signature comparison
- No options; registered alongside other built-in filters

ProcessedLiveStream idle-tick support:
- Detects supports_idle_frames filters at construction (_has_idle_filters)
- target_fps returns 2× source rate when idle filters are present
- _process_loop runs at 2× rate; idle ticks copy cached source frame
  and run full filter chain, publishing result only when a filter
  returned actual interpolated output (not a pass-through)
- Pass-through idle ticks leave _latest_frame unchanged so consumers
  correctly deduplicate via object identity

KC target hot-settings:
- brightness, smoothing, interpolation_mode now read from self._settings
  each frame instead of captured as stale locals at loop startup
- Changes take effect within one frame without stop/restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 21:01:45 +03:00
55e25b8860 Frame interpolation, FPS hot-update, timing metrics, KC brightness fixes
- CSS: add frame interpolation option — blends between consecutive captured
  frames on idle ticks so LED output runs at full target FPS even when
  capture rate is lower (e.g. capture 30fps, output 60fps)
- WledTargetProcessor: re-read stream.target_fps each loop tick so FPS
  changes to the CSS source take effect without restarting the target
- WledTargetProcessor: restore per-stage timing metrics on target card by
  pulling extract/map/smooth/total from CSS stream get_last_timing()
- TargetProcessingState schema: add missing timing_extract_ms,
  timing_map_leds_ms, timing_smooth_ms, timing_total_ms fields
- KC targets: add extraction FPS badge to target card props row
- KC targets: fix 500 error when changing brightness — update_fields now
  accepts (and ignores) WLED-specific kwargs
- KC targets: fix partial key_colors_settings update wiping pattern_template_id
  — update route merges only explicitly-set fields using model_dump(exclude_unset=True)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 20:29:22 +03:00
be37df4459 Calibration: pre-select device by LED count match or last remembered
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:45:49 +03:00
c5ced0d904 Dashboard: show color strip source type in target subtitle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:43:26 +03:00
7479b1fb8d CSS: add GradientColorStripSource with visual editor
- Backend: GradientColorStripSource storage model, GradientColorStripStream
  with numpy interpolation (bidirectional stops, auto-size from device LED count),
  ColorStop Pydantic schema, API create/update/guard routes
- Frontend: gradient editor modal (canvas preview, draggable markers, stop rows),
  CSS hard-edge card swatch, locale keys (en + ru)
- Fixes: stop row mousedown no longer rebuilds DOM (buttons now clickable),
  position input max-width, bidir/remove button static width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:35:41 +03:00
2a8e2daefc CSS: add StaticColorStripSource type with auto-sized LED count
Introduces a new 'static' source type that fills all device LEDs with a
single constant RGB color — no screen capture or processing required.

- StaticColorStripSource storage model (color + led_count=0 auto-size)
- StaticColorStripStream: no background thread, configure() sizes to device
  LED count at processor start; hot-updates preserve runtime size
- ColorStripStreamManager dispatches static sources (no LiveStream needed)
- WledTargetProcessor calls stream.configure(device_led_count) on start
- API schemas/routes: source_type Literal["picture","static"]; color field;
  overlay/calibration-test endpoints return 400 for static
- Frontend: type selector modal, color picker, type-aware card rendering
  (🎨 icon + color swatch), LED count field hidden for static type
- Locale keys: color_strip.type, color_strip.static_color (en + ru)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 17:49:48 +03:00
0a23cb7043 Overlay: show CW/CCW instead of full direction word
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 17:26:36 +03:00
018bedf9f6 Overlay: fix 404, crash on repeat, missing edge test colors, device reset on stop
- Target overlay works without active processing: route pre-loads calibration
  and display info from the CSS store, passes to processor as fallback
- Fix server crash on repeated overlay: replace per-window tk.Tk() with single
  persistent hidden root; each overlay is a Toplevel child dispatched via
  root.after() — eliminates Tcl interpreter crashes on Windows
- Fix edge test colors not lighting up: always call set_test_mode regardless
  of processing state (was guarded by 'not proc.is_running'); pass calibration
  so _send_test_pixels knows which LEDs map to which edges
- Fix device reset on overlay stop: keep idle serial client cached after
  clearing test mode; start_processing() already closes it before connecting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 17:16:10 +03:00
a3aeafef13 CSS: add led_count field; calibration dialog improvements; color corrections collapsible section
- Add explicit led_count to PictureColorStripSource (0 = auto from calibration)
- Stream pads with black or truncates to match led_count exactly
- Calibration dialog: show led_count input above visual editor in CSS mode
- Calibration dialog: pre-populate led_count with effective count (cal sum) when stored value is 0
- Calibration dialog: sync preview label live as led_count input changes
- CSS editor: group brightness/saturation/gamma into collapsible "Color Corrections" section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 16:42:32 +03:00
7de3546b14 Introduce ColorStripSource as first-class entity
Extracts color processing and calibration out of WledPictureTarget into a
new PictureColorStripSource entity, enabling multiple LED targets to share
one capture/processing pipeline.

New entities & processing:
- storage/color_strip_source.py: ColorStripSource + PictureColorStripSource models
- storage/color_strip_store.py: JSON-backed CRUD store (prefix css_)
- core/processing/color_strip_stream.py: ColorStripStream ABC + PictureColorStripStream (runs border-extract → map → smooth → brightness/sat/gamma in background thread)
- core/processing/color_strip_stream_manager.py: ref-counted shared stream manager

Modified storage/processing:
- WledPictureTarget simplified to device_id + color_strip_source_id + standby_interval + state_check_interval
- Device model: calibration field removed
- WledTargetProcessor: acquires ColorStripStream from manager instead of running its own pipeline
- ProcessorManager: wires ColorStripStreamManager into TargetContext

API layer:
- New routes: GET/POST/PUT/DELETE /api/v1/color-strip-sources, PUT calibration/test
- Removed calibration endpoints from /devices
- Updated /picture-targets CRUD for new target structure

Frontend:
- New color-strips.js module with CSS editor modal and card rendering
- Calibration modal extended with CSS mode (css-id hidden field + device picker)
- targets.js: Color Strip Sources section added to LED tab; target editor/card updated
- app.js: imports and window globals for CSS + showCSSCalibration
- en.json / ru.json: color_strip.* and targets.section.color_strips keys added

Data migration runs at startup: existing WledPictureTargets are converted to
reference a new PictureColorStripSource created from their old settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 15:49:47 +03:00
c4e0257389 Polymorphism Phase 2 + remove unused gamma/saturation fields
ProcessorManager: replace all isinstance checks with property-based
dispatch via base TargetProcessor (device_id, led_client,
get_display_index, update_device, update_calibration).

Remove gamma/saturation from ProcessingSettings, ColorCorrection
schema, serialization, and migration — these were never used in the
processing pipeline and are handled by postprocessing template filters.
Delete dead apply_color_correction() function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 02:34:03 +03:00
99f47fdbf9 Encapsulate target-type dispatch via polymorphism (Phase 1)
Replace isinstance checks with polymorphic methods on PictureTarget
hierarchy: register_with_manager, sync_with_manager, update_fields,
and has_picture_source property.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 02:20:33 +03:00
3101894ab5 HAOS: add server name field to config flow
Allows users to specify a custom display name when adding the
integration, replacing the hardcoded "LED Screen Controller" title.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 02:08:04 +03:00
c3b1d3edd9 Fix header z-index overlaying modal dialogs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 01:19:22 +03:00
3ae20761a1 Frontend: structured error handling, state fixes, accessibility, i18n
- Enhance fetchWithAuth with auto-401, retry w/ exponential backoff, timeout
- Remove ~40 manual 401 checks across 10 feature files
- Fix state: brightness cache setter, manual edit flag resets, static import
- Add ARIA: role=dialog/tablist, aria-modal, aria-labelledby, aria-selected
- Add focus trapping in Modal base class, aria-expanded on hint toggles
- Fix WCAG AA color contrast with --primary-text-color variable
- Add i18n pluralization (CLDR rules for en/ru), getCurrentLocale export
- Replace hardcoded strings in dashboard.js and profiles.js
- Add data-i18n-aria-label support, 20 new keys in en.json and ru.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 01:18:29 +03:00
2b90fafb9c Split monolithic index.html and style.css for maintainability
- Extract 15 modals and 3 partials from index.html into Jinja2 templates
  (templates/modals/*.html, templates/partials/*.html)
- Split style.css (3,712 lines) into 11 feature-scoped CSS files under
  static/css/ (base, layout, components, cards, modal, calibration,
  dashboard, streams, patterns, profiles, tutorials)
- Switch root route from FileResponse to Jinja2Templates
- Add jinja2 dependency
- Consolidate duplicate @keyframes spin definition
- Browser receives identical assembled HTML — zero JS changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 00:42:50 +03:00
755077607a Optimize frontend rendering: delta updates, rAF debouncing, cached DOM refs
- Disable Chart.js animations on real-time FPS and perf charts
- Dashboard: delta-update profile badges on state changes instead of full DOM rebuild
- Dashboard: cache querySelector results in Map for metrics update loop
- Dashboard: debounce poll interval slider restart (300ms)
- Calibration: debounce ResizeObserver and span drag via requestAnimationFrame
- Calibration: batch updateCalibrationPreview canvas render into rAF

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 23:06:39 +03:00
fbf597dc29 Optimize streaming pipeline and capture hot paths
- Replace asyncio.to_thread with dedicated ThreadPoolExecutor (skip
  per-frame context copy overhead)
- Move brightness scaling into _process_frame thread (avoid extra
  numpy array copies on event loop)
- Remove PIL intermediate in MSS capture (direct bytes→numpy)
- Unify median/dominant pixel mapping to numpy arrays (eliminate
  Python list-of-tuples path and duplicate Phase 2/3 code)
- Cache CalibrationConfig.segments property (avoid ~240 rebuilds/sec)
- Make KC WebSocket broadcasts concurrent via asyncio.gather
- Fix fps_samples list.pop(0) → deque(maxlen=10) in both processors
- Cache time.time() calls to reduce redundant syscalls per frame
- Log event queue drops instead of silently discarding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:55:21 +03:00
bfe6a7a2ab Replace WMI process enumeration with Win32 EnumProcesses (350x faster)
Use PROCESS_QUERY_LIMITED_INFORMATION + QueryFullProcessImageNameW
instead of WMI Win32_Process. Reduces process enumeration from ~3s
to ~8ms. All user-facing applications are detected; only protected
system services are not visible (irrelevant for profile conditions).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:30:22 +03:00
ab8041269e Add fullscreen and topmost+fullscreen profile condition modes
New match types for application conditions:
- "fullscreen": app has a fullscreen window on any monitor (detected via
  EnumWindows, works even when another window is focused on a different
  display)
- "topmost_fullscreen": app is the focused foreground window AND fullscreen

Optimizes profile evaluation to only call expensive detection methods when
needed: WMI process enumeration (~3s) is skipped when no condition uses
"running" mode; foreground/fullscreen checks (<1ms each) are called
selectively based on active match types.

Filters false positives from fullscreen detection by excluding desktop/shell
process windows, tool windows, and non-activatable overlay windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:26:50 +03:00
ff4e054ef8 Show edge test colors on LED device when overlay is active
Lights up device LEDs with calibration edge colors (top=red, right=green,
bottom=blue, left=yellow) when the overlay is started, and clears them when
the overlay is stopped. Skips if the target is currently processing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:39:37 +03:00
bef28ece5c Add static color support, HAOS light entity, and real-time profile updates
- Add static_color capability to WLED and serial providers with native
  set_color() dispatch (WLED uses JSON API, serial uses idle client)
- Encapsulate device-specific logic in providers instead of device_type
  checks in ProcessorManager and API routes
- Add HAOS light entity for devices with brightness_control + static_color
  (Adalight/AmbiLED get light entity, WLED keeps number entity)
- Fix serial device brightness and turn-off: pass software_brightness
  through provider chain, clear device on color=null, re-send static
  color after brightness change
- Add global events WebSocket (events-ws.js) replacing per-tab WS,
  enabling real-time profile state updates on both dashboard and profiles tabs
- Fix profile activation: mark active when all targets already running,
  add asyncio.Lock to prevent concurrent evaluation races, skip process
  enumeration when no profile has conditions, trigger immediate evaluation
  on enable/create/update for instant target startup
- Add reliable server restart script (restart.ps1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:23:47 +03:00
6388e0defa Decouple i18n from feature modules and fix auth/login UX
Replace hardcoded updateAllText() calls with languageChanged event
pattern so feature modules subscribe independently. Guard all API
calls behind apiKey checks to prevent unauthorized requests when not
logged in. Fix login modal localization, hide tabs when logged out,
clear all panels on logout, and treat profiles with no conditions as
always-true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:32:14 +03:00
747cdfabd6 Prioritize selected rectangle in pattern editor hit test
When multiple rectangles overlap, the currently selected one is now
tested first for both click and hover, keeping its edges and body
interactive even when another rectangle sits on top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:42:08 +03:00
df52a197d9 Group dashboard targets into a collapsible Targets section with Running/Stopped subsections
Wrap running and stopped target lists under a parent Targets group.
Fix narrow-screen layout by keeping action buttons inline and hiding
metrics below 768px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 03:08:00 +03:00
373 changed files with 80859 additions and 17067 deletions

View File

@@ -0,0 +1,74 @@
name: Build Release
on:
push:
tags:
- 'v*'
jobs:
build-windows:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build portable distribution
shell: pwsh
run: |
.\build-dist.ps1 -Version "${{ gitea.ref_name }}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: LedGrab-${{ gitea.ref_name }}-win-x64
path: build/LedGrab-*.zip
retention-days: 90
- name: Create Gitea release
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$tag = "${{ gitea.ref_name }}"
$zipFile = Get-ChildItem "build\LedGrab-*.zip" | Select-Object -First 1
if (-not $zipFile) { throw "ZIP not found" }
$baseUrl = "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
$headers = @{
"Authorization" = "token $env:GITEA_TOKEN"
"Content-Type" = "application/json"
}
# Create release
$body = @{
tag_name = $tag
name = "LedGrab $tag"
body = "Portable Windows build — unzip, run ``LedGrab.bat``, open http://localhost:8080"
draft = $false
prerelease = ($tag -match '(alpha|beta|rc)')
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post `
-Uri "$baseUrl/releases" `
-Headers $headers -Body $body
Write-Host "Created release: $($release.html_url)"
# Upload ZIP asset
$uploadHeaders = @{
"Authorization" = "token $env:GITEA_TOKEN"
}
$uploadUrl = "$baseUrl/releases/$($release.id)/assets?name=$($zipFile.Name)"
Invoke-RestMethod -Method Post -Uri $uploadUrl `
-Headers $uploadHeaders `
-ContentType "application/octet-stream" `
-InFile $zipFile.FullName
Write-Host "Uploaded: $($zipFile.Name)"

3
.gitignore vendored
View File

@@ -26,6 +26,9 @@ ENV/
env/
.venv
# Node
node_modules/
# IDE
.vscode/
.idea/

118
BRAINSTORM.md Normal file
View File

@@ -0,0 +1,118 @@
# Feature Brainstorm — LED Grab
## New Automation Conditions (Profiles)
Right now profiles only trigger on **app detection**. High-value additions:
- **Time-of-day / Schedule** — "warm tones after sunset, off at midnight." Schedule-based value sources pattern already exists
- **Display state** — detect monitor on/off/sleep, auto-stop targets when display is off
- **System idle** — dim or switch to ambient effect after N minutes of no input
- **Sunrise/sunset** — fetch local solar times, drive circadian color temperature shifts
- **Webhook/MQTT trigger** — let external systems activate profiles without HA integration
## New Output Targets
Currently: WLED, Adalight, AmbileD, DDP. Potential:
- **MQTT publish** — generic IoT output, any MQTT subscriber becomes a target
- **Art-Net / sACN (E1.31)** — stage/theatrical lighting protocols, DMX controllers
- **OpenRGB** — control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
- **HTTP webhook** — POST color data to arbitrary endpoints
- **Recording target** — save color streams to file for playback later
## New Color Strip Sources
- **Spotify / media player** — album art color extraction or tempo-synced effects
- **Weather** — pull conditions from API, map to palettes (blue=rain, orange=sun, white=snow)
- **Camera / webcam** — border-sampling from camera feed for video calls or room-reactive lighting
- **Script source** — user-written JS/Python snippets producing color arrays per frame
- **Notification reactive** — flash/pulse on OS notifications (optional app filter)
## Processing Pipeline Extensions
- **Palette quantization** — force output to match a user-defined palette
- **Zone grouping** — merge adjacent LEDs into logical groups sharing one averaged color
- **Color temperature filter** — warm/cool shift separate from hue shift (circadian/mood)
- **Noise gate** — suppress small color changes below threshold, preventing shimmer on static content
## Multi-Instance & Sync
- **Multi-room sync** — multiple instances with shared clock for synchronized effects
- **Multi-display unification** — treat 2-3 monitors as single virtual display for seamless ambilight
- **Leader/follower mode** — one target's output drives others with optional delay (cascade)
## UX & Dashboard
- **PWA / mobile layout** — mobile-first layout + "Add to Home Screen" manifest
- **Scene presets** — bundled source + filters + brightness as one-click presets ("Movie night", "Gaming")
- **Live preview on dashboard** — miniature screen with LED colors rendered around its border
- **Undo/redo for calibration** — reduce frustration in the fiddly calibration editor
- **Drag-and-drop filter ordering** — reorder postprocessing filter chains visually
## API & Integration
- **WebSocket event bus** — broadcast all state changes over a single WS channel
- **OBS integration** — detect active scene, switch profiles; or use OBS virtual camera as source
- **Plugin system** — formalize extension points into documented plugin API with hot-reload
## Creative / Fun
- **Effect sequencer** — timeline-based choreography of effects, colors, and transitions
- **Music BPM sync** — lock effect speed to detected BPM (beat detection already exists)
- **Color extraction from image** — upload photo, extract palette, use as gradient/cycle source
- **Transition effects** — crossfade, wipe, or dissolve between sources/profiles instead of instant cut
---
## Deep Dive: Notification Reactive Source
**Type:** New `ColorStripSource` (`source_type: "notification"`) — normally outputs transparent RGBA, flashes on notification events. Designed to be used as a layer in a **composite source** so it overlays on top of a persistent base (gradient, effect, screen capture, etc.).
### Trigger modes (both active simultaneously)
1. **OS listener (Windows)**`pywinrt` + `Windows.UI.Notifications.Management.UserNotificationListener`. Runs in background thread, pushes events to source via queue. Windows-only for now; macOS (`pyobjc` + `NSUserNotificationCenter`) and Linux (`dbus` + `org.freedesktop.Notifications`) deferred to future.
2. **Webhook**`POST /api/v1/notifications/{source_id}/fire` with optional body `{ "app": "MyApp", "color": "#FF0000" }`. Always available, cross-platform by nature.
### Source config
```yaml
os_listener: true # enable Windows notification listener
app_filter:
mode: whitelist|blacklist # which apps to react to
apps: [Discord, Slack, Telegram]
app_colors: # user-configured app → color mapping
Discord: "#5865F2"
Slack: "#4A154B"
Telegram: "#26A5E4"
default_color: "#FFFFFF" # fallback when app has no mapping
effect: flash|pulse|sweep # visual effect type
duration_ms: 1500 # effect duration
```
### Effect rendering
Source outputs RGBA color array per frame:
- **Idle**: all pixels `(0,0,0,0)` — composite passes through base layer
- **Flash**: instant full-color, linear fade to transparent over `duration_ms`
- **Pulse**: sine fade in/out over `duration_ms`
- **Sweep**: color travels across the strip like a wave
Each notification starts its own mini-timeline from trigger timestamp (not sync clock).
### Overlap handling
New notification while previous effect is active → restart timer with new color. No queuing.
### App color resolution
1. Webhook body `color` field (explicit override) → highest priority
2. `app_colors` mapping by app name
3. `default_color` fallback
---
## Top Picks (impact vs effort)
1. **Time-of-day + idle profile conditions** — builds on existing profile/condition architecture
2. **MQTT output target** — opens the door to an enormous IoT ecosystem
3. **Scene presets** — purely frontend, bundles existing features into one-click UX

121
CLAUDE.md
View File

@@ -1,5 +1,31 @@
# Claude Instructions for WLED Screen Controller
## Code Search
**If `ast-index` is available, use it as the PRIMARY code search tool.** It is significantly faster than grep and returns structured, accurate results. Fall back to grep/Glob only when ast-index is not installed, returns empty results, or when searching regex patterns/string literals/comments.
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt.
```bash
# Check if available:
ast-index version
# Rebuild index (first time or after major changes):
ast-index rebuild
# Common commands:
ast-index search "Query" # Universal search across files, symbols, modules
ast-index class "ClassName" # Find class/struct/interface definitions
ast-index usages "SymbolName" # Find all places a symbol is used
ast-index implementations "BaseClass" # Find all subclasses/implementations
ast-index symbol "FunctionName" # Find any symbol (class, function, property)
ast-index outline "path/to/File.cpp" # Show all symbols in a file
ast-index hierarchy "ClassName" # Show inheritance tree
ast-index callers "FunctionName" # Find all call sites
ast-index changed --base master # Show symbols changed in current branch
ast-index update # Incremental update after file changes
```
## CRITICAL: Git Commit and Push Policy
**🚨 NEVER CREATE COMMITS WITHOUT EXPLICIT USER APPROVAL 🚨**
@@ -70,13 +96,55 @@
**Whenever server-side Python code is modified** (any file under `/server/src/` **excluding** `/server/src/wled_controller/static/`), **automatically restart the server** so the changes take effect immediately. Do NOT wait for the user to ask for a restart.
**No restart needed for frontend-only changes.** Files under `/server/src/wled_controller/static/` (HTML, JS, CSS, JSON locale files) are served directly by FastAPI's static file handler — changes take effect on the next browser page refresh without restarting the server.
**No restart needed for frontend-only changes** — but you **MUST rebuild the bundle**. The browser loads the esbuild bundle (`static/dist/app.bundle.js`, `static/dist/app.bundle.css`), NOT the source files. After ANY change to frontend files (JS, CSS under `/server/src/wled_controller/static/`), run:
```bash
cd server && npm run build
```
Without this step, changes will NOT take effect. No server restart is needed — just rebuild and refresh the browser.
### Restart procedure
1. Stop the running Python process: `powershell -Command "Get-Process -Name python -ErrorAction SilentlyContinue | Stop-Process -Force"`
2. Start the server: `powershell -Command "Set-Location 'c:\Users\Alexei\Documents\wled-screen-controller\server'; python -m wled_controller.main"` (run in background)
3. Wait 3 seconds and check startup logs to confirm it's running
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
```bash
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1"
```
**Do NOT use** `Stop-Process -Name python` (kills unrelated Python processes like VS Code extensions) or bash background `&` jobs (get killed when the shell session ends).
## IMPORTANT: Server Startup Commands
There are two server modes with separate configs, ports, and data directories:
| Mode | Command | Config | Port | API Key | Data |
|------|---------|--------|------|---------|------|
| **Real** | `python -m wled_controller.main` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
Both can run simultaneously on different ports.
### Restarting after code changes
- **Real server**: Use the PowerShell restart script (it only targets the real server process):
```bash
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1"
```
- **Demo server**: Find and kill the process on port 8081, then restart:
```bash
# Find PID
powershell -Command "netstat -ano | Select-String ':8081.*LISTEN'"
# Kill it
powershell -Command "Stop-Process -Id <PID> -Force"
# Restart
cd server && python -m wled_controller.demo
```
**Do NOT use** `Stop-Process -Name python` — it kills unrelated Python processes (VS Code extensions, etc.).
## Project Structure
@@ -89,28 +157,41 @@ This is a monorepo containing:
For detailed server-specific instructions (restart policy, testing, etc.), see:
- `server/CLAUDE.md`
## UI Conventions for Dialogs
## Frontend (HTML, CSS, JS, i18n)
### Hints
For all frontend conventions (CSS variables, UI patterns, modals, localization, tutorials), see [`contexts/frontend.md`](contexts/frontend.md).
Every form field in a modal should have a hint. Use the `.label-row` wrapper with a `?` toggle button:
## Task Tracking via TODO.md
```html
<div class="form-group">
<div class="label-row">
<label for="my-field" data-i18n="my.label">Label:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="my.label.hint">Hint text</small>
<input type="text" id="my-field">
</div>
```
Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the TodoWrite tool** — all progress tracking goes through `TODO.md`.
Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffix on the label key.
- **When starting a multi-step task**: add sub-steps as `- [ ]` items under the relevant section
- **When completing a step**: mark it `- [x]` immediately — don't batch updates
- **When a task is fully done**: mark it `- [x]` and leave it for the user to clean up
- **When the user requests a new feature/fix**: add it to the appropriate section with a priority tag
### Select dropdowns
## Documentation Lookup
Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.
**Use context7 MCP tools for library/framework documentation lookups.** When you need to check API signatures, usage patterns, or current behavior of external libraries (e.g., FastAPI, OpenCV, Pydantic, yt-dlp), use `mcp__plugin_context7_context7__resolve-library-id` to find the library, then `mcp__plugin_context7_context7__query-docs` to fetch up-to-date docs. This avoids relying on potentially outdated training data.
## IMPORTANT: Demo Mode Awareness
**When adding new entity types, engines, device providers, or stores — keep demo mode in sync:**
1. **New entity stores**: Add the store's file path to `StorageConfig` in `config.py` — the `model_post_init()` auto-rewrites `data/` → `data/demo/` paths when demo is active.
2. **New capture engines**: If a new engine is added, verify demo mode filtering still works (demo engines use `is_demo_mode()` gate in `is_available()`).
3. **New audio engines**: Same as capture engines — `is_available()` must respect `is_demo_mode()`.
4. **New device providers**: If discovery is added, gate it with `is_demo_mode()` like `DemoDeviceProvider.discover()`.
5. **New seed data**: When adding new entity types that should appear in demo mode, update `server/src/wled_controller/core/demo_seed.py` to include sample entities.
6. **Frontend indicators**: Demo mode state is exposed via `GET /api/v1/version` → `demo_mode: bool`. Frontend stores it as `demoMode` in app state and sets `document.body.dataset.demo = 'true'`.
7. **Backup/Restore**: If new stores are added to `STORE_MAP` in `system.py`, they automatically work in demo mode since the data directory is already isolated.
**Key files:**
- Config flag: `server/src/wled_controller/config.py` → `Config.demo`, `is_demo_mode()`
- Demo engines: `core/capture_engines/demo_engine.py`, `core/audio/demo_engine.py`
- Demo devices: `core/devices/demo_provider.py`
- Seed data: `core/demo_seed.py`
## General Guidelines

320
README.md
View File

@@ -1,194 +1,236 @@
# WLED Screen Controller
# LED Grab
Ambient lighting controller that synchronizes WLED devices with your screen content for an immersive viewing experience.
Ambient lighting system that captures screen content and drives LED strips in real time. Supports WLED, Adalight, AmbileD, and DDP devices with audio-reactive effects, pattern generation, and automated profile switching.
## Overview
## What It Does
This project consists of two components:
The server captures pixels from a screen (or Android device via ADB), extracts border colors, applies post-processing filters, and streams the result to LED strips at up to 60 fps. A built-in web dashboard provides device management, calibration, live LED preview, and real-time metrics — no external UI required.
1. **Python Server** - Captures screen border pixels and sends color data to WLED devices via REST API
2. **Home Assistant Integration** - Controls and monitors the server from Home Assistant OS
A Home Assistant integration exposes devices as entities for smart home automation.
## Features
- 🖥️ **Multi-Monitor Support** - Select which display to capture
-**Configurable FPS** - Adjust update rate (1-60 FPS)
- 🎨 **Smart Calibration** - Map screen edges to LED positions
- 🔌 **REST API** - Full control via HTTP endpoints
- 🏠 **Home Assistant Integration** - Native HAOS support with entities
- 🐳 **Docker Support** - Easy deployment with Docker Compose
- 📊 **Real-time Metrics** - Monitor FPS, status, and performance
### Screen Capture
- Multi-monitor support with per-target display selection
- 6 capture engine backends — MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows), Scrcpy (Android via ADB), Camera/Webcam (OpenCV)
- Configurable capture regions, FPS, and border width
- Capture templates for reusable configurations
### LED Device Support
- WLED (HTTP/UDP) with mDNS auto-discovery
- Adalight (serial) — Arduino-compatible LED controllers
- AmbileD (serial)
- DDP (Distributed Display Protocol, UDP)
- OpenRGB — PC peripherals (keyboard, mouse, RAM, fans, LED strips)
- Serial port auto-detection and baud rate configuration
### Color Processing
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip
- Reusable post-processing templates
- Color strip sources: audio-reactive, pattern generator, composite layering, audio-to-color mapping
- Pattern templates with customizable effects
### Audio Integration
- Multichannel audio capture from any system device (input or loopback)
- WASAPI engine on Windows, Sounddevice (PortAudio) engine on Linux/macOS
- Per-channel mono extraction
- Audio-reactive color strip sources driven by frequency analysis
### Automation
- Profile engine with condition-based switching (time of day, active window, etc.)
- Dynamic brightness value sources (schedule-based, scene-aware)
- Key Colors (KC) targets with live WebSocket color streaming
### Dashboard
- Web UI at `http://localhost:8080` — no installation needed on the client side
- Progressive Web App (PWA) — installable on phones and tablets with offline caching
- Responsive mobile layout with bottom tab navigation
- Device management with auto-discovery wizard
- Visual calibration editor with overlay preview
- Live LED strip preview via WebSocket
- Real-time FPS, latency, and uptime charts
- Localized in English, Russian, and Chinese
### Home Assistant Integration
- HACS-compatible custom component
- Light, switch, sensor, and number entities per device
- Real-time metrics via data coordinator
- WebSocket-based live LED preview in HA
## Requirements
### Server
- Python 3.11 or higher
- Windows, Linux, or macOS
- WLED device on the same network
- Python 3.11+ (or Docker)
- A supported LED device on the local network or connected via USB
- Windows, Linux, or macOS — all core features work cross-platform
### Home Assistant Integration
- Home Assistant OS 2023.1 or higher
- Running WLED Screen Controller server
### Platform Notes
| Feature | Windows | Linux / macOS |
| ------- | ------- | ------------- |
| Screen capture | DXCam, BetterCam, WGC, MSS | MSS |
| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) |
| Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) |
| GPU monitoring | NVIDIA (pynvml) | NVIDIA (pynvml) |
| Android capture | Scrcpy (ADB) | Scrcpy (ADB) |
| Monitor names | Friendly names (WMI) | Generic ("Display 0") |
| Profile conditions | Process/window detection | Not yet implemented |
## Quick Start
### Server Installation
1. **Clone the repository**
```bash
git clone https://github.com/yourusername/wled-screen-controller.git
cd wled-screen-controller/server
```
2. **Install dependencies**
```bash
pip install .
```
3. **Run the server**
```bash
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
```
4. **Access the API**
- API: http://localhost:8080
- Interactive docs: http://localhost:8080/docs
### Docker Installation
```bash
cd server
git clone https://github.com/yourusername/wled-screen-controller.git
cd wled-screen-controller/server
# Option A: Docker (recommended)
docker-compose up -d
# Option B: Python
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
pip install .
export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
```
Open `http://localhost:8080` to access the dashboard. The default API key for development is `development-key-change-in-production`.
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including Docker manual builds and Home Assistant setup.
## Architecture
```text
wled-screen-controller/
├── server/ # Python FastAPI backend
│ ├── src/wled_controller/
│ │ ├── main.py # Application entry point
│ │ ├── config.py # YAML + env var configuration
│ │ ├── api/
│ │ │ ├── routes/ # REST + WebSocket endpoints
│ │ │ └── schemas/ # Pydantic request/response models
│ │ ├── core/
│ │ │ ├── capture/ # Screen capture, calibration, pixel processing
│ │ │ ├── capture_engines/ # MSS, DXCam, BetterCam, WGC, Scrcpy, Camera backends
│ │ │ ├── devices/ # WLED, Adalight, AmbileD, DDP, OpenRGB clients
│ │ │ ├── audio/ # Audio capture engines
│ │ │ ├── filters/ # Post-processing filter pipeline
│ │ │ ├── processing/ # Stream orchestration and target processors
│ │ │ └── profiles/ # Condition-based profile automation
│ │ ├── storage/ # JSON-based persistence layer
│ │ ├── static/ # Web dashboard (vanilla JS, CSS, HTML)
│ │ │ ├── js/core/ # API client, state, i18n, modals, events
│ │ │ ├── js/features/ # Feature modules (devices, streams, targets, etc.)
│ │ │ ├── css/ # Stylesheets
│ │ │ └── locales/ # en.json, ru.json, zh.json
│ │ └── utils/ # Logging, monitor detection
│ ├── config/ # default_config.yaml
│ ├── tests/ # pytest suite
│ ├── Dockerfile
│ └── docker-compose.yml
├── custom_components/ # Home Assistant integration (HACS)
│ └── wled_screen_controller/
├── docs/
│ ├── API.md # REST API reference
│ └── CALIBRATION.md # LED calibration guide
├── INSTALLATION.md
└── LICENSE # MIT
```
## Configuration
Edit `server/config/default_config.yaml`:
Edit `server/config/default_config.yaml` or use environment variables with the `LED_GRAB_` prefix:
```yaml
server:
host: "0.0.0.0"
port: 8080
log_level: "INFO"
processing:
default_fps: 30
border_width: 10
auth:
api_keys:
dev: "development-key-change-in-production"
wled:
timeout: 5
retry_attempts: 3
storage:
devices_file: "data/devices.json"
templates_file: "data/capture_templates.json"
logging:
format: "json"
file: "logs/wled_controller.log"
max_size_mb: 100
```
## API Usage
Environment variable override example: `LED_GRAB_SERVER__PORT=9090`.
### Attach a WLED Device
## API
```bash
curl -X POST http://localhost:8080/api/v1/devices \
-H "Content-Type: application/json" \
-d '{
"name": "Living Room TV",
"url": "http://192.168.1.100",
"led_count": 150
}'
```
The server exposes a REST API (with Swagger docs at `/docs`) covering:
### Start Processing
- **Devices** — CRUD, discovery, validation, state, metrics
- **Capture Templates** — Screen capture configurations
- **Picture Sources** — Screen capture stream definitions
- **Picture Targets** — LED target management, start/stop processing
- **Post-Processing Templates** — Filter pipeline configurations
- **Color Strip Sources** — Audio, pattern, composite, mapped sources
- **Audio Sources** — Multichannel and mono audio device configuration
- **Pattern Templates** — Effect pattern definitions
- **Value Sources** — Dynamic brightness/value providers
- **Key Colors Targets** — KC targets with WebSocket live color stream
- **Profiles** — Condition-based automation profiles
```bash
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
```
All endpoints require API key authentication via `X-API-Key` header or `?token=` query parameter.
### Get Status
```bash
curl http://localhost:8080/api/v1/devices/{device_id}/state
```
See [API Documentation](docs/API.md) for complete API reference.
See [docs/API.md](docs/API.md) for the full reference.
## Calibration
The calibration system maps screen border pixels to LED positions. See [Calibration Guide](docs/CALIBRATION.md) for details.
The calibration system maps screen border pixels to physical LED positions. Configure layout direction, start position, and per-edge segments through the web dashboard or API.
Example calibration:
```json
{
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [
{"edge": "bottom", "led_start": 0, "led_count": 40},
{"edge": "right", "led_start": 40, "led_count": 30},
{"edge": "top", "led_start": 70, "led_count": 40},
{"edge": "left", "led_start": 110, "led_count": 40}
]
}
```
See [docs/CALIBRATION.md](docs/CALIBRATION.md) for a step-by-step guide.
## Home Assistant Integration
## Home Assistant
1. Copy `homeassistant/custom_components/wled_screen_controller` to your Home Assistant `custom_components` folder
2. Restart Home Assistant
3. Go to Settings → Integrations → Add Integration
4. Search for "WLED Screen Controller"
5. Enter your server URL
Install via HACS (add as a custom repository) or manually copy `custom_components/wled_screen_controller/` into your HA config directory. The integration creates light, switch, sensor, and number entities for each configured device.
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
## Development
### Running Tests
```bash
cd server
pytest tests/ -v
# Install with dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Format and lint
black src/ tests/
ruff check src/ tests/
```
### Project Structure
Optional extras:
```bash
pip install -e ".[perf]" # High-performance capture engines (Windows)
pip install -e ".[camera]" # Webcam capture via OpenCV
```
wled-screen-controller/
├── server/ # Python FastAPI server
│ ├── src/wled_controller/ # Main application code
│ ├── tests/ # Unit and integration tests
│ ├── config/ # Configuration files
│ └── pyproject.toml # Python dependencies & project config
├── homeassistant/ # Home Assistant integration
│ └── custom_components/
└── docs/ # Documentation
```
## Troubleshooting
### Screen capture fails
- **Windows**: Ensure Python has screen capture permissions
- **Linux**: Install X11 dependencies: `apt-get install libxcb1 libxcb-randr0`
- **macOS**: Grant screen recording permission in System Preferences
### WLED not responding
- Verify WLED device is on the same network
- Check firewall settings
- Test connection: `curl http://YOUR_WLED_IP/json/info`
### Low FPS
- Reduce `border_width` in configuration
- Lower target FPS
- Check network latency to WLED device
- Reduce LED count
## License
MIT License - see [LICENSE](LICENSE) file
## Contributing
Contributions welcome! Please open an issue or pull request.
MIT see [LICENSE](LICENSE).
## Acknowledgments
- [WLED](https://github.com/Aircoookie/WLED) - Amazing LED control software
- [FastAPI](https://fastapi.tiangolo.com/) - Modern Python web framework
- [mss](https://python-mss.readthedocs.io/) - Fast screen capture library
## Support
- GitHub Issues: [Report a bug](https://github.com/yourusername/wled-screen-controller/issues)
- Discussions: [Ask a question](https://github.com/yourusername/wled-screen-controller/discussions)
- [WLED](https://github.com/Aircoookie/WLED) LED control firmware
- [FastAPI](https://fastapi.tiangolo.com/) Python web framework
- [MSS](https://python-mss.readthedocs.io/) — Cross-platform screen capture

250
build-dist.ps1 Normal file
View File

@@ -0,0 +1,250 @@
<#
.SYNOPSIS
Build a portable Windows distribution of LedGrab.
.DESCRIPTION
Downloads embedded Python, installs all dependencies, copies app code,
builds the frontend bundle, and produces a self-contained ZIP.
.PARAMETER Version
Version string (e.g. "0.1.0" or "v0.1.0"). Auto-detected from git tag
or __init__.py if omitted.
.PARAMETER PythonVersion
Embedded Python version to download. Default: 3.11.9
.PARAMETER SkipFrontend
Skip npm ci + npm run build (use if frontend is already built).
.PARAMETER SkipPerf
Skip installing optional [perf] extras (dxcam, bettercam, windows-capture).
.EXAMPLE
.\build-dist.ps1
.\build-dist.ps1 -Version "0.2.0"
.\build-dist.ps1 -SkipFrontend -SkipPerf
#>
param(
[string]$Version = "",
[string]$PythonVersion = "3.11.9",
[switch]$SkipFrontend,
[switch]$SkipPerf
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' # faster downloads
$ScriptRoot = $PSScriptRoot
$BuildDir = Join-Path $ScriptRoot "build"
$DistName = "LedGrab"
$DistDir = Join-Path $BuildDir $DistName
$ServerDir = Join-Path $ScriptRoot "server"
$PythonDir = Join-Path $DistDir "python"
$AppDir = Join-Path $DistDir "app"
# ── Version detection ──────────────────────────────────────────
if (-not $Version) {
# Try git tag
try {
$gitTag = git describe --tags --exact-match 2>$null
if ($gitTag) { $Version = $gitTag }
} catch {}
}
if (-not $Version) {
# Try env var (CI)
if ($env:GITEA_REF_NAME) { $Version = $env:GITEA_REF_NAME }
elseif ($env:GITHUB_REF_NAME) { $Version = $env:GITHUB_REF_NAME }
}
if (-not $Version) {
# Parse from __init__.py
$initFile = Join-Path $ServerDir "src\wled_controller\__init__.py"
$match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"'
if ($match) { $Version = $match.Matches[0].Groups[1].Value }
}
if (-not $Version) { $Version = "0.0.0" }
# Strip leading 'v' for filenames
$VersionClean = $Version -replace '^v', ''
$ZipName = "LedGrab-v${VersionClean}-win-x64.zip"
Write-Host "=== Building LedGrab v${VersionClean} ===" -ForegroundColor Cyan
Write-Host " Python: $PythonVersion"
Write-Host " Output: build\$ZipName"
Write-Host ""
# ── Clean ──────────────────────────────────────────────────────
if (Test-Path $DistDir) {
Write-Host "[1/8] Cleaning previous build..."
Remove-Item -Recurse -Force $DistDir
}
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
# ── Download embedded Python ───────────────────────────────────
$PythonZipUrl = "https://www.python.org/ftp/python/${PythonVersion}/python-${PythonVersion}-embed-amd64.zip"
$PythonZipPath = Join-Path $BuildDir "python-embed.zip"
Write-Host "[2/8] Downloading embedded Python ${PythonVersion}..."
if (-not (Test-Path $PythonZipPath)) {
Invoke-WebRequest -Uri $PythonZipUrl -OutFile $PythonZipPath
}
Write-Host " Extracting to python/..."
Expand-Archive -Path $PythonZipPath -DestinationPath $PythonDir -Force
# ── Patch ._pth to enable site-packages ────────────────────────
Write-Host "[3/8] Patching Python path configuration..."
$pthFile = Get-ChildItem -Path $PythonDir -Filter "python*._pth" | Select-Object -First 1
if (-not $pthFile) { throw "Could not find python*._pth in $PythonDir" }
$pthContent = Get-Content $pthFile.FullName -Raw
# Uncomment 'import site'
$pthContent = $pthContent -replace '#\s*import site', 'import site'
# Add Lib\site-packages if not present
if ($pthContent -notmatch 'Lib\\site-packages') {
$pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n"
}
Set-Content -Path $pthFile.FullName -Value $pthContent -NoNewline
Write-Host " Patched $($pthFile.Name)"
# ── Install pip ────────────────────────────────────────────────
Write-Host "[4/8] Installing pip..."
$GetPipPath = Join-Path $BuildDir "get-pip.py"
if (-not (Test-Path $GetPipPath)) {
Invoke-WebRequest -Uri "https://bootstrap.pypa.io/get-pip.py" -OutFile $GetPipPath
}
$python = Join-Path $PythonDir "python.exe"
$ErrorActionPreference = 'Continue'
& $python $GetPipPath --no-warn-script-location 2>&1 | Out-Null
$ErrorActionPreference = 'Stop'
if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" }
# ── Install dependencies ──────────────────────────────────────
Write-Host "[5/8] Installing dependencies..."
$extras = "camera,notifications"
if (-not $SkipPerf) { $extras += ",perf" }
# Install the project (pulls all deps via pyproject.toml), then remove
# the installed package itself — PYTHONPATH handles app code loading.
$ErrorActionPreference = 'Continue'
& $python -m pip install --no-warn-script-location "${ServerDir}[${extras}]" 2>&1 | ForEach-Object {
if ($_ -match 'ERROR|Failed') { Write-Host " $_" -ForegroundColor Red }
}
$ErrorActionPreference = 'Stop'
if ($LASTEXITCODE -ne 0) {
Write-Host " Some optional deps may have failed (continuing)..." -ForegroundColor Yellow
}
# Remove the installed wled_controller package to avoid duplication
$sitePackages = Join-Path $PythonDir "Lib\site-packages"
Get-ChildItem -Path $sitePackages -Filter "wled*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "wled*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Clean up caches and test files to reduce size
Write-Host " Cleaning up caches..."
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "tests" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "test" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# ── Build frontend ─────────────────────────────────────────────
if (-not $SkipFrontend) {
Write-Host "[6/8] Building frontend bundle..."
Push-Location $ServerDir
try {
$ErrorActionPreference = 'Continue'
& npm ci --loglevel error 2>&1 | Out-Null
& npm run build 2>&1 | ForEach-Object {
$line = "$_"
if ($line -and $line -notmatch 'RemoteException') { Write-Host " $line" }
}
$ErrorActionPreference = 'Stop'
} finally {
Pop-Location
}
} else {
Write-Host "[6/8] Skipping frontend build (--SkipFrontend)"
}
# ── Copy application files ─────────────────────────────────────
Write-Host "[7/8] Copying application files..."
New-Item -ItemType Directory -Path $AppDir -Force | Out-Null
# Copy source code (includes static/dist bundle, templates, locales)
$srcDest = Join-Path $AppDir "src"
Copy-Item -Path (Join-Path $ServerDir "src") -Destination $srcDest -Recurse
# Copy config
$configDest = Join-Path $AppDir "config"
Copy-Item -Path (Join-Path $ServerDir "config") -Destination $configDest -Recurse
# Create empty data/ and logs/ directories
New-Item -ItemType Directory -Path (Join-Path $DistDir "data") -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
# Clean up source maps and __pycache__ from app code
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# ── Create launcher ────────────────────────────────────────────
Write-Host "[8/8] Creating launcher..."
$launcherContent = @'
@echo off
title LedGrab v%VERSION%
cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
echo.
echo =============================================
echo LedGrab v%VERSION%
echo Open http://localhost:8080 in your browser
echo =============================================
echo.
:: Start the server (open browser after short delay)
start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:8080"
"%~dp0python\python.exe" -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
pause
'@
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
$launcherPath = Join-Path $DistDir "LedGrab.bat"
Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII
# ── Create ZIP ─────────────────────────────────────────────────
$ZipPath = Join-Path $BuildDir $ZipName
if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath }
Write-Host ""
Write-Host "Creating $ZipName..." -ForegroundColor Cyan
# Use 7-Zip if available (faster, handles locked files), else fall back to Compress-Archive
$7z = Get-Command 7z -ErrorAction SilentlyContinue
if ($7z) {
& 7z a -tzip -mx=7 $ZipPath "$DistDir\*" | Select-Object -Last 3
} else {
Compress-Archive -Path "$DistDir\*" -DestinationPath $ZipPath -CompressionLevel Optimal
}
$zipSize = (Get-Item $ZipPath).Length / 1MB
Write-Host ""
Write-Host "=== Build complete ===" -ForegroundColor Green
Write-Host " Archive: $ZipPath"
Write-Host " Size: $([math]::Round($zipSize, 1)) MB"
Write-Host ""

66
contexts/chrome-tools.md Normal file
View File

@@ -0,0 +1,66 @@
# Chrome Browser Tools (MCP)
**Read this file when using Chrome browser tools** (`mcp__claude-in-chrome__*`) for testing or debugging the frontend.
## Tool Loading
All Chrome MCP tools are deferred — they must be loaded with `ToolSearch` before first use:
```
ToolSearch query="select:mcp__claude-in-chrome__<tool_name>"
```
Commonly used tools:
- `tabs_context_mcp` — get available tabs (call first in every session)
- `navigate` — go to a URL
- `computer` — screenshots, clicks, keyboard, scrolling, zoom
- `read_page` — accessibility tree of page elements
- `find` — find elements by text/selector
- `javascript_tool` — run JS in the page console
- `form_input` — fill form fields
## Browser Tricks
### Hard Reload (bypass cache)
After rebuilding the frontend bundle (`npm run build`), do a hard reload to bypass browser cache:
```
computer action="key" text="ctrl+shift+r"
```
This is equivalent to Ctrl+Shift+R and forces the browser to re-fetch all resources, ignoring cached versions.
### Zoom into UI regions
Use the `zoom` action to inspect small UI elements (icons, badges, text):
```
computer action="zoom" region=[x0, y0, x1, y1]
```
Coordinates define a rectangle from top-left to bottom-right in viewport pixels.
### Scroll to element
Use `scroll_to` with a `ref` from `read_page` to bring an element into view:
```
computer action="scroll_to" ref="ref_123"
```
### Console messages
Use `read_console_messages` to check for JS errors after page load or interactions.
### Network requests
Use `read_network_requests` to inspect API calls, check response codes, and debug loading issues.
## Typical Verification Workflow
1. Rebuild bundle: `npm run build` (from `server/` directory)
2. Hard reload: `ctrl+shift+r`
3. Take screenshot to verify visual changes
4. Zoom into specific regions if needed
5. Check console for errors

269
contexts/frontend.md Normal file
View File

@@ -0,0 +1,269 @@
# Frontend Rules & Conventions
**Read this file when working on frontend tasks** (HTML, CSS, JS, locales, templates).
## CSS Custom Properties (Variables)
Defined in `server/src/wled_controller/static/css/base.css`.
**IMPORTANT:** There is NO `--accent` variable. Always use `--primary-color` for accent/brand color.
### Global (`:root`)
| Variable | Value | Usage |
|---|---|---|
| `--primary-color` | `#4CAF50` | **Accent/brand color** — borders, highlights, active states |
| `--primary-hover` | `#5cb860` | Hover state for primary elements |
| `--primary-contrast` | `#ffffff` | Text on primary background |
| `--danger-color` | `#f44336` | Destructive actions, errors |
| `--warning-color` | `#ff9800` | Warnings |
| `--info-color` | `#2196F3` | Informational highlights |
### Theme-specific (`[data-theme="dark"]` / `[data-theme="light"]`)
| Variable | Dark | Light | Usage |
|---|---|---|---|
| `--bg-color` | `#1a1a1a` | `#f5f5f5` | Page background |
| `--bg-secondary` | `#242424` | `#eee` | Secondary background |
| `--card-bg` | `#2d2d2d` | `#ffffff` | Card/panel background |
| `--text-color` | `#e0e0e0` | `#333333` | Primary text |
| `--text-secondary` | `#999` | `#666` | Secondary text |
| `--text-muted` | `#777` | `#999` | Muted/disabled text |
| `--border-color` | `#404040` | `#e0e0e0` | Borders, dividers |
| `--primary-text-color` | `#66bb6a` | `#3d8b40` | Primary-colored text |
| `--success-color` | `#28a745` | `#2e7d32` | Success indicators |
| `--shadow-color` | `rgba(0,0,0,0.3)` | `rgba(0,0,0,0.12)` | Box shadows |
## UI Conventions for Dialogs
### Hints
Every form field in a modal should have a hint. Use the `.label-row` wrapper with a `?` toggle button:
```html
<div class="form-group">
<div class="label-row">
<label for="my-field" data-i18n="my.label">Label:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="my.label.hint">Hint text</small>
<input type="text" id="my-field">
</div>
```
Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffix on the label key.
### Select dropdowns
Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.
### Empty/None option format
When a selector has an optional entity (e.g., sync clock, processing template, brightness source), the empty option must use the format `None (<description>)` where the description explains what happens when nothing is selected. Use i18n keys, never hardcoded `—` or bare `None`.
Examples:
- `None (no processing template)``t('common.none_no_cspt')`
- `None (no input source)``t('common.none_no_input')`
- `None (use own speed)``t('common.none_own_speed')`
- `None (full brightness)``t('color_strip.composite.brightness.none')`
- `None (device brightness)``t('targets.brightness_vs.none')`
For `EntitySelect` with `allowNone: true`, pass the same i18n string as `noneLabel`.
### Enhanced selectors (IconSelect & EntitySelect)
Plain `<select>` dropdowns should be enhanced with visual selectors depending on the data type:
- **Predefined options** (source types, effect types, palettes, waveforms, viz modes) → use `IconSelect` from `js/core/icon-select.js`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.js` for examples.
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.js`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.js` or `_lineSourceEntitySelect` in `advanced-calibration.js` for examples.
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.js` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
### Modal dirty check (discard unsaved changes)
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.js`:
1. **Subclass Modal** with `snapshotValues()` returning an object of all tracked field values:
```javascript
class MyEditorModal extends Modal {
constructor() { super('my-modal-id'); }
snapshotValues() {
return {
name: document.getElementById('my-name').value,
// ... all form fields
};
}
onForceClose() {
// Optional: cleanup (reset flags, clear state, etc.)
}
}
const myModal = new MyEditorModal();
```
2. **Call `modal.snapshot()`** after the form is fully populated (after `modal.open()`).
3. **Close/cancel button** calls `await modal.close()` — triggers dirty check + confirmation.
4. **Save function** calls `modal.forceClose()` after successful save — skips dirty check.
5. For complex/dynamic state (filter lists, schedule rows, conditions), serialize to JSON string in `snapshotValues()`.
The base class handles: `isDirty()` comparison, confirmation dialog, backdrop click, ESC key, focus trapping, and body scroll lock.
### Card appearance
When creating or modifying entity cards (devices, targets, CSS sources, streams, audio/value sources, templates), **always reference existing cards** of the same or similar type for visual consistency. Cards should have:
- Clone (📋) and Edit (✏️) icon buttons in `.template-card-actions`
- Delete (✕) button as `.card-remove-btn`
- Property badges in `.stream-card-props` with emoji icons
- **Crosslinks**: When a card references another entity (audio source, picture source, capture template, PP template, etc.), make the property badge a clickable link using the `stream-card-link` CSS class and an `onclick` handler calling `navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue)`. Only add the link when the referenced entity is found (to avoid broken navigation). Example: `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-multi','data-id','${id}')">🎵 Name</span>`
### Modal footer buttons
Use **icon-only** buttons (✓ / ✕) matching the device settings modal pattern, **not** text buttons:
```html
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeMyModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
```
### Slider value display
For range sliders, display the current value **inside the label** (not in a separate wrapper). This keeps the value visible next to the property name:
```html
<label for="my-slider"><span data-i18n="my.label">Speed:</span> <span id="my-slider-display">1.0</span></label>
...
<input type="range" id="my-slider" min="0" max="10" step="0.1" value="1.0"
oninput="document.getElementById('my-slider-display').textContent = this.value">
```
Do **not** use a `range-with-value` wrapper div.
### Tutorials
The app has an interactive tutorial system (`static/js/features/tutorials.js`) with a generic engine, spotlight overlay, tooltip positioning, and keyboard navigation. Tutorials exist for:
- **Getting started** (header-level walkthrough of all tabs and controls)
- **Per-tab tutorials** (Dashboard, Targets, Sources, Profiles) triggered by `?` buttons
- **Device card tutorial** and **Calibration tutorial** (context-specific)
When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.js` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`).
## Icons
**Always use SVG icons from the icon system, never text/emoji/Unicode symbols for buttons and UI controls.**
- Icon SVG paths are defined in `static/js/core/icon-paths.js` (Lucide icons, 24×24 viewBox)
- Icon constants are exported from `static/js/core/icons.js` (e.g. `ICON_START`, `ICON_TRASH`, `ICON_EDIT`)
- Use `_svg(path)` wrapper from `icons.js` to create new icon constants from paths
When you need a new icon:
1. Find the Lucide icon at https://lucide.dev
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.js` as a new export
3. Add a corresponding `ICON_*` constant in `icons.js` using `_svg(P.myIcon)`
4. Import and use the constant in your feature module
Common icons: `ICON_START` (play), `ICON_STOP` (power), `ICON_EDIT` (pencil), `ICON_CLONE` (copy), `ICON_TRASH` (trash), `ICON_SETTINGS` (gear), `ICON_TEST` (flask), `ICON_OK` (circle-check), `ICON_WARNING` (triangle-alert), `ICON_HELP` (circle-help), `ICON_LIST_CHECKS` (list-checks), `ICON_CIRCLE_OFF` (circle-off).
For icon-only buttons, use `btn btn-icon` CSS classes. The `.icon` class inside buttons auto-sizes to 16×16.
## Localization (i18n)
**Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.js` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`).
- In JS modules: `import { t } from '../core/i18n.js';` then `showToast(t('my.key'), 'error')`
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.t ? t('key') : 'fallback'`
- In HTML templates: use `data-i18n="key"` for text content, `data-i18n-title="key"` for title attributes, `data-i18n-aria-label="key"` for aria-labels
- Keys follow dotted namespace convention: `feature.context.description` (e.g. `device.error.brightness`, `calibration.saved`)
### Dynamic content and language changes
When a feature module generates HTML with baked-in `t()` calls (e.g., toolbar button titles, legend text), that content won't update when the user switches language. To handle this, listen for the `languageChanged` event and re-render:
```javascript
document.addEventListener('languageChanged', () => {
if (_initialized) _reRender();
});
```
Static HTML using `data-i18n` attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern.
## Bundling & Development Workflow
The frontend uses **esbuild** to bundle all JS modules and CSS files into single files for production.
### Files
- **Entry points:** `static/js/app.js` (JS), `static/css/all.css` (CSS imports all individual sheets)
- **Output:** `static/dist/app.bundle.js` and `static/dist/app.bundle.css` (minified + source maps)
- **Config:** `server/esbuild.mjs`
- **HTML:** `templates/index.html` references the bundles, not individual source files
### Commands (from `server/` directory)
| Command | Description |
|---|---|
| `npm run build` | One-shot bundle + minify (~30ms) |
| `npm run watch` | Watch mode — auto-rebuilds on any JS/CSS file save |
### Development workflow
1. Run `npm run watch` in a terminal (stays running)
2. Edit source files in `static/js/` or `static/css/` as usual
3. esbuild rebuilds the bundle automatically (~30ms)
4. Refresh the browser to see changes
### Dependencies
All JS/CSS dependencies are bundled — **no CDN or external requests** at runtime:
- **Chart.js** — imported in `perf-charts.js`, exposed as `window.Chart` for `targets.js` and `dashboard.js`
- **ELK.js** — imported in `graph-layout.js` for graph auto-layout
- **Fonts** — DM Sans (400-700) and Orbitron (700) woff2 files in `static/fonts/`, declared in `css/fonts.css`
When adding a new JS dependency: `npm install <pkg>` in `server/`, then `import` it in the relevant source file. esbuild bundles it automatically.
### Notes
- The `dist/` directory is gitignored — bundles are build artifacts, run `npm run build` after clone
- Source maps are generated so browser DevTools show original source files
- The server sets `Cache-Control: no-cache` on static JS/CSS/JSON to prevent stale browser caches during development
- GZip compression middleware reduces transfer sizes by ~75%
- **Do not edit files in `static/dist/`** — they are overwritten by the build
## Chrome Browser Tools
See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, browser tricks (hard reload, zoom, console), and verification workflow.
## Duration & Numeric Formatting
### Uptime / duration values
Use `formatUptime(seconds)` from `core/ui.js`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
### Large numbers
Use `formatCompact(n)` from `core/ui.js`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
### Preventing layout shift
Numeric/duration values that update frequently (FPS, uptime, frame counts) **must** use fixed-width styling to prevent layout reflow:
- `font-family: var(--font-mono, monospace)` — equal-width characters
- `font-variant-numeric: tabular-nums` — equal-width digits in proportional fonts
- Fixed `width` or `min-width` on the value container
- `text-align: right` to anchor the growing edge
Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(--font-mono)`, `font-weight: 600`, `min-width: 48px`.
### FPS sparkline charts
Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.js`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.
## Visual Graph Editor
See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.
**IMPORTANT:** When adding or modifying entity types, subtypes, or connection fields, the graph editor files **must** be updated in sync. The graph maintains its own maps of entity colors, labels, icons, connection rules, and cache references. See the "Keeping the graph in sync with entity types" section in `graph-editor.md` for the complete checklist.

101
contexts/graph-editor.md Normal file
View File

@@ -0,0 +1,101 @@
# Visual Graph Editor
**Read this file when working on the graph editor** (`static/js/features/graph-editor.js` and related modules).
## Architecture
The graph editor renders all entities (devices, templates, sources, clocks, targets, scenes, automations) as SVG nodes connected by edges in a left-to-right layered layout.
### Core modules
| File | Responsibility |
|---|---|
| `js/features/graph-editor.js` | Main orchestrator — toolbar, keyboard, search, filter, add-entity menu, port/node drag, minimap |
| `js/core/graph-layout.js` | ELK.js layout, `buildGraph()`, `computePorts()`, entity color/label maps |
| `js/core/graph-nodes.js` | SVG node rendering, overlay buttons, per-node color overrides |
| `js/core/graph-edges.js` | SVG edge rendering (bezier curves, arrowheads, flow dots) |
| `js/core/graph-canvas.js` | Pan/zoom controller with `zoomToPoint()` rAF animation |
| `js/core/graph-connections.js` | CONNECTION_MAP — which fields link entity types, drag-connect/detach logic |
| `css/graph-editor.css` | All graph-specific styles |
### Data flow
1. `loadGraphEditor()``_fetchAllEntities()` fetches all caches in parallel
2. `computeLayout(entities)` builds ELK graph, runs layout → returns `{nodes: Map, edges: Array, bounds}`
3. `computePorts(nodeMap, edges)` assigns port positions and annotates edges with `fromPortY`/`toPortY`
4. Manual position overrides (`_manualPositions`) applied after layout
5. `renderEdges()` + `renderNodes()` paint SVG elements
6. `GraphCanvas` handles pan/zoom via CSS `transform: scale() translate()`
### Edge rendering
Edges always use `_defaultBezier()` (port-aware cubic bezier) — ELK edge routing is ignored because it lacks port awareness, causing misaligned bend points. ELK is only used for node positioning.
### Port system
Nodes have input ports (left) and output ports (right), colored by edge type. Port types are ordered vertically: `template > picture > colorstrip > value > audio > clock > scene > device > default`.
## Keeping the graph in sync with entity types
**CRITICAL:** When adding or modifying entity types in the system, these graph files MUST be updated:
### Adding a new entity type
1. **`graph-layout.js`** — `ENTITY_COLORS`, `ENTITY_LABELS`, `buildGraph()` (add node loop + edge loops)
2. **`graph-layout.js`** — `edgeType()` function if the new type needs a distinct edge color
3. **`graph-nodes.js`** — `KIND_ICONS` (default icon), `SUBTYPE_ICONS` (subtype-specific icons)
4. **`graph-nodes.js`** — `START_STOP_KINDS` or `TEST_KINDS` sets if the entity supports start/stop or test
5. **`graph-connections.js`** — `CONNECTION_MAP` for drag-connect edge creation
6. **`graph-editor.js`** — `ADD_ENTITY_MAP` (add-entity menu entry with window function)
7. **`graph-editor.js`** — `ALL_CACHES` array (for new-entity-focus watcher)
8. **`graph-editor.js`** — `_fetchAllEntities()` (add cache fetch + pass to `computeLayout`)
9. **`core/state.js`** — Add/export the new DataCache
10. **`app.js`** — Import and window-export the add/edit/clone functions
### Adding a new field/connection to an existing entity
1. **`graph-layout.js`** — `buildGraph()` edges section: add `addEdge()` call
2. **`graph-connections.js`** — `CONNECTION_MAP`: add the field entry
3. **`graph-edges.js`** — `EDGE_COLORS` if a new edge type is needed
### Adding a new entity subtype
1. **`graph-nodes.js`** — `SUBTYPE_ICONS[kind]` — add icon for the new subtype
2. **`graph-layout.js`** — `buildGraph()` — ensure `subtype` is extracted from the entity data
## Features & keyboard shortcuts
| Key | Action |
|---|---|
| `/` | Open search |
| `F` | Toggle filter |
| `F11` | Toggle fullscreen |
| `+` | Add entity menu |
| `Escape` | Close filter → close search → deselect all |
| `Delete` | Delete selected edge or node |
| `Arrows / WASD` | Spatial navigation between nodes |
| `Ctrl+A` | Select all nodes |
## Node color overrides
Per-node colors stored in `localStorage` key `graph_node_colors`. The `getNodeColor(nodeId, kind)` function returns the override or falls back to `ENTITY_COLORS[kind]`. The color bar on the left side of each node is clickable to open a native color picker.
## Filter system
The filter bar (toggled with F or toolbar button) filters nodes by name/kind/subtype. Non-matching nodes get the `.graph-filtered-out` CSS class (low opacity, no pointer events). Edges where either endpoint is filtered also dim. Minimap nodes for filtered-out entities become nearly invisible (opacity 0.07).
## Minimap
Rendered as a small SVG with colored rects for each node and a viewport rect. Supports drag-to-pan, resize handles, and position persistence in localStorage.
## Node hover FPS tooltip
Running `output_target` nodes show a floating HTML tooltip on hover (300ms delay). The tooltip is an absolutely-positioned `<div class="graph-node-tooltip">` inside `.graph-container` (not SVG — needed for Chart.js canvas). It displays errors, uptime, and a FPS sparkline (reusing `createFpsSparkline` from `core/chart-utils.js`). The sparkline is seeded from `/api/v1/system/metrics-history` for instant context.
**Hover events** use `pointerover`/`pointerout` with `relatedTarget` check to prevent flicker when the cursor moves between child SVG elements within the same `<g>` node.
**Node titles** display the full entity name (no truncation). Native SVG `<title>` tooltips are omitted on nodes to avoid conflict with the custom tooltip.
## New entity focus
When a user adds an entity via the graph's + menu, a watcher subscribes to all caches, detects the new ID, reloads the graph, and uses `zoomToPoint()` to smoothly fly to the new node with zoom + highlight animation.

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -12,6 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
DOMAIN,
CONF_SERVER_NAME,
CONF_SERVER_URL,
CONF_API_KEY,
DEFAULT_SCAN_INTERVAL,
@@ -27,14 +30,18 @@ from .ws_manager import KeyColorsWebSocketManager
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH,
Platform.SENSOR,
Platform.NUMBER,
Platform.SELECT,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LED Screen Controller from a config entry."""
server_name = entry.data.get(CONF_SERVER_NAME, "LED Screen Controller")
server_url = entry.data[CONF_SERVER_URL]
api_key = entry.data[CONF_API_KEY]
@@ -54,8 +61,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
event_listener = EventStreamListener(hass, server_url, api_key, coordinator)
await event_listener.start()
# Create device entries for each target
# Create device entries for each target and remove stale ones
device_registry = dr.async_get(hass)
current_identifiers: set[tuple[str, str]] = set()
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
@@ -69,10 +77,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, target_id)},
name=info.get("name", target_id),
manufacturer="LED Screen Controller",
manufacturer=server_name,
model=model,
configuration_url=server_url,
)
current_identifiers.add((DOMAIN, target_id))
# Create a single "Scenes" device for scene preset buttons
scenes_identifier = (DOMAIN, f"{entry.entry_id}_scenes")
scene_presets = coordinator.data.get("scene_presets", []) if coordinator.data else []
if scene_presets:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={scenes_identifier},
name="Scenes",
manufacturer=server_name,
model="Scene Presets",
configuration_url=server_url,
)
current_identifiers.add(scenes_identifier)
# Remove devices for targets that no longer exist
for device_entry in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
if not device_entry.identifiers & current_identifiers:
_LOGGER.info("Removing stale device: %s", device_entry.name)
device_registry.async_remove_device(device_entry.id)
# Store data
hass.data.setdefault(DOMAIN, {})
@@ -82,13 +113,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
DATA_EVENT_LISTENER: event_listener,
}
# Track target IDs to detect changes
initial_target_ids = set(
# Track target and scene IDs to detect changes
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."""
nonlocal known_target_ids, known_scene_ids
if not coordinator.data:
return
@@ -100,20 +136,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
state = target_data.get("state") or {}
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
if state.get("processing"):
hass.async_create_task(ws_manager.start_listening(target_id))
if target_id not in ws_manager._connections:
hass.async_create_task(ws_manager.start_listening(target_id))
else:
hass.async_create_task(ws_manager.stop_listening(target_id))
if target_id in ws_manager._connections:
hass.async_create_task(ws_manager.stop_listening(target_id))
# Reload if target list changed
# Reload if target or scene list changed
current_ids = set(targets.keys())
if current_ids != initial_target_ids:
_LOGGER.info("Target list changed, reloading integration")
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)
)
coordinator.async_add_listener(_on_coordinator_update)
# Register set_leds service (once across all entries)
async def handle_set_leds(call) -> None:
"""Handle the set_leds service call."""
source_id = call.data["source_id"]
segments = call.data["segments"]
# Route to the coordinator that owns this source
for entry_data in hass.data[DOMAIN].values():
coord = entry_data.get(DATA_COORDINATOR)
if not coord or not coord.data:
continue
source_ids = {
s["id"] for s in coord.data.get("css_sources", [])
}
if source_id in source_ids:
await coord.push_segments(source_id, segments)
return
_LOGGER.error("No server found with source_id %s", source_id)
if not hass.services.has_service(DOMAIN, "set_leds"):
hass.services.async_register(
DOMAIN,
"set_leds",
handle_set_leds,
schema=vol.Schema({
vol.Required("source_id"): str,
vol.Required("segments"): list,
}),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -129,5 +201,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
# Unregister service if no entries remain
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, "set_leds")
return unload_ok

View File

@@ -0,0 +1,74 @@
"""Button platform for LED Screen Controller — scene preset activation."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up scene preset buttons."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data:
for preset in coordinator.data.get("scene_presets", []):
entities.append(
SceneActivateButton(coordinator, preset, entry.entry_id)
)
async_add_entities(entities)
class SceneActivateButton(CoordinatorEntity, ButtonEntity):
"""Button that activates a scene preset."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
preset: dict[str, Any],
entry_id: str,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self._preset_id = preset["id"]
self._entry_id = entry_id
self._attr_unique_id = f"{entry_id}_scene_{preset['id']}"
self._attr_translation_key = "activate_scene"
self._attr_translation_placeholders = {"scene_name": preset["name"]}
self._attr_icon = "mdi:palette"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information — all scene buttons belong to the Scenes device."""
return {"identifiers": {(DOMAIN, f"{self._entry_id}_scenes")}}
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.data:
return False
return self._preset_id in {
p["id"] for p in self.coordinator.data.get("scene_presets", [])
}
async def async_press(self) -> None:
"""Activate the scene preset."""
await self.coordinator.activate_scene(self._preset_id)

View File

@@ -13,12 +13,13 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, CONF_SERVER_URL, CONF_API_KEY, DEFAULT_TIMEOUT
from .const import DOMAIN, CONF_SERVER_NAME, CONF_SERVER_URL, CONF_API_KEY, DEFAULT_TIMEOUT
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_SERVER_NAME, default="LED Screen Controller"): str,
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str,
vol.Required(CONF_API_KEY): str,
}
@@ -60,7 +61,7 @@ async def validate_server(
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with session.get(
f"{server_url}/api/v1/picture-targets",
f"{server_url}/api/v1/output-targets",
headers=headers,
timeout=timeout,
) as resp:
@@ -87,6 +88,7 @@ class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
server_name = user_input.get(CONF_SERVER_NAME, "LED Screen Controller")
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
api_key = user_input[CONF_API_KEY]
@@ -97,8 +99,9 @@ class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="LED Screen Controller",
title=server_name,
data={
CONF_SERVER_NAME: server_name,
CONF_SERVER_URL: server_url,
CONF_API_KEY: api_key,
},

View File

@@ -3,6 +3,7 @@
DOMAIN = "wled_screen_controller"
# Configuration
CONF_SERVER_NAME = "server_name"
CONF_SERVER_URL = "server_url"
CONF_API_KEY = "api_key"

View File

@@ -37,7 +37,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
self.api_key = api_key
self.server_version = "unknown"
self._auth_headers = {"Authorization": f"Bearer {api_key}"}
self._pattern_cache: dict[str, list[dict]] = {}
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
super().__init__(
hass,
@@ -85,7 +85,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
kc_settings = target.get("key_colors_settings") or {}
template_id = kc_settings.get("pattern_template_id", "")
if template_id:
result["rectangles"] = await self._get_rectangles(
result["rectangles"] = await self._fetch_rectangles(
template_id
)
else:
@@ -107,12 +107,22 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
target_id, data = r
targets_data[target_id] = data
# Fetch devices with capabilities and brightness
devices_data = await self._fetch_devices()
# 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(),
)
)
return {
"targets": targets_data,
"devices": devices_data,
"css_sources": css_sources,
"value_sources": value_sources,
"scene_presets": scene_presets,
"server_version": self.server_version,
}
@@ -126,7 +136,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
try:
async with self.session.get(
f"{self.server_url}/health",
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -136,11 +146,11 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
self.server_version = "unknown"
async def _fetch_targets(self) -> list[dict[str, Any]]:
"""Fetch all picture targets."""
"""Fetch all output targets."""
async with self.session.get(
f"{self.server_url}/api/v1/picture-targets",
f"{self.server_url}/api/v1/output-targets",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -149,9 +159,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async def _fetch_target_state(self, target_id: str) -> dict[str, Any]:
"""Fetch target processing state."""
async with self.session.get(
f"{self.server_url}/api/v1/picture-targets/{target_id}/state",
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
@@ -159,29 +169,24 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async def _fetch_target_metrics(self, target_id: str) -> dict[str, Any]:
"""Fetch target metrics."""
async with self.session.get(
f"{self.server_url}/api/v1/picture-targets/{target_id}/metrics",
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _get_rectangles(self, template_id: str) -> list[dict]:
"""Get rectangles for a pattern template, using cache."""
if template_id in self._pattern_cache:
return self._pattern_cache[template_id]
async def _fetch_rectangles(self, template_id: str) -> list[dict]:
"""Fetch rectangles for a pattern template (no cache — always fresh)."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/pattern-templates/{template_id}",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
rectangles = data.get("rectangles", [])
self._pattern_cache[template_id] = rectangles
return rectangles
return data.get("rectangles", [])
except Exception as err:
_LOGGER.warning(
"Failed to fetch pattern template %s: %s", template_id, err
@@ -194,7 +199,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/devices",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -203,18 +208,16 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
_LOGGER.warning("Failed to fetch devices: %s", err)
return {}
devices_data: dict[str, dict[str, Any]] = {}
for device in devices:
# Fetch brightness for all capable devices in parallel
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
device_id = device["id"]
entry: dict[str, Any] = {"info": device, "brightness": None}
if "brightness_control" in (device.get("capabilities") or []):
try:
async with self.session.get(
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status == 200:
bri_data = await resp.json()
@@ -224,7 +227,19 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"Failed to fetch brightness for device %s: %s",
device_id, err,
)
return device_id, entry
results = await asyncio.gather(
*(fetch_device_entry(d) for d in devices),
return_exceptions=True,
)
devices_data: dict[str, dict[str, Any]] = {}
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Device fetch failed: %s", r)
continue
device_id, entry = r
devices_data[device_id] = entry
return devices_data
@@ -235,7 +250,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"brightness": brightness},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -246,14 +261,31 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
resp.raise_for_status()
await self.async_request_refresh()
async def set_color(self, device_id: str, color: list[int] | None) -> None:
"""Set or clear the static color for a device."""
async with self.session.put(
f"{self.server_url}/api/v1/devices/{device_id}/color",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"color": color},
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set color for device %s: %s %s",
device_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def set_kc_brightness(self, target_id: str, brightness: int) -> None:
"""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/picture-targets/{target_id}",
f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"key_colors_settings": {"brightness": brightness_float}},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -264,12 +296,138 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
resp.raise_for_status()
await self.async_request_refresh()
async def _fetch_css_sources(self) -> list[dict[str, Any]]:
"""Fetch all color strip sources."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/color-strip-sources",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("sources", [])
except Exception as err:
_LOGGER.warning("Failed to fetch CSS sources: %s", err)
return []
async def _fetch_value_sources(self) -> list[dict[str, Any]]:
"""Fetch all value sources."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/value-sources",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("sources", [])
except Exception as err:
_LOGGER.warning("Failed to fetch value sources: %s", err)
return []
async def _fetch_scene_presets(self) -> list[dict[str, Any]]:
"""Fetch all scene presets."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/scene-presets",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("presets", [])
except Exception as err:
_LOGGER.warning("Failed to fetch scene presets: %s", err)
return []
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
"""Push flat color array to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push colors to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
"""Push segment data to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push segments to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def activate_scene(self, preset_id: str) -> None:
"""Activate a scene preset."""
async with self.session.post(
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to activate scene %s: %s %s",
preset_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def update_source(self, source_id: str, **kwargs: Any) -> None:
"""Update a color strip source's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def update_target(self, target_id: str, **kwargs: Any) -> None:
"""Update an output target's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update target %s: %s %s",
target_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def start_processing(self, target_id: str) -> None:
"""Start processing for a target."""
async with self.session.post(
f"{self.server_url}/api/v1/picture-targets/{target_id}/start",
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already processing", target_id)
@@ -285,9 +443,9 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async def stop_processing(self, target_id: str) -> None:
"""Stop processing for a target."""
async with self.session.post(
f"{self.server_url}/api/v1/picture-targets/{target_id}/stop",
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already stopped", target_id)

View File

@@ -0,0 +1,151 @@
"""Light platform for LED Screen Controller (api_input CSS sources)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller api_input lights."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data:
for source in coordinator.data.get("css_sources", []):
if source.get("source_type") == "api_input":
entities.append(
ApiInputLight(coordinator, source, entry.entry_id)
)
async_add_entities(entities)
class ApiInputLight(CoordinatorEntity, LightEntity):
"""Representation of an api_input CSS source as a light entity."""
_attr_has_entity_name = True
_attr_color_mode = ColorMode.RGB
_attr_supported_color_modes = {ColorMode.RGB}
_attr_translation_key = "api_input_light"
_attr_icon = "mdi:led-strip-variant"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
source: dict[str, Any],
entry_id: str,
) -> None:
"""Initialize the light."""
super().__init__(coordinator)
self._source_id: str = source["id"]
self._source_name: str = source.get("name", self._source_id)
self._entry_id = entry_id
self._attr_unique_id = f"{self._source_id}_light"
# Restore state from fallback_color
fallback = self._get_fallback_color()
is_off = fallback == [0, 0, 0]
self._is_on: bool = not is_off
self._rgb_color: tuple[int, int, int] = (
(255, 255, 255) if is_off else tuple(fallback) # type: ignore[arg-type]
)
self._brightness: int = 255
@property
def device_info(self) -> dict[str, Any]:
"""Return device information — one virtual device per api_input source."""
return {
"identifiers": {(DOMAIN, self._source_id)},
"name": self._source_name,
"manufacturer": "WLED Screen Controller",
"model": "API Input CSS Source",
}
@property
def name(self) -> str:
"""Return the entity name."""
return self._source_name
@property
def is_on(self) -> bool:
"""Return true if the light is on."""
return self._is_on
@property
def rgb_color(self) -> tuple[int, int, int]:
"""Return the current RGB color."""
return self._rgb_color
@property
def brightness(self) -> int:
"""Return the current brightness (0-255)."""
return self._brightness
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light, optionally setting color and brightness."""
if ATTR_RGB_COLOR in kwargs:
self._rgb_color = kwargs[ATTR_RGB_COLOR]
if ATTR_BRIGHTNESS in kwargs:
self._brightness = kwargs[ATTR_BRIGHTNESS]
# Scale RGB by brightness
scale = self._brightness / 255
r, g, b = self._rgb_color
scaled = [round(r * scale), round(g * scale), round(b * scale)]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": scaled}],
)
# Update fallback_color so the color persists beyond the timeout
await self.coordinator.update_source(
self._source_id, fallback_color=scaled,
)
self._is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light by pushing black and setting fallback to black."""
off_color = [0, 0, 0]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": off_color}],
)
await self.coordinator.update_source(
self._source_id, fallback_color=off_color,
)
self._is_on = False
self.async_write_ha_state()
def _get_fallback_color(self) -> list[int]:
"""Read fallback_color from the source config in coordinator data."""
if not self.coordinator.data:
return [0, 0, 0]
for source in self.coordinator.data.get("css_sources", []):
if source.get("id") == self._source_id:
fallback = source.get("fallback_color")
if fallback and len(fallback) >= 3:
return list(fallback[:3])
break
return [0, 0, 0]

View File

@@ -51,7 +51,7 @@ async def async_setup_entry(
continue
capabilities = device_data.get("info", {}).get("capabilities") or []
if "brightness_control" not in capabilities:
if "brightness_control" not in capabilities or "static_color" in capabilities:
continue
entities.append(

View File

@@ -0,0 +1,177 @@
"""Select platform for LED Screen Controller (CSS source & brightness source)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
NONE_OPTION = "— None —"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller select entities."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities: list[SelectEntity] = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
# Only LED targets
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
continue
entities.append(
CSSSourceSelect(coordinator, target_id, entry.entry_id)
)
entities.append(
BrightnessSourceSelect(coordinator, target_id, entry.entry_id)
)
async_add_entities(entities)
class CSSSourceSelect(CoordinatorEntity, SelectEntity):
"""Select entity for choosing a color strip source for an LED target."""
_attr_has_entity_name = True
_attr_icon = "mdi:palette"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_css_source"
self._attr_translation_key = "color_strip_source"
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def options(self) -> list[str]:
if not self.coordinator.data:
return []
sources = self.coordinator.data.get("css_sources") or []
return [s["name"] for s in sources]
@property
def current_option(self) -> str | None:
if not self.coordinator.data:
return None
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data:
return None
current_id = target_data["info"].get("color_strip_source_id", "")
sources = self.coordinator.data.get("css_sources") or []
for s in sources:
if s["id"] == current_id:
return s["name"]
return None
@property
def available(self) -> bool:
if not self.coordinator.data:
return False
return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None:
source_id = self._name_to_id_map().get(option)
if source_id is None:
_LOGGER.error("CSS source not found: %s", option)
return
await self.coordinator.update_target(
self._target_id, color_strip_source_id=source_id
)
def _name_to_id_map(self) -> dict[str, str]:
sources = (self.coordinator.data or {}).get("css_sources") or []
return {s["name"]: s["id"] for s in sources}
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
"""Select entity for choosing a brightness value source for an LED target."""
_attr_has_entity_name = True
_attr_icon = "mdi:brightness-auto"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_brightness_source"
self._attr_translation_key = "brightness_source"
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def options(self) -> list[str]:
if not self.coordinator.data:
return [NONE_OPTION]
sources = self.coordinator.data.get("value_sources") or []
return [NONE_OPTION] + [s["name"] for s in sources]
@property
def current_option(self) -> str | None:
if not self.coordinator.data:
return None
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data:
return None
current_id = target_data["info"].get("brightness_value_source_id", "")
if not current_id:
return NONE_OPTION
sources = self.coordinator.data.get("value_sources") or []
for s in sources:
if s["id"] == current_id:
return s["name"]
return NONE_OPTION
@property
def available(self) -> bool:
if not self.coordinator.data:
return False
return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None:
if option == NONE_OPTION:
source_id = ""
else:
name_map = {
s["name"]: s["id"]
for s in (self.coordinator.data or {}).get("value_sources") or []
}
source_id = name_map.get(option)
if source_id is None:
_LOGGER.error("Value source not found: %s", option)
return
await self.coordinator.update_target(
self._target_id, brightness_value_source_id=source_id
)

View File

@@ -0,0 +1,19 @@
set_leds:
name: Set LEDs
description: Push segment data to an api_input color strip source
fields:
source_id:
name: Source ID
description: The api_input CSS source ID (e.g., css_abc12345)
required: true
selector:
text:
segments:
name: Segments
description: >
List of segment objects. Each segment has: start (int), length (int),
mode ("solid"/"per_pixel"/"gradient"), color ([R,G,B] for solid),
colors ([[R,G,B],...] for per_pixel/gradient)
required: true
selector:
object:

View File

@@ -5,10 +5,12 @@
"title": "Set up LED Screen Controller",
"description": "Enter the URL and API key for your LED Screen Controller server.",
"data": {
"server_name": "Server Name",
"server_url": "Server URL",
"api_key": "API Key"
},
"data_description": {
"server_name": "Display name for this server in Home Assistant",
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
"api_key": "API key from your server's configuration file"
}
@@ -24,6 +26,16 @@
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
@@ -50,6 +62,30 @@
"brightness": {
"name": "Brightness"
}
},
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
}
}
},
"services": {
"set_leds": {
"name": "Set LEDs",
"description": "Push segment data to an api_input color strip source.",
"fields": {
"source_id": {
"name": "Source ID",
"description": "The api_input CSS source ID (e.g., css_abc12345)."
},
"segments": {
"name": "Segments",
"description": "List of segment objects with start, length, mode, and color/colors fields."
}
}
}
}
}

View File

@@ -5,10 +5,12 @@
"title": "Set up LED Screen Controller",
"description": "Enter the URL and API key for your LED Screen Controller server.",
"data": {
"server_name": "Server Name",
"server_url": "Server URL",
"api_key": "API Key"
},
"data_description": {
"server_name": "Display name for this server in Home Assistant",
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
"api_key": "API key from your server's configuration file"
}
@@ -24,6 +26,16 @@
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
@@ -50,6 +62,14 @@
"brightness": {
"name": "Brightness"
}
},
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
}
}
}
}

View File

@@ -5,10 +5,12 @@
"title": "Настройка LED Screen Controller",
"description": "Введите URL и API-ключ вашего сервера LED Screen Controller.",
"data": {
"server_name": "Имя сервера",
"server_url": "URL сервера",
"api_key": "API-ключ"
},
"data_description": {
"server_name": "Отображаемое имя сервера в Home Assistant",
"server_url": "URL сервера LED Screen Controller (например, http://192.168.1.100:8080)",
"api_key": "API-ключ из конфигурационного файла сервера"
}
@@ -24,6 +26,16 @@
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Подсветка"
}
},
"switch": {
"processing": {
"name": "Обработка"
@@ -50,6 +62,14 @@
"brightness": {
"name": "Яркость"
}
},
"select": {
"color_strip_source": {
"name": "Источник цветовой полосы"
},
"brightness_source": {
"name": "Источник яркости"
}
}
}
}

View File

@@ -40,7 +40,7 @@ class KeyColorsWebSocketManager:
ws_base = self._server_url.replace("http://", "ws://").replace(
"https://", "wss://"
)
return f"{ws_base}/api/v1/picture-targets/{target_id}/ws?token={self._api_key}"
return f"{ws_base}/api/v1/output-targets/{target_id}/ws?token={self._api_key}"
async def start_listening(self, target_id: str) -> None:
"""Start WebSocket connection for a target."""

View File

@@ -201,12 +201,11 @@ Get processing settings.
{
"display_index": 0,
"fps": 30,
"border_width": 10,
"color_correction": {
"gamma": 2.2,
"saturation": 1.0,
"brightness": 1.0
}
"brightness": 1.0,
"smoothing": 0.3,
"interpolation_mode": "average",
"standby_interval": 1.0,
"state_check_interval": 30
}
```
@@ -219,12 +218,7 @@ Update processing settings.
{
"display_index": 1,
"fps": 60,
"border_width": 15,
"color_correction": {
"gamma": 2.4,
"saturation": 1.2,
"brightness": 0.8
}
"brightness": 0.8
}
```

View File

@@ -0,0 +1,30 @@
# Feature Context: Demo Mode
## Current State
Starting implementation. No changes made yet.
## Key Architecture Notes
- `EngineRegistry` (class-level dict) holds capture engines, auto-registered in `capture_engines/__init__.py`
- `AudioEngineRegistry` (class-level dict) holds audio engines, auto-registered in `audio/__init__.py`
- `LEDDeviceProvider` instances registered via `register_provider()` in `led_client.py`
- Already has `MockDeviceProvider` + `MockClient` (device type "mock") for testing
- Config is `pydantic_settings.BaseSettings` in `config.py`, loaded from YAML + env vars
- Frontend header in `templates/index.html` line 27-31: title + version badge
- Frontend bundle: `cd server && npm run build` (esbuild)
- Data stored as JSON in `data/` directory, paths configured via `StorageConfig`
## Temporary Workarounds
- None yet
## Cross-Phase Dependencies
- Phase 1 (config flag) is foundational — all other phases depend on `is_demo_mode()`
- Phase 2 & 3 (engines) can be done independently of each other
- Phase 4 (seed data) depends on knowing what entities to create, which is informed by phases 2-3
- Phase 5 (frontend) depends on the system info API field from phase 1
- Phase 6 (engine resolution) depends on engines existing from phases 2-3
## Implementation Notes
- Demo mode activated via `WLED_DEMO=true` env var or `demo: true` in YAML config
- Isolated data directory `data/demo/` keeps demo entities separate from real config
- Demo engines use `ENGINE_TYPE = "demo"` and are always registered but return `is_available() = True` only in demo mode
- The existing `MockDeviceProvider`/`MockClient` can be reused or extended for demo device output

44
plans/demo-mode/PLAN.md Normal file
View File

@@ -0,0 +1,44 @@
# Feature: Demo Mode
**Branch:** `feature/demo-mode`
**Base branch:** `master`
**Created:** 2026-03-20
**Status:** 🟡 In Progress
**Strategy:** Big Bang
**Mode:** Automated
**Execution:** Orchestrator
## Summary
Add a demo mode that allows users to explore and test the app without real hardware. Virtual capture engines, audio engines, and device providers replace real hardware. An isolated data directory with seed data provides a fully populated sandbox. A visual indicator in the UI makes it clear the app is running in demo mode.
## Build & Test Commands
- **Build (frontend):** `cd server && npm run build`
- **Typecheck (frontend):** `cd server && npm run typecheck`
- **Test (backend):** `cd server && python -m pytest ../tests/ -x`
- **Server start:** `cd server && python -m wled_controller.main`
## Phases
- [x] Phase 1: Demo Mode Config & Flag [domain: backend] → [subplan](./phase-1-config-flag.md)
- [x] Phase 2: Virtual Capture Engine [domain: backend] → [subplan](./phase-2-virtual-capture-engine.md)
- [x] Phase 3: Virtual Audio Engine [domain: backend] → [subplan](./phase-3-virtual-audio-engine.md)
- [x] Phase 4: Demo Device Provider & Seed Data [domain: backend] → [subplan](./phase-4-demo-device-seed-data.md)
- [x] Phase 5: Frontend Demo Indicator & Sandbox UX [domain: fullstack] → [subplan](./phase-5-frontend-demo-ux.md)
- [x] Phase 6: Demo-only Engine Resolution [domain: backend] → [subplan](./phase-6-engine-resolution.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Config & Flag | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 2: Virtual Capture Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 3: Virtual Audio Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 4: Demo Device & Seed Data | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 5: Frontend Demo UX | fullstack | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 6: Engine Resolution | backend | ✅ Done | ✅ | ✅ | ⬜ |
## Final Review
- [ ] Comprehensive code review
- [ ] Full build passes
- [ ] Full test suite passes
- [ ] Merged to `master`

View File

@@ -0,0 +1,42 @@
# Phase 1: Demo Mode Config & Flag
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Add a `demo` boolean flag to the application configuration and expose it to the frontend via the system info API. When demo mode is active, the server uses an isolated data directory so demo entities don't pollute real user data.
## Tasks
- [ ] Task 1: Add `demo: bool = False` field to `Config` class in `config.py`
- [ ] Task 2: Add a module-level helper `is_demo_mode() -> bool` in `config.py` for easy import
- [ ] Task 3: Modify `StorageConfig` path resolution: when `demo=True`, prefix all storage paths with `data/demo/` instead of `data/`
- [ ] Task 4: Expose `demo_mode: bool` in the existing `GET /api/v1/system/info` endpoint response
- [ ] Task 5: Add `WLED_DEMO=true` env var support (already handled by pydantic-settings env prefix `WLED_`)
## Files to Modify/Create
- `server/src/wled_controller/config.py` — Add `demo` field, `is_demo_mode()` helper, storage path override
- `server/src/wled_controller/api/routes/system.py` — Add `demo_mode` to system info response
- `server/src/wled_controller/api/schemas/system.py` — Add `demo_mode` field to response schema
## Acceptance Criteria
- `Config(demo=True)` is accepted; default is `False`
- `WLED_DEMO=true` activates demo mode
- `is_demo_mode()` returns the correct value
- When demo mode is on, all storage files resolve under `data/demo/`
- `GET /api/v1/system/info` includes `demo_mode: true/false`
## Notes
- The env var will be `WLED_DEMO` because of `env_prefix="WLED_"` in pydantic-settings
- Storage path override should happen at `Config` construction time, not lazily
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->

View File

@@ -0,0 +1,48 @@
# Phase 2: Virtual Capture Engine
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create a `DemoCaptureEngine` that provides virtual displays and produces animated test pattern frames, allowing screen capture workflows to function in demo mode without real monitors.
## Tasks
- [ ] Task 1: Create `server/src/wled_controller/core/capture_engines/demo_engine.py` with `DemoCaptureEngine` and `DemoCaptureStream`
- [ ] Task 2: `DemoCaptureEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000` (highest in demo mode)
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
- [ ] Task 4: `get_available_displays()` returns 3 virtual displays:
- "Demo Display 1080p" (1920×1080)
- "Demo Ultrawide" (3440×1440)
- "Demo Portrait" (1080×1920)
- [ ] Task 5: `DemoCaptureStream.capture_frame()` produces animated test patterns:
- Horizontally scrolling rainbow gradient (simple, visually clear)
- Uses `time.time()` for animation so frames change over time
- Returns proper `ScreenCapture` with RGB numpy array
- [ ] Task 6: Register `DemoCaptureEngine` in `capture_engines/__init__.py`
## Files to Modify/Create
- `server/src/wled_controller/core/capture_engines/demo_engine.py` — New file: DemoCaptureEngine + DemoCaptureStream
- `server/src/wled_controller/core/capture_engines/__init__.py` — Register DemoCaptureEngine
## Acceptance Criteria
- `DemoCaptureEngine.is_available()` is True only in demo mode
- Virtual displays appear in the display list API when in demo mode
- `capture_frame()` returns valid RGB frames that change over time
- Engine is properly registered in EngineRegistry
## Notes
- Test patterns should be computationally cheap (no heavy image processing)
- Use numpy operations for pattern generation (vectorized, fast)
- Frame dimensions must match the virtual display dimensions
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->

View File

@@ -0,0 +1,47 @@
# Phase 3: Virtual Audio Engine
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create a `DemoAudioEngine` that provides virtual audio devices and produces synthetic audio data, enabling audio-reactive visualizations in demo mode.
## Tasks
- [ ] Task 1: Create `server/src/wled_controller/core/audio/demo_engine.py` with `DemoAudioEngine` and `DemoAudioCaptureStream`
- [ ] Task 2: `DemoAudioEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000`
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
- [ ] Task 4: `enumerate_devices()` returns 2 virtual devices:
- "Demo Microphone" (input, not loopback)
- "Demo System Audio" (loopback)
- [ ] Task 5: `DemoAudioCaptureStream` implements:
- `channels = 2`, `sample_rate = 44100`, `chunk_size = 1024`
- `read_chunk()` produces synthetic audio: a mix of sine waves with slowly varying frequencies to simulate music-like beat patterns
- Returns proper float32 ndarray
- [ ] Task 6: Register `DemoAudioEngine` in `audio/__init__.py`
## Files to Modify/Create
- `server/src/wled_controller/core/audio/demo_engine.py` — New file: DemoAudioEngine + DemoAudioCaptureStream
- `server/src/wled_controller/core/audio/__init__.py` — Register DemoAudioEngine
## Acceptance Criteria
- `DemoAudioEngine.is_available()` is True only in demo mode
- Virtual audio devices appear in audio device enumeration when in demo mode
- `read_chunk()` returns valid float32 audio data that varies over time
- Audio analyzer produces non-trivial frequency band data from the synthetic signal
## Notes
- Synthetic audio should produce interesting FFT results (multiple frequencies, amplitude modulation)
- Keep it computationally lightweight
- Must conform to `AudioCaptureStreamBase` interface exactly
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->

View File

@@ -0,0 +1,54 @@
# Phase 4: Demo Device Provider & Seed Data
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create a demo device provider that exposes discoverable virtual LED devices, and build a seed data generator that populates the demo data directory with sample entities on first run.
## Tasks
- [ ] Task 1: Create `server/src/wled_controller/core/devices/demo_provider.py``DemoDeviceProvider` extending `LEDDeviceProvider`:
- `device_type = "demo"`
- `capabilities = {"manual_led_count", "power_control", "brightness_control", "static_color"}`
- `create_client()` returns a `MockClient` (reuse existing)
- `discover()` returns 3 pre-defined virtual devices:
- "Demo LED Strip" (60 LEDs, ip="demo-strip")
- "Demo LED Matrix" (256 LEDs / 16×16, ip="demo-matrix")
- "Demo LED Ring" (24 LEDs, ip="demo-ring")
- `check_health()` always returns online with simulated ~2ms latency
- `validate_device()` returns `{"led_count": <from url>}`
- [ ] Task 2: Register `DemoDeviceProvider` in `led_client.py` `_register_builtin_providers()`
- [ ] Task 3: Create `server/src/wled_controller/core/demo_seed.py` — seed data generator:
- Function `seed_demo_data(storage_config: StorageConfig)` that checks if demo data dir is empty and populates it
- Seed entities: 3 devices (matching discover results), 2 output targets, 2 picture sources (using demo engine), 2 CSS sources (gradient + color_cycle), 1 audio source (using demo engine), 1 scene preset, 1 automation
- Use proper ID formats matching existing conventions (e.g., `dev_<hex>`, `tgt_<hex>`, etc.)
- [ ] Task 4: Call `seed_demo_data()` during server startup in `main.py` when demo mode is active (before stores are loaded)
## Files to Modify/Create
- `server/src/wled_controller/core/devices/demo_provider.py` — New: DemoDeviceProvider
- `server/src/wled_controller/core/devices/led_client.py` — Register DemoDeviceProvider
- `server/src/wled_controller/core/demo_seed.py` — New: seed data generator
- `server/src/wled_controller/main.py` — Call seed on demo startup
## Acceptance Criteria
- Demo devices appear in discovery results when in demo mode
- Seed data populates `data/demo/` with valid JSON files on first demo run
- Subsequent demo runs don't overwrite existing demo data
- All seeded entities load correctly in stores
## Notes
- Seed data must match the exact schema expected by each store (look at existing JSON files for format)
- Use the entity dataclass `to_dict()` / store patterns to generate valid data
- Demo discovery should NOT appear when not in demo mode
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->

View File

@@ -0,0 +1,50 @@
# Phase 5: Frontend Demo Indicator & Sandbox UX
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Add visual indicators in the frontend that clearly communicate demo mode status to the user, including a badge, dismissible banner, and engine labeling.
## Tasks
- [ ] Task 1: Add `demo_mode` field to system info API response schema (if not already done in Phase 1)
- [ ] Task 2: In frontend initialization (`app.ts` or `state.ts`), fetch system info and store `demoMode` in app state
- [ ] Task 3: Add `<span class="demo-badge" id="demo-badge" style="display:none">DEMO</span>` next to app title in `index.html` header
- [ ] Task 4: CSS for `.demo-badge`: amber/yellow pill shape, subtle pulse animation, clearly visible but not distracting
- [ ] Task 5: On app load, if `demoMode` is true: show badge, set `document.body.dataset.demo = 'true'`
- [ ] Task 6: Add a dismissible demo banner at the top of the page: "You're in demo mode — all devices and data are virtual. No real hardware is used." with a dismiss (×) button. Store dismissal in localStorage.
- [ ] Task 7: Add i18n keys for demo badge and banner text in `en.json`, `ru.json`, `zh.json`
- [ ] Task 8: In engine/display dropdowns, demo engines should display with "Demo: " prefix for clarity
## Files to Modify/Create
- `server/src/wled_controller/templates/index.html` — Demo badge + banner HTML
- `server/src/wled_controller/static/css/app.css` — Demo badge + banner styles
- `server/src/wled_controller/static/js/app.ts` — Demo mode detection and UI toggle
- `server/src/wled_controller/static/js/core/state.ts` — Store demo mode flag
- `server/src/wled_controller/static/locales/en.json` — i18n keys
- `server/src/wled_controller/static/locales/ru.json` — i18n keys
- `server/src/wled_controller/static/locales/zh.json` — i18n keys
## Acceptance Criteria
- Demo badge visible next to "LED Grab" title when in demo mode
- Demo badge hidden when not in demo mode
- Banner appears on first demo visit, can be dismissed, stays dismissed across refreshes
- Engine dropdowns clearly label demo engines
- All text is localized
## Notes
- Badge should use `--warning-color` or a custom amber for the pill
- Banner should be a thin strip, not intrusive
- `localStorage` key: `demo-banner-dismissed`
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->

View File

@@ -0,0 +1,46 @@
# Phase 6: Demo-only Engine Resolution
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Ensure demo engines are the primary/preferred engines in demo mode, and are hidden when not in demo mode. This makes demo mode act as a "virtual platform" where only demo engines resolve.
## Tasks
- [ ] Task 1: Modify `EngineRegistry.get_available_engines()` to filter out engines with `ENGINE_TYPE == "demo"` when not in demo mode (they report `is_available()=False` anyway, but belt-and-suspenders)
- [ ] Task 2: Modify `AudioEngineRegistry.get_available_engines()` similarly
- [ ] Task 3: In demo mode, `get_best_available_engine()` should return the demo engine (already handled by priority=1000, but verify)
- [ ] Task 4: Modify the `GET /api/v1/config/displays` endpoint: in demo mode, default to demo engine displays if no engine_type specified
- [ ] Task 5: Modify the audio engine listing endpoint similarly
- [ ] Task 6: Ensure `DemoDeviceProvider.discover()` only returns devices when in demo mode
- [ ] Task 7: End-to-end verification: start server in demo mode, verify only demo engines/devices appear in API responses
## Files to Modify/Create
- `server/src/wled_controller/core/capture_engines/factory.py` — Filter demo engines
- `server/src/wled_controller/core/audio/factory.py` — Filter demo engines
- `server/src/wled_controller/api/routes/system.py` — Display endpoint defaults
- `server/src/wled_controller/api/routes/audio_templates.py` — Audio engine listing
- `server/src/wled_controller/core/devices/demo_provider.py` — Guard discover()
## Acceptance Criteria
- In demo mode: demo engines are primary, real engines may also be listed but demo is default
- Not in demo mode: demo engines are completely hidden from all API responses
- Display list defaults to demo displays in demo mode
- Audio device list defaults to demo devices in demo mode
## Notes
- This is the "demo OS identifier" concept — demo mode acts as a virtual platform
- Be careful not to break existing behavior when demo=False (default)
- The demo engines already have `is_available() = is_demo_mode()`, so the main concern is UI defaults
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->

View File

@@ -54,7 +54,7 @@ netstat -an | grep 8080
- **Permission errors**: Ensure file permissions allow Python to execute
#### Files that DON'T require restart:
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - these are served directly
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - but you **MUST rebuild the bundle** after changes: `cd server && npm run build`
- Locale files (`static/locales/*.json`) - loaded by frontend
- Documentation files (`*.md`)
- Configuration files in `config/` if server supports hot-reload (check implementation)
@@ -130,6 +130,31 @@ After restarting the server with new code:
## Frontend UI Patterns
### Entity Cards
All entity cards (devices, targets, CSS sources, streams, scenes, automations, etc.) **must support clone functionality**. Clone buttons use the `ICON_CLONE` (📋) icon in `.card-actions`.
**Clone pattern**: Clone must open the entity's add/create modal with fields prefilled from the cloned item. It must **never** silently create a duplicate — the user should review and confirm.
Implementation:
1. Export a `cloneMyEntity(id)` function that fetches (or finds in cache) the entity data
2. Call the add/create modal function, passing the entity data as `cloneData`
3. In the modal opener, detect clone mode (no ID + cloneData present) and prefill all fields
4. Append `' (Copy)'` to the name
5. Set the modal title to the "add" variant (not "edit")
6. The save action creates a new entity (POST), not an update (PUT)
```javascript
export async function cloneMyEntity(id) {
const entity = myCache.data.find(e => e.id === id);
if (!entity) return;
showMyEditor(null, entity); // null id = create mode, entity = cloneData
}
```
Register the clone function in `app.js` window exports so inline `onclick` handlers can call it.
### Modal Dialogs
**IMPORTANT**: All modal dialogs must follow these standards for consistent UX:

View File

@@ -18,7 +18,7 @@ RUN apt-get update && apt-get install -y \
COPY pyproject.toml .
COPY src/ ./src/
COPY config/ ./config/
RUN pip install --no-cache-dir .
RUN pip install --no-cache-dir ".[notifications]"
# Create directories for data and logs
RUN mkdir -p /app/data /app/logs

View File

@@ -17,9 +17,18 @@ storage:
templates_file: "data/capture_templates.json"
postprocessing_templates_file: "data/postprocessing_templates.json"
picture_sources_file: "data/picture_sources.json"
picture_targets_file: "data/picture_targets.json"
output_targets_file: "data/output_targets.json"
pattern_templates_file: "data/pattern_templates.json"
mqtt:
enabled: false
broker_host: "localhost"
broker_port: 1883
username: ""
password: ""
client_id: "ledgrab"
base_topic: "ledgrab"
logging:
format: "json" # json or text
file: "logs/wled_controller.log"

View File

@@ -0,0 +1,34 @@
# Demo mode configuration
# Loaded automatically when WLED_DEMO=true is set.
# Uses isolated data directory (data/demo/) and a pre-configured API key
# so the demo works out of the box with zero setup.
demo: true
server:
host: "0.0.0.0"
port: 8081
log_level: "INFO"
cors_origins:
- "*"
auth:
api_keys:
demo: "demo"
storage:
devices_file: "data/devices.json"
templates_file: "data/capture_templates.json"
postprocessing_templates_file: "data/postprocessing_templates.json"
picture_sources_file: "data/picture_sources.json"
output_targets_file: "data/output_targets.json"
pattern_templates_file: "data/pattern_templates.json"
mqtt:
enabled: false
logging:
format: "text"
file: "logs/wled_controller.log"
max_size_mb: 100
backup_count: 5

View File

@@ -15,7 +15,7 @@ storage:
templates_file: "data/capture_templates.json"
postprocessing_templates_file: "data/postprocessing_templates.json"
picture_sources_file: "data/picture_sources.json"
picture_targets_file: "data/picture_targets.json"
output_targets_file: "data/output_targets.json"
pattern_templates_file: "data/pattern_templates.json"
logging:

43
server/esbuild.mjs Normal file
View File

@@ -0,0 +1,43 @@
import * as esbuild from 'esbuild';
const srcDir = 'src/wled_controller/static';
const outDir = `${srcDir}/dist`;
const watch = process.argv.includes('--watch');
/** @type {esbuild.BuildOptions} */
const jsOpts = {
entryPoints: [`${srcDir}/js/app.ts`],
bundle: true,
format: 'iife',
outfile: `${outDir}/app.bundle.js`,
minify: true,
sourcemap: true,
target: ['es2020'],
logLevel: 'info',
};
/** @type {esbuild.BuildOptions} */
const cssOpts = {
entryPoints: [`${srcDir}/css/all.css`],
bundle: true,
outdir: outDir,
outbase: `${srcDir}/css`,
minify: true,
sourcemap: true,
logLevel: 'info',
loader: { '.woff2': 'file' },
assetNames: '[name]',
entryNames: 'app.bundle',
};
if (watch) {
const jsCtx = await esbuild.context(jsOpts);
const cssCtx = await esbuild.context(cssOpts);
await jsCtx.watch();
await cssCtx.watch();
console.log('Watching for changes...');
} else {
await esbuild.build(jsOpts);
await esbuild.build(cssOpts);
}

754
server/package-lock.json generated Normal file
View File

@@ -0,0 +1,754 @@
{
"name": "server",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "server",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"chart.js": "^4.5.1",
"elkjs": "^0.11.1"
},
"devDependencies": {
"esbuild": "^0.27.4",
"typescript": "^5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/elkjs": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
},
"dependencies": {
"@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"dev": true,
"optional": true
},
"@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"dev": true,
"optional": true
},
"@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"dev": true,
"optional": true
},
"@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"dev": true,
"optional": true
},
"@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"dev": true,
"optional": true
},
"@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"dev": true,
"optional": true
},
"@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"dev": true,
"optional": true
},
"@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"dev": true,
"optional": true
},
"@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"dev": true,
"optional": true
},
"@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"dev": true,
"optional": true
},
"@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"dev": true,
"optional": true
},
"@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"dev": true,
"optional": true
},
"@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"dev": true,
"optional": true
},
"@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"dev": true,
"optional": true
},
"@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"dev": true,
"optional": true
},
"@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"dev": true,
"optional": true
},
"@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"dev": true,
"optional": true
},
"@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"dev": true,
"optional": true
},
"@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"requires": {
"@kurkle/color": "^0.3.0"
}
},
"elkjs": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="
},
"esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"requires": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true
}
}
}

26
server/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "server",
"version": "1.0.0",
"description": "High-performance FastAPI server that captures screen content and controls WLED devices for ambient lighting.",
"main": "index.js",
"directories": {
"doc": "docs",
"test": "tests"
},
"scripts": {
"build": "node esbuild.mjs",
"watch": "node esbuild.mjs --watch",
"typecheck": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"esbuild": "^0.27.4",
"typescript": "^5.9.3"
},
"dependencies": {
"chart.js": "^4.5.1",
"elkjs": "^0.11.1"
}
}

View File

@@ -36,11 +36,16 @@ dependencies = [
"python-json-logger>=3.1.0",
"python-dateutil>=2.9.0",
"python-multipart>=0.0.12",
"jinja2>=3.1.0",
"wmi>=1.5.1; sys_platform == 'win32'",
"zeroconf>=0.131.0",
"pyserial>=3.5",
"psutil>=5.9.0",
"nvidia-ml-py>=12.0.0; sys_platform == 'win32'",
"nvidia-ml-py>=12.0.0",
"PyAudioWPatch>=0.2.12; sys_platform == 'win32'",
"sounddevice>=0.5",
"aiomqtt>=2.0.0",
"openrgb-python>=0.2.15",
]
[project.optional-dependencies]
@@ -52,6 +57,14 @@ dev = [
"black>=24.0.0",
"ruff>=0.6.0",
]
camera = [
"opencv-python-headless>=4.8.0",
]
# OS notification capture
notifications = [
"winsdk>=1.0.0b10; sys_platform == 'win32'",
"dbus-next>=0.2.3; sys_platform == 'linux'",
]
# High-performance screen capture engines (Windows only)
perf = [
"dxcam>=0.0.5; sys_platform == 'win32'",

37
server/restart.ps1 Normal file
View File

@@ -0,0 +1,37 @@
# Restart the WLED Screen Controller server
# Stop any running instance
$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*wled_controller.main*' }
foreach ($p in $procs) {
Write-Host "Stopping server (PID $($p.ProcessId))..."
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
}
if ($procs) { Start-Sleep -Seconds 2 }
# Merge registry PATH with current PATH so newly-installed tools (e.g. scrcpy) are visible
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
if ($regUser) {
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
foreach ($dir in ($regUser -split ';')) {
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
$env:PATH = "$env:PATH;$dir"
}
}
}
# Start server detached
Write-Host "Starting server..."
Start-Process -FilePath python -ArgumentList '-m', 'wled_controller.main' `
-WorkingDirectory 'c:\Users\Alexei\Documents\wled-screen-controller\server' `
-WindowStyle Hidden
Start-Sleep -Seconds 3
# Verify it's running
$check = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*wled_controller.main*' }
if ($check) {
Write-Host "Server started (PID $($check[0].ProcessId))"
} else {
Write-Host "WARNING: Server does not appear to be running!"
}

27
server/restart.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Restart the WLED Screen Controller server (Linux/macOS)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Stop any running instance
PIDS=$(pgrep -f 'wled_controller\.main' 2>/dev/null || true)
if [ -n "$PIDS" ]; then
echo "Stopping server (PID $PIDS)..."
pkill -f 'wled_controller\.main' 2>/dev/null || true
sleep 2
fi
# Start server detached
echo "Starting server..."
cd "$SCRIPT_DIR"
nohup python -m wled_controller.main > /dev/null 2>&1 &
sleep 3
# Verify it's running
NEW_PID=$(pgrep -f 'wled_controller\.main' 2>/dev/null || true)
if [ -n "$NEW_PID" ]; then
echo "Server started (PID $NEW_PID)"
else
echo "WARNING: Server does not appear to be running!"
fi

View File

@@ -8,8 +8,17 @@ 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.picture_targets import router as picture_targets_router
from .routes.profiles import router as profiles_router
from .routes.output_targets import router as output_targets_router
from .routes.color_strip_sources import router as color_strip_sources_router
from .routes.audio import router as audio_router
from .routes.audio_sources import router as audio_sources_router
from .routes.audio_templates import router as audio_templates_router
from .routes.value_sources import router as value_sources_router
from .routes.automations import router as automations_router
from .routes.scene_presets import router as scene_presets_router
from .routes.webhooks import router as webhooks_router
from .routes.sync_clocks import router as sync_clocks_router
from .routes.color_strip_processing import router as cspt_router
router = APIRouter()
router.include_router(system_router)
@@ -18,7 +27,16 @@ 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(picture_targets_router)
router.include_router(profiles_router)
router.include_router(color_strip_sources_router)
router.include_router(audio_router)
router.include_router(audio_sources_router)
router.include_router(audio_templates_router)
router.include_router(value_sources_router)
router.include_router(output_targets_router)
router.include_router(automations_router)
router.include_router(scene_presets_router)
router.include_router(webhooks_router)
router.include_router(sync_clocks_router)
router.include_router(cspt_router)
__all__ = ["router"]

View File

@@ -59,7 +59,7 @@ def verify_api_key(
break
if not authenticated_as:
logger.warning(f"Invalid API key attempt: {token[:8]}...")
logger.warning("Invalid API key attempt")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
@@ -75,3 +75,16 @@ def verify_api_key(
# Dependency for protected routes
# Returns the label/identifier of the authenticated client
AuthRequired = Annotated[str, Depends(verify_api_key)]
def verify_ws_token(token: str) -> bool:
"""Check a WebSocket query-param token against configured API keys.
Use this for WebSocket endpoints where FastAPI's Depends() isn't available.
"""
config = get_config()
if token and config.auth.api_keys:
for _label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
return True
return False

View File

@@ -1,4 +1,10 @@
"""Dependency injection for API routes."""
"""Dependency injection for API routes.
Uses a registry dict instead of individual module-level globals.
All getter function signatures remain unchanged for FastAPI Depends() compatibility.
"""
from typing import Any, Dict, Type, TypeVar
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
@@ -6,83 +12,130 @@ from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
# Global instances (initialized in main.py)
_device_store: DeviceStore | None = None
_template_store: TemplateStore | None = None
_pp_template_store: PostprocessingTemplateStore | None = None
_pattern_template_store: PatternTemplateStore | None = None
_picture_source_store: PictureSourceStore | None = None
_picture_target_store: PictureTargetStore | None = None
_processor_manager: ProcessorManager | None = None
_profile_store: ProfileStore | None = None
_profile_engine: ProfileEngine | None = None
T = TypeVar("T")
# Central dependency registry — keyed by type or string label
_deps: Dict[str, Any] = {}
def _get(key: str, label: str) -> Any:
"""Get a dependency by key, raising RuntimeError if not initialized."""
dep = _deps.get(key)
if dep is None:
raise RuntimeError(f"{label} not initialized")
return dep
# ── Typed getters (unchanged signatures for FastAPI Depends()) ──────────
def get_device_store() -> DeviceStore:
"""Get device store dependency."""
if _device_store is None:
raise RuntimeError("Device store not initialized")
return _device_store
return _get("device_store", "Device store")
def get_template_store() -> TemplateStore:
"""Get template store dependency."""
if _template_store is None:
raise RuntimeError("Template store not initialized")
return _template_store
return _get("template_store", "Template store")
def get_pp_template_store() -> PostprocessingTemplateStore:
"""Get postprocessing template store dependency."""
if _pp_template_store is None:
raise RuntimeError("Postprocessing template store not initialized")
return _pp_template_store
return _get("pp_template_store", "Postprocessing template store")
def get_pattern_template_store() -> PatternTemplateStore:
"""Get pattern template store dependency."""
if _pattern_template_store is None:
raise RuntimeError("Pattern template store not initialized")
return _pattern_template_store
return _get("pattern_template_store", "Pattern template store")
def get_picture_source_store() -> PictureSourceStore:
"""Get picture source store dependency."""
if _picture_source_store is None:
raise RuntimeError("Picture source store not initialized")
return _picture_source_store
return _get("picture_source_store", "Picture source store")
def get_picture_target_store() -> PictureTargetStore:
"""Get picture target store dependency."""
if _picture_target_store is None:
raise RuntimeError("Picture target store not initialized")
return _picture_target_store
def get_output_target_store() -> OutputTargetStore:
return _get("output_target_store", "Output target store")
def get_color_strip_store() -> ColorStripStore:
return _get("color_strip_store", "Color strip store")
def get_audio_source_store() -> AudioSourceStore:
return _get("audio_source_store", "Audio source store")
def get_audio_template_store() -> AudioTemplateStore:
return _get("audio_template_store", "Audio template store")
def get_value_source_store() -> ValueSourceStore:
return _get("value_source_store", "Value source store")
def get_processor_manager() -> ProcessorManager:
"""Get processor manager dependency."""
if _processor_manager is None:
raise RuntimeError("Processor manager not initialized")
return _processor_manager
return _get("processor_manager", "Processor manager")
def get_profile_store() -> ProfileStore:
"""Get profile store dependency."""
if _profile_store is None:
raise RuntimeError("Profile store not initialized")
return _profile_store
def get_automation_store() -> AutomationStore:
return _get("automation_store", "Automation store")
def get_profile_engine() -> ProfileEngine:
"""Get profile engine dependency."""
if _profile_engine is None:
raise RuntimeError("Profile engine not initialized")
return _profile_engine
def get_scene_preset_store() -> ScenePresetStore:
return _get("scene_preset_store", "Scene preset store")
def get_automation_engine() -> AutomationEngine:
return _get("automation_engine", "Automation engine")
def get_auto_backup_engine() -> AutoBackupEngine:
return _get("auto_backup_engine", "Auto-backup engine")
def get_sync_clock_store() -> SyncClockStore:
return _get("sync_clock_store", "Sync clock store")
def get_sync_clock_manager() -> SyncClockManager:
return _get("sync_clock_manager", "Sync clock manager")
def get_cspt_store() -> ColorStripProcessingTemplateStore:
return _get("cspt_store", "Color strip processing template store")
# ── Event helper ────────────────────────────────────────────────────────
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus.
Args:
entity_type: e.g. "device", "output_target", "color_strip_source"
action: "created", "updated", or "deleted"
entity_id: The entity's unique ID
"""
pm = _deps.get("processor_manager")
if pm is not None:
pm.fire_event({
"type": "entity_changed",
"entity_type": entity_type,
"action": action,
"id": entity_id,
})
# ── Initialization ──────────────────────────────────────────────────────
def init_dependencies(
@@ -92,20 +145,37 @@ def init_dependencies(
pp_template_store: PostprocessingTemplateStore | None = None,
pattern_template_store: PatternTemplateStore | None = None,
picture_source_store: PictureSourceStore | None = None,
picture_target_store: PictureTargetStore | None = None,
profile_store: ProfileStore | None = None,
profile_engine: ProfileEngine | None = None,
output_target_store: OutputTargetStore | None = None,
color_strip_store: ColorStripStore | None = None,
audio_source_store: AudioSourceStore | None = None,
audio_template_store: AudioTemplateStore | None = None,
value_source_store: ValueSourceStore | None = None,
automation_store: AutomationStore | None = None,
scene_preset_store: ScenePresetStore | None = None,
automation_engine: AutomationEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None,
sync_clock_store: SyncClockStore | None = None,
sync_clock_manager: SyncClockManager | None = None,
cspt_store: ColorStripProcessingTemplateStore | None = None,
):
"""Initialize global dependencies."""
global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _profile_store, _profile_engine
_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
_picture_target_store = picture_target_store
_profile_store = profile_store
_profile_engine = profile_engine
_deps.update({
"device_store": device_store,
"template_store": template_store,
"processor_manager": processor_manager,
"pp_template_store": pp_template_store,
"pattern_template_store": pattern_template_store,
"picture_source_store": picture_source_store,
"output_target_store": output_target_store,
"color_strip_store": color_strip_store,
"audio_source_store": audio_source_store,
"audio_template_store": audio_template_store,
"value_source_store": value_source_store,
"automation_store": automation_store,
"scene_preset_store": scene_preset_store,
"automation_engine": automation_engine,
"auto_backup_engine": auto_backup_engine,
"sync_clock_store": sync_clock_store,
"sync_clock_manager": sync_clock_manager,
"cspt_store": cspt_store,
})

View File

@@ -0,0 +1,230 @@
"""Shared helpers for WebSocket-based capture test endpoints."""
import asyncio
import base64
import io
import secrets
import threading
import time
from typing import Callable, List, Optional
import numpy as np
from PIL import Image
from starlette.websockets import WebSocket
from wled_controller.config import get_config
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.utils import get_logger
logger = get_logger(__name__)
PREVIEW_INTERVAL = 0.1 # seconds between intermediate thumbnail sends
PREVIEW_MAX_WIDTH = 640 # px for intermediate thumbnails
FINAL_THUMBNAIL_WIDTH = 640 # px for the final thumbnail
FINAL_JPEG_QUALITY = 90
PREVIEW_JPEG_QUALITY = 70
def authenticate_ws_token(token: str) -> bool:
"""Check a WebSocket query-param token against configured API keys.
Delegates to the canonical implementation in auth module.
"""
from wled_controller.api.auth import verify_ws_token
return verify_ws_token(token)
def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
"""Encode a PIL image as a JPEG base64 data URI."""
buf = io.BytesIO()
pil_image.save(buf, format="JPEG", quality=quality)
buf.seek(0)
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return f"data:image/jpeg;base64,{b64}"
def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int = 80) -> bytes:
"""Encode a numpy RGB image to JPEG bytes, optionally downscaling."""
import cv2
if max_width and image.shape[1] > max_width:
scale = max_width / image.shape[1]
new_h = int(image.shape[0] * scale)
image = cv2.resize(image, (max_width, new_h), interpolation=cv2.INTER_AREA)
# RGB → BGR for OpenCV JPEG encoding
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
_, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
return buf.tobytes()
def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image:
"""Create a thumbnail copy of the image, preserving aspect ratio."""
thumb = pil_image.copy()
aspect = pil_image.height / pil_image.width
thumb.thumbnail((max_width, int(max_width * aspect)), Image.Resampling.LANCZOS)
return thumb
def _apply_pp_filters(pil_image: Image.Image, flat_filters: list) -> Image.Image:
"""Apply postprocessing filter instances to a PIL image."""
if not flat_filters:
return pil_image
pool = ImagePool()
arr = np.array(pil_image)
for fi in flat_filters:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(arr, pool)
if result is not None:
arr = result
return Image.fromarray(arr)
async def stream_capture_test(
websocket: WebSocket,
engine_factory: Callable,
duration: float,
pp_filters: Optional[list] = None,
preview_width: Optional[int] = None,
) -> None:
"""Run a capture test, streaming intermediate thumbnails and a final full-res frame.
The engine is created and used entirely within a background thread to avoid
thread-affinity issues (e.g. MSS uses thread-local state).
Args:
websocket: Accepted WebSocket connection.
engine_factory: Zero-arg callable that returns an initialized engine stream
(with .capture_frame() and .cleanup() methods). Called inside the
capture thread so thread-local resources work correctly.
duration: Test duration in seconds.
pp_filters: Optional list of resolved filter instances to apply to frames.
"""
thumb_width = preview_width or PREVIEW_MAX_WIDTH
# Shared state between capture thread and async loop
latest_frame = None # PIL Image (converted from numpy)
frame_count = 0
total_capture_time = 0.0
stop_event = threading.Event()
done_event = threading.Event()
init_error = None # set if engine_factory fails
def _capture_loop():
nonlocal latest_frame, frame_count, total_capture_time, init_error
stream = None
try:
stream = engine_factory()
start = time.perf_counter()
end = start + duration
while time.perf_counter() < end and not stop_event.is_set():
t0 = time.perf_counter()
capture = stream.capture_frame()
t1 = time.perf_counter()
if capture is None:
time.sleep(0.005)
continue
total_capture_time += t1 - t0
frame_count += 1
# Convert numpy → PIL once in the capture thread
if isinstance(capture.image, np.ndarray):
latest_frame = Image.fromarray(capture.image)
else:
latest_frame = capture.image
except Exception as e:
init_error = str(e)
logger.error(f"Capture thread error: {e}")
finally:
if stream:
try:
stream.cleanup()
except Exception:
pass
done_event.set()
# Start capture in background thread
loop = asyncio.get_running_loop()
capture_future = loop.run_in_executor(None, _capture_loop)
start_time = time.perf_counter()
last_sent_frame = None
try:
# Stream intermediate previews
while not done_event.is_set():
await asyncio.sleep(PREVIEW_INTERVAL)
# Check for init error
if init_error:
stop_event.set()
await capture_future
await websocket.send_json({"type": "error", "detail": init_error})
return
frame = latest_frame
if frame is not None and frame is not last_sent_frame:
last_sent_frame = frame
elapsed = time.perf_counter() - start_time
fc = frame_count
tc = total_capture_time
# Encode preview thumbnail (small + fast)
thumb = _make_thumbnail(frame, thumb_width)
if pp_filters:
thumb = _apply_pp_filters(thumb, pp_filters)
thumb_uri = _encode_jpeg(thumb, PREVIEW_JPEG_QUALITY)
fps = fc / elapsed if elapsed > 0 else 0
avg_ms = (tc / fc * 1000) if fc > 0 else 0
await websocket.send_json({
"type": "frame",
"thumbnail": thumb_uri,
"frame_count": fc,
"elapsed_s": round(elapsed, 2),
"fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1),
})
# Wait for capture thread to fully finish
await capture_future
# Check for errors
if init_error:
await websocket.send_json({"type": "error", "detail": init_error})
return
# Send final result
final_frame = latest_frame
if final_frame is None:
await websocket.send_json({"type": "error", "detail": "No frames captured"})
return
elapsed = time.perf_counter() - start_time
fc = frame_count
tc = total_capture_time
fps = fc / elapsed if elapsed > 0 else 0
avg_ms = (tc / fc * 1000) if fc > 0 else 0
# Apply PP filters to final images
if pp_filters:
final_frame = _apply_pp_filters(final_frame, pp_filters)
w, h = final_frame.size
full_uri = _encode_jpeg(final_frame, FINAL_JPEG_QUALITY)
thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH)
thumb_uri = _encode_jpeg(thumb, 85)
await websocket.send_json({
"type": "result",
"full_image": full_uri,
"thumbnail": thumb_uri,
"width": w,
"height": h,
"frame_count": fc,
"elapsed_s": round(elapsed, 2),
"fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1),
})
except Exception:
# WebSocket disconnect or send error — signal capture thread to stop
stop_event.set()
await capture_future
raise

View File

@@ -0,0 +1,34 @@
"""Audio device routes: enumerate available audio devices."""
import asyncio
from fastapi import APIRouter
from wled_controller.api.auth import AuthRequired
from wled_controller.core.audio.audio_capture import AudioCaptureManager
router = APIRouter()
@router.get("/api/v1/audio-devices", tags=["Audio"])
async def list_audio_devices(_auth: AuthRequired):
"""List available audio input/output devices for audio-reactive sources.
Returns a deduped flat list (backward compat) plus a ``by_engine`` dict
with per-engine device lists (no cross-engine dedup) so the frontend can
filter by the selected audio template's engine type.
"""
try:
devices, by_engine = await asyncio.to_thread(
lambda: (
AudioCaptureManager.enumerate_devices(),
AudioCaptureManager.enumerate_devices_by_engine(),
)
)
return {
"devices": devices,
"count": len(devices),
"by_engine": by_engine,
}
except Exception as e:
return {"devices": [], "count": 0, "by_engine": {}, "error": str(e)}

View File

@@ -0,0 +1,251 @@
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_audio_source_store,
get_audio_template_store,
get_color_strip_store,
get_processor_manager,
)
from wled_controller.api.schemas.audio_sources import (
AudioSourceCreate,
AudioSourceListResponse,
AudioSourceResponse,
AudioSourceUpdate,
)
from wled_controller.storage.audio_source import AudioSource
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
def _to_response(source: AudioSource) -> AudioSourceResponse:
"""Convert an AudioSource to an AudioSourceResponse."""
return AudioSourceResponse(
id=source.id,
name=source.name,
source_type=source.source_type,
device_index=getattr(source, "device_index", None),
is_loopback=getattr(source, "is_loopback", None),
audio_template_id=getattr(source, "audio_template_id", None),
audio_source_id=getattr(source, "audio_source_id", None),
channel=getattr(source, "channel", None),
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
)
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
async def list_audio_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel or mono"),
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""List all audio sources, optionally filtered by type."""
sources = store.get_all_sources()
if source_type:
sources = [s for s in sources if s.source_type == source_type]
return AudioSourceListResponse(
sources=[_to_response(s) for s in sources],
count=len(sources),
)
@router.post("/api/v1/audio-sources", response_model=AudioSourceResponse, status_code=201, tags=["Audio Sources"])
async def create_audio_source(
data: AudioSourceCreate,
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Create a new audio source."""
try:
source = store.create_source(
name=data.name,
source_type=data.source_type,
device_index=data.device_index,
is_loopback=data.is_loopback,
audio_source_id=data.audio_source_id,
channel=data.channel,
description=data.description,
audio_template_id=data.audio_template_id,
tags=data.tags,
)
fire_entity_event("audio_source", "created", source.id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
async def get_audio_source(
source_id: str,
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Get an audio source by ID."""
try:
source = store.get_source(source_id)
return _to_response(source)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
async def update_audio_source(
source_id: str,
data: AudioSourceUpdate,
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Update an existing audio source."""
try:
source = store.update_source(
source_id=source_id,
name=data.name,
device_index=data.device_index,
is_loopback=data.is_loopback,
audio_source_id=data.audio_source_id,
channel=data.channel,
description=data.description,
audio_template_id=data.audio_template_id,
tags=data.tags,
)
fire_entity_event("audio_source", "updated", source_id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/api/v1/audio-sources/{source_id}", status_code=204, tags=["Audio Sources"])
async def delete_audio_source(
source_id: str,
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete an audio source."""
try:
# Check if any CSS entities reference this audio source
from wled_controller.storage.color_strip_source import AudioColorStripSource
for css in css_store.get_all_sources():
if isinstance(css, AudioColorStripSource) and getattr(css, "audio_source_id", None) == source_id:
raise ValueError(
f"Cannot delete: referenced by color strip source '{css.name}'"
)
store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# ===== REAL-TIME AUDIO TEST WEBSOCKET =====
@router.websocket("/api/v1/audio-sources/{source_id}/test/ws")
async def test_audio_source_ws(
websocket: WebSocket,
source_id: str,
token: str = Query(""),
):
"""WebSocket for real-time audio spectrum analysis. Auth via ?token=<api_key>.
Resolves the audio source to its device, acquires a ManagedAudioStream
(ref-counted — shares with running targets), and streams AudioAnalysis
snapshots as JSON at ~20 Hz.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Resolve source → device info
store = get_audio_source_store()
template_store = get_audio_template_store()
manager = get_processor_manager()
try:
device_index, is_loopback, channel, audio_template_id = store.resolve_audio_source(source_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
# Resolve template → engine_type + config
engine_type = None
engine_config = None
if audio_template_id:
try:
template = template_store.get_template(audio_template_id)
engine_type = template.engine_type
engine_config = template.engine_config
except ValueError:
pass # Fall back to best available engine
# Acquire shared audio stream
audio_mgr = manager.audio_capture_manager
try:
stream = audio_mgr.acquire(device_index, is_loopback, engine_type, engine_config)
except RuntimeError as e:
await websocket.close(code=4003, reason=str(e))
return
await websocket.accept()
logger.info(f"Audio test WebSocket connected for source {source_id}")
last_ts = 0.0
try:
while True:
analysis = stream.get_latest_analysis()
if analysis is not None and analysis.timestamp != last_ts:
last_ts = analysis.timestamp
# Select channel-specific data
if channel == "left":
spectrum = analysis.left_spectrum
rms = analysis.left_rms
elif channel == "right":
spectrum = analysis.right_spectrum
rms = analysis.right_rms
else:
spectrum = analysis.spectrum
rms = analysis.rms
await websocket.send_json({
"spectrum": spectrum.tolist(),
"rms": round(rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),
})
await asyncio.sleep(0.05)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Audio test WebSocket error for {source_id}: {e}")
finally:
audio_mgr.release(device_index, is_loopback, engine_type)
logger.info(f"Audio test WebSocket disconnected for source {source_id}")

View File

@@ -0,0 +1,246 @@
"""Audio capture template and engine routes."""
import asyncio
import json
from fastapi import APIRouter, HTTPException, Depends, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import fire_entity_event, get_audio_template_store, get_audio_source_store, get_processor_manager
from wled_controller.api.schemas.audio_templates import (
AudioEngineInfo,
AudioEngineListResponse,
AudioTemplateCreate,
AudioTemplateListResponse,
AudioTemplateResponse,
AudioTemplateUpdate,
)
from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
# ===== AUDIO TEMPLATE ENDPOINTS =====
@router.get("/api/v1/audio-templates", response_model=AudioTemplateListResponse, tags=["Audio Templates"])
async def list_audio_templates(
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
):
"""List all audio capture templates."""
try:
templates = store.get_all_templates()
responses = [
AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
for t in templates
]
return AudioTemplateListResponse(templates=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list audio templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201)
async def create_audio_template(
data: AudioTemplateCreate,
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
):
"""Create a new audio capture template."""
try:
template = store.create_template(
name=data.name, engine_type=data.engine_type,
engine_config=data.engine_config, description=data.description,
tags=data.tags,
)
fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse(
id=template.id, name=template.name, engine_type=template.engine_type,
engine_config=template.engine_config, tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at, description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
async def get_audio_template(
template_id: str,
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
):
"""Get audio template by ID."""
try:
t = store.get_template(template_id)
except ValueError:
raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
@router.put("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
async def update_audio_template(
template_id: str,
data: AudioTemplateUpdate,
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
):
"""Update an audio template."""
try:
t = store.update_template(
template_id=template_id, name=data.name,
engine_type=data.engine_type, engine_config=data.engine_config,
description=data.description, tags=data.tags,
)
fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/audio-templates/{template_id}", status_code=204, tags=["Audio Templates"])
async def delete_audio_template(
template_id: str,
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
audio_source_store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Delete an audio template."""
try:
store.delete_template(template_id, audio_source_store=audio_source_store)
fire_entity_event("audio_template", "deleted", template_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== AUDIO ENGINE ENDPOINTS =====
@router.get("/api/v1/audio-engines", response_model=AudioEngineListResponse, tags=["Audio Templates"])
async def list_audio_engines(_auth: AuthRequired):
"""List all registered audio capture engines."""
try:
available_set = set(AudioEngineRegistry.get_available_engines())
all_engines = AudioEngineRegistry.get_all_engines()
engines = []
for engine_type, engine_class in all_engines.items():
engines.append(
AudioEngineInfo(
type=engine_type,
name=engine_type.upper(),
default_config=engine_class.get_default_config(),
available=(engine_type in available_set),
)
)
return AudioEngineListResponse(engines=engines, count=len(engines))
except Exception as e:
logger.error(f"Failed to list audio engines: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== REAL-TIME AUDIO TEMPLATE TEST WEBSOCKET =====
@router.websocket("/api/v1/audio-templates/{template_id}/test/ws")
async def test_audio_template_ws(
websocket: WebSocket,
template_id: str,
token: str = Query(""),
device_index: int = Query(-1),
is_loopback: int = Query(1),
):
"""WebSocket for real-time audio spectrum test of a template with a chosen device.
Auth via ?token=<api_key>. Device specified via ?device_index=N&is_loopback=0|1.
Streams AudioAnalysis snapshots as JSON at ~20 Hz.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Resolve template
store = get_audio_template_store()
try:
template = store.get_template(template_id)
except ValueError:
await websocket.close(code=4004, reason="Template not found")
return
# Acquire shared audio stream
manager = get_processor_manager()
audio_mgr = manager.audio_capture_manager
loopback = is_loopback != 0
try:
stream = audio_mgr.acquire(device_index, loopback, template.engine_type, template.engine_config)
except RuntimeError as e:
await websocket.close(code=4003, reason=str(e))
return
await websocket.accept()
logger.info(f"Audio template test WS connected: template={template_id} device={device_index} loopback={loopback}")
last_ts = 0.0
try:
while True:
analysis = stream.get_latest_analysis()
if analysis is not None and analysis.timestamp != last_ts:
last_ts = analysis.timestamp
await websocket.send_json({
"spectrum": analysis.spectrum.tolist(),
"rms": round(analysis.rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),
})
await asyncio.sleep(0.05)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Audio template test WS error: {e}")
finally:
audio_mgr.release(device_index, loopback, template.engine_type)
logger.info(f"Audio template test WS disconnected: template={template_id}")

View File

@@ -0,0 +1,363 @@
"""Automation management API routes."""
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_automation_engine,
get_automation_store,
get_scene_preset_store,
)
from wled_controller.api.schemas.automations import (
AutomationCreate,
AutomationListResponse,
AutomationResponse,
AutomationUpdate,
ConditionSchema,
)
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
Condition,
DisplayStateCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
# ===== Helpers =====
def _condition_from_schema(s: ConditionSchema) -> Condition:
if s.condition_type == "always":
return AlwaysCondition()
if s.condition_type == "application":
return ApplicationCondition(
apps=s.apps or [],
match_type=s.match_type or "running",
)
if s.condition_type == "time_of_day":
return TimeOfDayCondition(
start_time=s.start_time or "00:00",
end_time=s.end_time or "23:59",
)
if s.condition_type == "system_idle":
return SystemIdleCondition(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
when_idle=s.when_idle if s.when_idle is not None else True,
)
if s.condition_type == "display_state":
return DisplayStateCondition(
state=s.state or "on",
)
if s.condition_type == "mqtt":
return MQTTCondition(
topic=s.topic or "",
payload=s.payload or "",
match_mode=s.match_mode or "exact",
)
if s.condition_type == "webhook":
return WebhookCondition(
token=s.token or secrets.token_hex(16),
)
if s.condition_type == "startup":
return StartupCondition()
raise ValueError(f"Unknown condition type: {s.condition_type}")
def _condition_to_schema(c: Condition) -> ConditionSchema:
d = c.to_dict()
return ConditionSchema(**d)
def _automation_to_response(automation, engine: AutomationEngine, request: Request = None) -> AutomationResponse:
state = engine.get_automation_state(automation.id)
# Build webhook URL from the first webhook condition (if any)
webhook_url = None
for c in automation.conditions:
if isinstance(c, WebhookCondition) and c.token:
# Prefer configured external URL, fall back to request base URL
from wled_controller.api.routes.system import load_external_url
ext = load_external_url()
if ext:
webhook_url = ext + f"/api/v1/webhooks/{c.token}"
elif request:
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
else:
webhook_url = f"/api/v1/webhooks/{c.token}"
break
return AutomationResponse(
id=automation.id,
name=automation.name,
enabled=automation.enabled,
condition_logic=automation.condition_logic,
conditions=[_condition_to_schema(c) for c in automation.conditions],
scene_preset_id=automation.scene_preset_id,
deactivation_mode=automation.deactivation_mode,
deactivation_scene_preset_id=automation.deactivation_scene_preset_id,
webhook_url=webhook_url,
is_active=state["is_active"],
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
tags=automation.tags,
created_at=automation.created_at,
updated_at=automation.updated_at,
)
def _validate_condition_logic(logic: str) -> None:
if logic not in ("or", "and"):
raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.")
def _validate_scene_refs(
scene_preset_id: str | None,
deactivation_scene_preset_id: str | None,
scene_store: ScenePresetStore,
) -> None:
"""Validate that referenced scene preset IDs exist."""
for sid, label in [
(scene_preset_id, "scene_preset_id"),
(deactivation_scene_preset_id, "deactivation_scene_preset_id"),
]:
if sid is not None:
try:
scene_store.get_preset(sid)
except ValueError:
raise HTTPException(status_code=400, detail=f"Scene preset not found: {sid} ({label})")
# ===== CRUD Endpoints =====
@router.post(
"/api/v1/automations",
response_model=AutomationResponse,
tags=["Automations"],
status_code=201,
)
async def create_automation(
request: Request,
data: AutomationCreate,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Create a new automation."""
_validate_condition_logic(data.condition_logic)
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
automation = store.create_automation(
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
scene_preset_id=data.scene_preset_id,
deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags,
)
if automation.enabled:
await engine.trigger_evaluate()
fire_entity_event("automation", "created", automation.id)
return _automation_to_response(automation, engine, request)
@router.get(
"/api/v1/automations",
response_model=AutomationListResponse,
tags=["Automations"],
)
async def list_automations(
request: Request,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""List all automations."""
automations = store.get_all_automations()
return AutomationListResponse(
automations=[_automation_to_response(a, engine, request) for a in automations],
count=len(automations),
)
@router.get(
"/api/v1/automations/{automation_id}",
response_model=AutomationResponse,
tags=["Automations"],
)
async def get_automation(
request: Request,
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Get a single automation."""
try:
automation = store.get_automation(automation_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine, request)
@router.put(
"/api/v1/automations/{automation_id}",
response_model=AutomationResponse,
tags=["Automations"],
)
async def update_automation(
request: Request,
automation_id: str,
data: AutomationUpdate,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Update an automation."""
if data.condition_logic is not None:
_validate_condition_logic(data.condition_logic)
# Validate scene refs (only the ones being updated)
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
conditions = None
if data.conditions is not None:
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
try:
# If disabling, deactivate first
if data.enabled is False:
await engine.deactivate_if_active(automation_id)
# Build update kwargs — use sentinel for Optional[str] fields
update_kwargs = dict(
automation_id=automation_id,
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
deactivation_mode=data.deactivation_mode,
tags=data.tags,
)
if data.scene_preset_id is not None:
update_kwargs["scene_preset_id"] = data.scene_preset_id
if data.deactivation_scene_preset_id is not None:
update_kwargs["deactivation_scene_preset_id"] = data.deactivation_scene_preset_id
automation = store.update_automation(**update_kwargs)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# Re-evaluate immediately if automation is enabled (may have new conditions/scene)
if automation.enabled:
await engine.trigger_evaluate()
fire_entity_event("automation", "updated", automation_id)
return _automation_to_response(automation, engine, request)
@router.delete(
"/api/v1/automations/{automation_id}",
status_code=204,
tags=["Automations"],
)
async def delete_automation(
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Delete an automation."""
# Deactivate first
await engine.deactivate_if_active(automation_id)
try:
store.delete_automation(automation_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("automation", "deleted", automation_id)
# ===== Enable/Disable =====
@router.post(
"/api/v1/automations/{automation_id}/enable",
response_model=AutomationResponse,
tags=["Automations"],
)
async def enable_automation(
request: Request,
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Enable an automation."""
try:
automation = store.update_automation(automation_id=automation_id, enabled=True)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# Evaluate immediately so scene activates without waiting for the next poll cycle
await engine.trigger_evaluate()
return _automation_to_response(automation, engine, request)
@router.post(
"/api/v1/automations/{automation_id}/disable",
response_model=AutomationResponse,
tags=["Automations"],
)
async def disable_automation(
request: Request,
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Disable an automation and deactivate it."""
await engine.deactivate_if_active(automation_id)
try:
automation = store.update_automation(automation_id=automation_id, enabled=False)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine, request)

View File

@@ -0,0 +1,275 @@
"""Color strip processing template routes."""
import asyncio
import json as _json
import time as _time
import uuid as _uuid
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_cspt_store,
get_device_store,
get_processor_manager,
)
from wled_controller.api.schemas.filters import FilterInstanceSchema
from wled_controller.api.schemas.color_strip_processing import (
ColorStripProcessingTemplateCreate,
ColorStripProcessingTemplateListResponse,
ColorStripProcessingTemplateResponse,
ColorStripProcessingTemplateUpdate,
)
from wled_controller.core.filters import FilterInstance
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage import DeviceStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
"""Convert a ColorStripProcessingTemplate to its API response."""
return ColorStripProcessingTemplateResponse(
id=t.id,
name=t.name,
filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters],
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
)
@router.get("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateListResponse, tags=["Color Strip Processing"])
async def list_cspt(
_auth: AuthRequired,
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
):
"""List all color strip processing templates."""
try:
templates = store.get_all_templates()
responses = [_cspt_to_response(t) for t in templates]
return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list color strip processing templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201)
async def create_cspt(
data: ColorStripProcessingTemplateCreate,
_auth: AuthRequired,
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
):
"""Create a new color strip processing template."""
try:
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters]
template = store.create_template(
name=data.name,
filters=filters,
description=data.description,
tags=data.tags,
)
fire_entity_event("cspt", "created", template.id)
return _cspt_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create color strip processing template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
async def get_cspt(
template_id: str,
_auth: AuthRequired,
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
):
"""Get color strip processing template by ID."""
try:
template = store.get_template(template_id)
return _cspt_to_response(template)
except ValueError:
raise HTTPException(status_code=404, detail=f"Color strip processing template {template_id} not found")
@router.put("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
async def update_cspt(
template_id: str,
data: ColorStripProcessingTemplateUpdate,
_auth: AuthRequired,
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
):
"""Update a color strip processing template."""
try:
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None
template = store.update_template(
template_id=template_id,
name=data.name,
filters=filters,
description=data.description,
tags=data.tags,
)
fire_entity_event("cspt", "updated", template_id)
return _cspt_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update color strip processing template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"])
async def delete_cspt(
template_id: str,
_auth: AuthRequired,
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
device_store: DeviceStore = Depends(get_device_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete a color strip processing template."""
try:
refs = store.get_references(template_id, device_store=device_store, css_store=css_store)
if refs:
names = ", ".join(refs)
raise HTTPException(
status_code=409,
detail=f"Cannot delete: template is referenced by: {names}. "
"Please reassign before deleting.",
)
store.delete_template(template_id)
fire_entity_event("cspt", "deleted", template_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete color strip processing template: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ── Test / Preview WebSocket ──────────────────────────────────────────
@router.websocket("/api/v1/color-strip-processing-templates/{template_id}/test/ws")
async def test_cspt_ws(
websocket: WebSocket,
template_id: str,
token: str = Query(""),
input_source_id: str = Query(""),
led_count: int = Query(100),
fps: int = Query(20),
):
"""WebSocket for real-time CSPT preview.
Takes an input CSS source, applies the CSPT filter chain, and streams
the processed RGB frames. Auth via ``?token=<api_key>``.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.core.filters import FilterRegistry
from wled_controller.core.processing.processor_manager import ProcessorManager
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Validate template exists
cspt_store = get_cspt_store()
try:
template = cspt_store.get_template(template_id)
except (ValueError, RuntimeError) as e:
await websocket.close(code=4004, reason=str(e))
return
if not input_source_id:
await websocket.close(code=4003, reason="input_source_id is required")
return
# Validate input source exists
css_store = get_color_strip_store()
try:
input_source = css_store.get_source(input_source_id)
except (ValueError, RuntimeError) as e:
await websocket.close(code=4004, reason=str(e))
return
# Resolve filter chain
try:
resolved = cspt_store.resolve_filter_instances(template.filters)
filters = [FilterRegistry.create_instance(fi.filter_id, fi.options) for fi in resolved]
except Exception as e:
logger.error(f"CSPT test: failed to resolve filters for {template_id}: {e}")
await websocket.close(code=4003, reason=str(e))
return
# Acquire input stream
manager: ProcessorManager = get_processor_manager()
csm = manager.color_strip_stream_manager
consumer_id = f"__cspt_test_{_uuid.uuid4().hex[:8]}__"
try:
stream = csm.acquire(input_source_id, consumer_id)
except Exception as e:
logger.error(f"CSPT test: failed to acquire input stream for {input_source_id}: {e}")
await websocket.close(code=4003, reason=str(e))
return
# Configure LED count for auto-sizing streams
if hasattr(stream, "configure"):
stream.configure(max(1, led_count))
fps = max(1, min(60, fps))
frame_interval = 1.0 / fps
await websocket.accept()
logger.info(f"CSPT test WS connected: template={template_id}, input={input_source_id}")
try:
# Send metadata
meta = {
"type": "meta",
"source_type": input_source.source_type,
"source_name": input_source.name,
"template_name": template.name,
"led_count": stream.led_count,
"filter_count": len(filters),
}
await websocket.send_text(_json.dumps(meta))
# Stream processed frames
while True:
colors = stream.get_latest_colors()
if colors is not None:
# Apply CSPT filters
for flt in filters:
try:
result = flt.process_strip(colors)
if result is not None:
colors = result
except Exception:
pass
await websocket.send_bytes(colors.tobytes())
await asyncio.sleep(frame_interval)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"CSPT test WS error: {e}")
finally:
csm.release(input_source_id, consumer_id)
logger.info(f"CSPT test WS disconnected: template={template_id}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
"""Device routes: CRUD, health state, brightness, power, calibration."""
"""Device routes: CRUD, health state, brightness, power, calibration, WS stream."""
import httpx
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.core.devices.led_client import (
@@ -10,14 +10,12 @@ from wled_controller.core.devices.led_client import (
get_provider,
)
from wled_controller.api.dependencies import (
fire_entity_event,
get_device_store,
get_picture_target_store,
get_output_target_store,
get_processor_manager,
)
from wled_controller.api.schemas.devices import (
Calibration as CalibrationSchema,
CalibrationTestModeRequest,
CalibrationTestModeResponse,
DeviceCreate,
DeviceListResponse,
DeviceResponse,
@@ -25,16 +23,14 @@ from wled_controller.api.schemas.devices import (
DeviceUpdate,
DiscoveredDeviceResponse,
DiscoverDevicesResponse,
StaticColorUpdate,
)
from wled_controller.core.capture.calibration import (
calibration_from_dict,
calibration_to_dict,
OpenRGBZoneResponse,
OpenRGBZonesResponse,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -52,9 +48,23 @@ def _device_to_response(device) -> DeviceResponse:
enabled=device.enabled,
baud_rate=device.baud_rate,
auto_shutdown=device.auto_shutdown,
static_color=list(device.static_color) if device.static_color else None,
send_latency_ms=device.send_latency_ms,
rgbw=device.rgbw,
zone_mode=device.zone_mode,
capabilities=sorted(get_device_capabilities(device.device_type)),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
tags=device.tags,
dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'),
dmx_start_universe=getattr(device, 'dmx_start_universe', 0),
dmx_start_channel=getattr(device, 'dmx_start_channel', 1),
espnow_peer_mac=getattr(device, 'espnow_peer_mac', ''),
espnow_channel=getattr(device, 'espnow_channel', 1),
hue_username=getattr(device, 'hue_username', ''),
hue_client_key=getattr(device, 'hue_client_key', ''),
hue_entertainment_group_id=getattr(device, 'hue_entertainment_group_id', ''),
spi_speed_hz=getattr(device, 'spi_speed_hz', 800000),
spi_led_type=getattr(device, 'spi_led_type', 'WS2812B'),
chroma_device_type=getattr(device, 'chroma_device_type', 'chromalink'),
gamesense_device_type=getattr(device, 'gamesense_device_type', 'keyboard'),
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -113,10 +123,10 @@ async def create_device(
detail=f"Failed to connect to {device_type} device at {device_url}: {e}"
)
# Resolve auto_shutdown default: True for adalight, False otherwise
# Resolve auto_shutdown default: False for all types
auto_shutdown = device_data.auto_shutdown
if auto_shutdown is None:
auto_shutdown = device_type == "adalight"
auto_shutdown = False
# Create device in storage
device = store.create_device(
@@ -126,19 +136,40 @@ async def create_device(
device_type=device_type,
baud_rate=device_data.baud_rate,
auto_shutdown=auto_shutdown,
send_latency_ms=device_data.send_latency_ms or 0,
rgbw=device_data.rgbw or False,
zone_mode=device_data.zone_mode or "combined",
tags=device_data.tags,
dmx_protocol=device_data.dmx_protocol or "artnet",
dmx_start_universe=device_data.dmx_start_universe or 0,
dmx_start_channel=device_data.dmx_start_channel or 1,
espnow_peer_mac=device_data.espnow_peer_mac or "",
espnow_channel=device_data.espnow_channel or 1,
hue_username=device_data.hue_username or "",
hue_client_key=device_data.hue_client_key or "",
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
spi_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink",
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
)
# WS devices: auto-set URL to ws://{device_id}
if device_type == "ws":
device = store.update_device(device.id, url=f"ws://{device.id}")
# Register in processor manager for health monitoring
manager.add_device(
device_id=device.id,
device_url=device.url,
led_count=device.led_count,
calibration=device.calibration,
device_type=device.device_type,
baud_rate=device.baud_rate,
auto_shutdown=device.auto_shutdown,
zone_mode=device.zone_mode,
)
fire_entity_event("device", "created", device.id)
return _device_to_response(device)
except HTTPException:
@@ -213,6 +244,62 @@ async def discover_devices(
)
@router.get("/api/v1/devices/openrgb-zones", response_model=OpenRGBZonesResponse, tags=["Devices"])
async def get_openrgb_zones(
_auth: AuthRequired,
url: str = Query(..., description="Base OpenRGB URL (e.g. openrgb://localhost:6742/0)"),
):
"""List available zones on an OpenRGB device."""
import asyncio
from wled_controller.core.devices.openrgb_client import parse_openrgb_url
host, port, device_index, _zones = parse_openrgb_url(url)
def _fetch_zones():
from openrgb import OpenRGBClient
client = OpenRGBClient(host, port, name="WLED Controller (zones)")
try:
devices = client.devices
if device_index >= len(devices):
raise ValueError(
f"Device index {device_index} out of range "
f"(server has {len(devices)} device(s))"
)
device = devices[device_index]
zone_type_map = {0: "single", 1: "linear", 2: "matrix"}
zones = []
for z in device.zones:
zt = zone_type_map.get(getattr(z, "type", -1), "unknown")
zones.append(OpenRGBZoneResponse(
name=z.name,
led_count=len(z.leds),
zone_type=zt,
))
return device.name, zones
finally:
client.disconnect()
try:
device_name, zones = await asyncio.to_thread(_fetch_zones)
return OpenRGBZonesResponse(device_name=device_name, zones=zones)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception as e:
logger.error(f"Failed to list OpenRGB zones: {e}")
raise HTTPException(status_code=502, detail=f"Cannot reach OpenRGB server: {e}")
@router.get("/api/v1/devices/batch/states", tags=["Devices"])
async def batch_device_states(
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get health/connection state for all devices in a single request."""
return {"states": manager.get_all_device_health_dicts()}
@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
async def get_device(
device_id: str,
@@ -220,9 +307,10 @@ async def get_device(
store: DeviceStore = Depends(get_device_store),
):
"""Get device details by ID."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
try:
device = store.get_device(device_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _device_to_response(device)
@@ -244,6 +332,22 @@ async def update_device(
led_count=update_data.led_count,
baud_rate=update_data.baud_rate,
auto_shutdown=update_data.auto_shutdown,
send_latency_ms=update_data.send_latency_ms,
rgbw=update_data.rgbw,
zone_mode=update_data.zone_mode,
tags=update_data.tags,
dmx_protocol=update_data.dmx_protocol,
dmx_start_universe=update_data.dmx_start_universe,
dmx_start_channel=update_data.dmx_start_channel,
espnow_peer_mac=update_data.espnow_peer_mac,
espnow_channel=update_data.espnow_channel,
hue_username=update_data.hue_username,
hue_client_key=update_data.hue_client_key,
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
spi_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type,
gamesense_device_type=update_data.gamesense_device_type,
)
# Sync connection info in processor manager
@@ -257,10 +361,15 @@ async def update_device(
except ValueError:
pass
# Sync auto_shutdown in runtime state
if update_data.auto_shutdown is not None and device_id in manager._devices:
manager._devices[device_id].auto_shutdown = update_data.auto_shutdown
# Sync auto_shutdown and zone_mode in runtime state
ds = manager.find_device_state(device_id)
if ds:
if update_data.auto_shutdown is not None:
ds.auto_shutdown = update_data.auto_shutdown
if update_data.zone_mode is not None:
ds.zone_mode = update_data.zone_mode
fire_entity_event("device", "updated", device_id)
return _device_to_response(device)
except ValueError as e:
@@ -275,7 +384,7 @@ async def delete_device(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
target_store: PictureTargetStore = Depends(get_picture_target_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Delete/detach a device. Returns 409 if referenced by a target."""
@@ -298,6 +407,7 @@ async def delete_device(
# Delete from storage
store.delete_device(device_id)
fire_entity_event("device", "deleted", device_id)
logger.info(f"Deleted device {device_id}")
except HTTPException:
@@ -319,9 +429,10 @@ async def get_device_state(
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get device health/connection state."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
try:
device = store.get_device(device_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
try:
state = manager.get_device_health_dict(device_id)
@@ -331,6 +442,27 @@ async def get_device_state(
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/devices/{device_id}/ping", response_model=DeviceStateResponse, tags=["Devices"])
async def ping_device(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Force an immediate health check on a device."""
try:
device = store.get_device(device_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
try:
state = await manager.force_device_health_check(device_id)
state["device_type"] = device.device_type
return DeviceStateResponse(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ===== WLED BRIGHTNESS ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
@@ -340,19 +472,34 @@ async def get_device_brightness(
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get current brightness from the device."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
"""Get current brightness from the device.
Uses a server-side cache to avoid polling the physical device on every
frontend request — hitting the ESP32 over WiFi in the async event loop
causes ~150 ms jitter in the processing loop.
"""
try:
device = store.get_device(device_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if "brightness_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
# Return cached hardware brightness if available (updated by SET endpoint)
ds = manager.find_device_state(device_id)
if ds and ds.hardware_brightness is not None:
return {"brightness": ds.hardware_brightness}
try:
if device.device_type == "adalight":
return {"brightness": device.software_brightness}
provider = get_provider(device.device_type)
bri = await provider.get_brightness(device.url)
# Cache the result so subsequent polls don't hit the device
if ds:
ds.hardware_brightness = bri
return {"brightness": bri}
except NotImplementedError:
# Provider has no hardware brightness; use software brightness
return {"brightness": device.software_brightness}
except Exception as e:
logger.error(f"Failed to get brightness for {device_id}: {e}")
raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
@@ -367,9 +514,10 @@ async def set_device_brightness(
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Set brightness on the device."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
try:
device = store.get_device(device_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if "brightness_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
@@ -378,16 +526,21 @@ async def set_device_brightness(
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
try:
if device.device_type == "adalight":
device.software_brightness = bri
device.updated_at = __import__("datetime").datetime.utcnow()
store.save()
# Update runtime state so the processing loop picks it up
if device_id in manager._devices:
manager._devices[device_id].software_brightness = bri
return {"brightness": bri}
provider = get_provider(device.device_type)
await provider.set_brightness(device.url, bri)
try:
provider = get_provider(device.device_type)
await provider.set_brightness(device.url, bri)
except NotImplementedError:
# Provider has no hardware brightness; use software brightness
store.update_device(device_id=device_id, software_brightness=bri)
ds = manager.find_device_state(device_id)
if ds:
ds.software_brightness = bri
# Update cached hardware brightness
ds = manager.find_device_state(device_id)
if ds:
ds.hardware_brightness = bri
return {"brightness": bri}
except Exception as e:
logger.error(f"Failed to set brightness for {device_id}: {e}")
@@ -404,15 +557,16 @@ async def get_device_power(
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get current power state from the device."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
try:
device = store.get_device(device_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if "power_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
try:
# Serial devices: use tracked state (no hardware query available)
ds = manager._devices.get(device_id)
ds = manager.find_device_state(device_id)
if device.device_type in ("adalight", "ambiled") and ds:
return {"on": ds.power_on}
@@ -433,9 +587,10 @@ async def set_device_power(
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Turn device on or off."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
try:
device = store.get_device(device_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if "power_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
@@ -445,14 +600,10 @@ async def set_device_power(
try:
# For serial devices, use the cached idle client to avoid port conflicts
ds = manager._devices.get(device_id)
ds = manager.find_device_state(device_id)
if device.device_type in ("adalight", "ambiled") and ds:
if on:
# Restore idle state (static color or stay dark)
if ds.static_color is not None:
await manager.send_static_color(device_id, ds.static_color)
else:
await manager._send_clear_pixels(device_id)
if not on:
await manager.send_clear_pixels(device_id)
ds.power_on = on
else:
provider = get_provider(device.device_type)
@@ -466,160 +617,47 @@ async def set_device_power(
raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
# ===== STATIC COLOR ENDPOINTS =====
# ===== WEBSOCKET DEVICE STREAM =====
@router.get("/api/v1/devices/{device_id}/color", tags=["Settings"])
async def get_device_color(
@router.websocket("/api/v1/devices/{device_id}/ws")
async def device_ws_stream(
websocket: WebSocket,
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
token: str = Query(""),
):
"""Get the static idle color for a device."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
if "static_color" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail="Static color is not supported for this device type")
return {"color": list(device.static_color) if device.static_color else None}
"""WebSocket stream of LED pixel data for WS device type.
Wire format: [brightness_byte][R G B R G B ...]
Auth via ?token=<api_key>.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@router.put("/api/v1/devices/{device_id}/color", tags=["Settings"])
async def set_device_color(
device_id: str,
body: StaticColorUpdate,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Set or clear the static idle color for a device."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
if "static_color" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail="Static color is not supported for this device type")
color = None
if body.color is not None:
if len(body.color) != 3 or not all(isinstance(c, int) and 0 <= c <= 255 for c in body.color):
raise HTTPException(status_code=400, detail="color must be [R, G, B] with values 0-255")
color = tuple(body.color)
store.set_static_color(device_id, color)
# Update runtime state
ds = manager._devices.get(device_id)
if ds:
ds.static_color = color
# If device is idle, apply the color immediately
if color is not None and not manager.is_device_processing(device_id):
try:
await manager.send_static_color(device_id, color)
except Exception as e:
logger.warning(f"Failed to apply static color immediately: {e}")
return {"color": list(color) if color else None}
# ===== CALIBRATION ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
async def get_calibration(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
):
"""Get calibration configuration for a device."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
return CalibrationSchema(**calibration_to_dict(device.calibration))
@router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
async def update_calibration(
device_id: str,
calibration_data: CalibrationSchema,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update calibration configuration for a device."""
try:
# Convert schema to CalibrationConfig
calibration_dict = calibration_data.model_dump()
calibration = calibration_from_dict(calibration_dict)
# Update in storage
device = store.update_device(device_id, calibration=calibration)
# Update in manager (also updates active target's cached calibration)
try:
manager.update_calibration(device_id, calibration)
except ValueError:
pass
return CalibrationSchema(**calibration_to_dict(device.calibration))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update calibration: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put(
"/api/v1/devices/{device_id}/calibration/test",
response_model=CalibrationTestModeResponse,
tags=["Calibration"],
)
async def set_calibration_test_mode(
device_id: str,
body: CalibrationTestModeRequest,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Toggle calibration test mode for specific edges."""
store = get_device_store()
try:
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
except ValueError:
await websocket.close(code=4004, reason="Device not found")
return
if device.device_type != "ws":
await websocket.close(code=4003, reason="Device is not a WebSocket device")
return
# Validate edge names and colors
valid_edges = {"top", "right", "bottom", "left"}
for edge_name, color in body.edges.items():
if edge_name not in valid_edges:
raise HTTPException(
status_code=400,
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(valid_edges)}"
)
if len(color) != 3 or not all(0 <= c <= 255 for c in color):
raise HTTPException(
status_code=400,
detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255."
)
await websocket.accept()
await manager.set_test_mode(device_id, body.edges)
from wled_controller.core.devices.ws_client import get_ws_broadcaster
active_edges = list(body.edges.keys())
logger.info(
f"Test mode {'activated' if active_edges else 'deactivated'} "
f"for device {device_id}: {active_edges}"
)
broadcaster = get_ws_broadcaster()
broadcaster.add_client(device_id, websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
broadcaster.remove_client(device_id, websocket)
return CalibrationTestModeResponse(
test_mode=len(active_edges) > 0,
active_edges=active_edges,
device_id=device_id,
)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to set test mode: {e}")
raise HTTPException(status_code=500, detail=str(e))

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,9 @@ from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_pattern_template_store,
get_picture_target_store,
get_output_target_store,
)
from wled_controller.api.schemas.pattern_templates import (
PatternTemplateCreate,
@@ -13,11 +14,12 @@ from wled_controller.api.schemas.pattern_templates import (
PatternTemplateResponse,
PatternTemplateUpdate,
)
from wled_controller.api.schemas.picture_targets import KeyColorRectangleSchema
from wled_controller.storage.key_colors_picture_target import KeyColorRectangle
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_store import PatternTemplateStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -36,6 +38,7 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
)
@@ -70,8 +73,13 @@ async def create_pattern_template(
name=data.name,
rectangles=rectangles,
description=data.description,
tags=data.tags,
)
fire_entity_event("pattern_template", "created", template.id)
return _pat_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -113,8 +121,13 @@ async def update_pattern_template(
name=data.name,
rectangles=rectangles,
description=data.description,
tags=data.tags,
)
fire_entity_event("pattern_template", "updated", template_id)
return _pat_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -127,19 +140,25 @@ async def delete_pattern_template(
template_id: str,
_auth: AuthRequired,
store: PatternTemplateStore = Depends(get_pattern_template_store),
target_store: PictureTargetStore = Depends(get_picture_target_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
):
"""Delete a pattern template."""
try:
if store.is_referenced_by(template_id, target_store):
target_names = store.get_targets_referencing(template_id, target_store)
if target_names:
names = ", ".join(target_names)
raise HTTPException(
status_code=409,
detail="Cannot delete pattern template: it is referenced by one or more key colors targets. "
detail=f"Cannot delete pattern template: it is referenced by target(s): {names}. "
"Please reassign those targets before deleting.",
)
store.delete_template(template_id)
fire_entity_event("pattern_template", "deleted", template_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:

View File

@@ -1,5 +1,6 @@
"""Picture source routes."""
import asyncio
import base64
import io
import time
@@ -7,16 +8,15 @@ import time
import httpx
import numpy as np
from PIL import Image
from fastapi import APIRouter, HTTPException, Depends, Query
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
fire_entity_event,
get_picture_source_store,
get_picture_target_store,
get_output_target_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.common import (
@@ -35,14 +35,13 @@ from wled_controller.api.schemas.picture_sources import (
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -64,6 +63,15 @@ def _stream_to_response(s) -> PictureSourceResponse:
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),
)
@@ -99,23 +107,26 @@ async def validate_image(
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(source)
response.raise_for_status()
pil_image = Image.open(io.BytesIO(response.content))
img_bytes = response.content
else:
path = Path(source)
if not path.exists():
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
pil_image = Image.open(path)
img_bytes = path
pil_image = pil_image.convert("RGB")
width, height = pil_image.size
def _process_image(src):
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
pil_image = pil_image.convert("RGB")
width, height = pil_image.size
thumb = pil_image.copy()
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
buf = io.BytesIO()
thumb.save(buf, format="JPEG", quality=80)
buf.seek(0)
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
return width, height, preview
# Create thumbnail preview (max 320px wide)
thumb = pil_image.copy()
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
buf = io.BytesIO()
thumb.save(buf, format="JPEG", quality=80)
buf.seek(0)
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
return ImageValidateResponse(
valid=True, width=width, height=height, preview=preview
@@ -142,18 +153,22 @@ async def get_full_image(
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(source)
response.raise_for_status()
pil_image = Image.open(io.BytesIO(response.content))
img_bytes = response.content
else:
path = Path(source)
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
pil_image = Image.open(path)
img_bytes = path
pil_image = pil_image.convert("RGB")
buf = io.BytesIO()
pil_image.save(buf, format="JPEG", quality=90)
buf.seek(0)
return Response(content=buf.getvalue(), media_type="image/jpeg")
def _encode_full(src):
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
pil_image = pil_image.convert("RGB")
buf = io.BytesIO()
pil_image.save(buf, format="JPEG", quality=90)
return buf.getvalue()
jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes)
return Response(content=jpeg_bytes, media_type="image/jpeg")
except HTTPException:
raise
@@ -200,10 +215,23 @@ async def create_picture_source(
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,
)
fire_entity_event("picture_source", "created", stream.id)
return _stream_to_response(stream)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -244,8 +272,21 @@ async def update_picture_source(
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,
)
fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -258,20 +299,26 @@ async def delete_picture_source(
stream_id: str,
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
target_store: PictureTargetStore = Depends(get_picture_target_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
):
"""Delete a picture source."""
try:
# Check if any target references this stream
if store.is_referenced_by_target(stream_id, target_store):
target_names = store.get_targets_referencing(stream_id, target_store)
if target_names:
names = ", ".join(target_names)
raise HTTPException(
status_code=409,
detail="Cannot delete picture source: it is assigned to one or more targets. "
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
"Please reassign those targets before deleting.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -279,6 +326,52 @@ async def delete_picture_source(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"])
async def get_video_thumbnail(
stream_id: str,
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Get a thumbnail for a video picture source (first frame)."""
import base64
from io import BytesIO
from PIL import Image
from wled_controller.core.processing.video_stream import extract_thumbnail
from wled_controller.storage.picture_source import VideoCaptureSource
try:
source = store.get_stream(stream_id)
if not isinstance(source, VideoCaptureSource):
raise HTTPException(status_code=400, detail="Not a video source")
frame = await asyncio.get_event_loop().run_in_executor(
None, extract_thumbnail, source.url, source.resolution_limit
)
if frame is None:
raise HTTPException(status_code=404, detail="Could not extract thumbnail")
# Encode as JPEG
pil_img = Image.fromarray(frame)
# Resize to max 320px wide for thumbnail
if pil_img.width > 320:
ratio = 320 / pil_img.width
pil_img = pil_img.resize((320, int(pil_img.height * ratio)), Image.LANCZOS)
buf = BytesIO()
pil_img.save(buf, format="JPEG", quality=80)
b64 = base64.b64encode(buf.getvalue()).decode()
return {"thumbnail": f"data:image/jpeg;base64,{b64}", "width": pil_img.width, "height": pil_img.height}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to extract video thumbnail: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
async def test_picture_source(
stream_id: str,
@@ -286,8 +379,6 @@ async def test_picture_source(
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store),
):
"""Test a picture source by resolving its chain and running a capture test.
@@ -302,6 +393,9 @@ async def test_picture_source(
# Resolve stream chain
try:
chain = store.resolve_stream_chain(stream_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -323,7 +417,7 @@ async def test_picture_source(
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
pil_image = Image.open(path).convert("RGB")
pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB"))
actual_duration = time.perf_counter() - start_time
frame_count = 1
@@ -347,19 +441,6 @@ async def test_picture_source(
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
)
@@ -403,46 +484,50 @@ async def test_picture_source(
else:
raise ValueError("Unexpected image format from engine")
# Create thumbnail
thumbnail_width = 640
aspect_ratio = pil_image.height / pil_image.width
thumbnail_height = int(thumbnail_width * aspect_ratio)
thumbnail = pil_image.copy()
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
# Apply postprocessing filters if this is a processed stream
# Create thumbnail + encode (CPU-bound — run in thread)
pp_template_ids = chain["postprocessing_template_ids"]
flat_filters = None
if pp_template_ids:
try:
pp_template = pp_store.get_template(pp_template_ids[0])
pool = ImagePool()
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
except ValueError:
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
def _create_thumbnails_and_encode(pil_img, filters):
thumbnail_w = 640
aspect_ratio = pil_img.height / pil_img.width
thumbnail_h = int(thumbnail_w * aspect_ratio)
thumb = pil_img.copy()
thumb.thumbnail((thumbnail_w, thumbnail_h), Image.Resampling.LANCZOS)
if filters:
pool = ImagePool()
def apply_filters(img):
arr = np.array(img)
for fi in pp_template.filters:
for fi in filters:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(arr, pool)
if result is not None:
arr = result
return Image.fromarray(arr)
thumb = apply_filters(thumb)
pil_img = apply_filters(pil_img)
thumbnail = apply_filters(thumbnail)
pil_image = apply_filters(pil_image)
except ValueError:
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
img_buffer = io.BytesIO()
thumb.save(img_buffer, format='JPEG', quality=85)
thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
# Encode thumbnail
img_buffer = io.BytesIO()
thumbnail.save(img_buffer, format='JPEG', quality=85)
img_buffer.seek(0)
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
full_buffer = io.BytesIO()
pil_img.save(full_buffer, format='JPEG', quality=90)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
return thumbnail_w, thumbnail_h, thumb_b64, full_b64
thumbnail_width, thumbnail_height, thumbnail_b64, full_b64 = await asyncio.to_thread(
_create_thumbnails_and_encode, pil_image, flat_filters
)
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
# Encode full-resolution image
full_buffer = io.BytesIO()
pil_image.save(full_buffer, format='JPEG', quality=90)
full_buffer.seek(0)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
@@ -469,6 +554,9 @@ async def test_picture_source(
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
@@ -482,3 +570,180 @@ async def test_picture_source(
stream.cleanup()
except Exception as e:
logger.error(f"Error cleaning up test stream: {e}")
# ===== REAL-TIME PICTURE SOURCE TEST WEBSOCKET =====
@router.websocket("/api/v1/picture-sources/{stream_id}/test/ws")
async def test_picture_source_ws(
websocket: WebSocket,
stream_id: str,
token: str = Query(""),
duration: float = Query(5.0),
preview_width: int = Query(0),
):
"""WebSocket for picture source test with intermediate frame previews."""
from wled_controller.api.routes._test_helpers import (
authenticate_ws_token,
stream_capture_test,
)
from wled_controller.api.dependencies import (
get_picture_source_store as _get_ps_store,
get_template_store as _get_t_store,
get_pp_template_store as _get_pp_store,
)
if not authenticate_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
store = _get_ps_store()
template_store = _get_t_store()
pp_store = _get_pp_store()
# Resolve stream chain
try:
chain = store.resolve_stream_chain(stream_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
raw_stream = chain["raw_stream"]
# Static images don't benefit from streaming — reject gracefully
if isinstance(raw_stream, StaticImagePictureSource):
await websocket.close(code=4003, reason="Static image streams don't support live test")
return
# Video sources: use VideoCaptureLiveStream for test preview
if isinstance(raw_stream, VideoCaptureSource):
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
await websocket.accept()
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
video_stream = VideoCaptureLiveStream(
url=raw_stream.url,
loop=raw_stream.loop,
playback_speed=raw_stream.playback_speed,
start_time=raw_stream.start_time,
end_time=raw_stream.end_time,
resolution_limit=raw_stream.resolution_limit,
target_fps=raw_stream.target_fps,
)
def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI."""
from PIL import Image as PILImage
pil = PILImage.fromarray(image)
if pw and pil.width > pw:
ratio = pw / pil.width
pil = pil.resize((pw, int(pil.height * ratio)), PILImage.LANCZOS)
buf = io.BytesIO()
pil.save(buf, format="JPEG", quality=80)
b64 = base64.b64encode(buf.getvalue()).decode()
return f"data:image/jpeg;base64,{b64}", pil.width, pil.height
try:
await asyncio.get_event_loop().run_in_executor(None, video_stream.start)
import time as _time
fps = min(raw_stream.target_fps or 30, 30)
frame_time = 1.0 / fps
end_at = _time.monotonic() + duration
frame_count = 0
last_frame = None
while _time.monotonic() < end_at:
frame = video_stream.get_latest_frame()
if frame is not None and frame.image is not None and frame is not last_frame:
last_frame = frame
frame_count += 1
thumb, w, h = await asyncio.get_event_loop().run_in_executor(
None, _encode_video_frame, frame.image, preview_width or None,
)
elapsed = duration - (end_at - _time.monotonic())
await websocket.send_json({
"type": "frame",
"thumbnail": thumb,
"width": w, "height": h,
"elapsed": round(elapsed, 1),
"frame_count": frame_count,
})
await asyncio.sleep(frame_time)
# Send final result
if last_frame is not None:
full_img, fw, fh = await asyncio.get_event_loop().run_in_executor(
None, _encode_video_frame, last_frame.image, None,
)
await websocket.send_json({
"type": "result",
"full_image": full_img,
"width": fw, "height": fh,
"total_frames": frame_count,
"duration": duration,
"avg_fps": round(frame_count / max(duration, 0.001), 1),
})
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Video source test WS error for {stream_id}: {e}")
try:
await websocket.send_json({"type": "error", "detail": str(e)})
except Exception:
pass
finally:
video_stream.stop()
logger.info(f"Video source test WS disconnected for {stream_id}")
return
if not isinstance(raw_stream, ScreenCapturePictureSource):
await websocket.close(code=4003, reason="Unsupported stream type for live test")
return
# Create capture engine
try:
capture_template = template_store.get_template(raw_stream.capture_template_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
if capture_template.engine_type not in EngineRegistry.get_available_engines():
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
return
# Resolve postprocessing filters (if any)
pp_filters = None
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids:
try:
pp_template = pp_store.get_template(pp_template_ids[0])
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
except ValueError:
pass
# Engine factory — creates + initializes engine inside the capture thread
# to avoid thread-affinity issues (e.g. MSS uses thread-local state)
_engine_type = capture_template.engine_type
_display_index = raw_stream.display_index
_engine_config = capture_template.engine_config
def engine_factory():
s = EngineRegistry.create_stream(_engine_type, _display_index, _engine_config)
s.initialize()
return s
await websocket.accept()
logger.info(f"Picture source test WS connected for {stream_id} ({duration}s)")
try:
await stream_capture_test(
websocket, engine_factory, duration,
pp_filters=pp_filters,
preview_width=preview_width or None,
)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Picture source test WS error for {stream_id}: {e}")
finally:
logger.info(f"Picture source test WS disconnected for {stream_id}")

View File

@@ -1,873 +0,0 @@
"""Picture target routes: CRUD, processing control, settings, state, metrics."""
import base64
import io
import secrets
import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from PIL import Image
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
get_pattern_template_store,
get_picture_source_store,
get_picture_target_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.picture_targets import (
ExtractedColorResponse,
KCTestRectangleResponse,
KCTestResponse,
KeyColorsResponse,
KeyColorsSettingsSchema,
PictureTargetCreate,
PictureTargetListResponse,
PictureTargetResponse,
PictureTargetUpdate,
ProcessingSettings as ProcessingSettingsSchema,
TargetMetricsResponse,
TargetProcessingState,
)
from wled_controller.config import get_config
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.processing_settings import ProcessingSettings
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import (
KeyColorsSettings,
KeyColorsPictureTarget,
)
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings:
"""Convert schema ProcessingSettings to core ProcessingSettings."""
settings = ProcessingSettings(
display_index=schema.display_index,
fps=schema.fps,
interpolation_mode=schema.interpolation_mode,
brightness=schema.brightness,
smoothing=schema.smoothing,
standby_interval=schema.standby_interval,
state_check_interval=schema.state_check_interval,
)
if schema.color_correction:
settings.gamma = schema.color_correction.gamma
settings.saturation = schema.color_correction.saturation
# color_correction.brightness maps to settings.brightness
settings.brightness = schema.color_correction.brightness
return settings
def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchema:
"""Convert core ProcessingSettings to schema ProcessingSettings."""
from wled_controller.api.schemas.picture_targets import ColorCorrection
return ProcessingSettingsSchema(
display_index=settings.display_index,
fps=settings.fps,
interpolation_mode=settings.interpolation_mode,
brightness=settings.brightness,
smoothing=settings.smoothing,
standby_interval=settings.standby_interval,
state_check_interval=settings.state_check_interval,
color_correction=ColorCorrection(
gamma=settings.gamma,
saturation=settings.saturation,
brightness=settings.brightness,
),
)
def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema:
"""Convert core KeyColorsSettings to schema."""
return KeyColorsSettingsSchema(
fps=settings.fps,
interpolation_mode=settings.interpolation_mode,
smoothing=settings.smoothing,
pattern_template_id=settings.pattern_template_id,
brightness=settings.brightness,
)
def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings:
"""Convert schema KeyColorsSettings to core."""
return KeyColorsSettings(
fps=schema.fps,
interpolation_mode=schema.interpolation_mode,
smoothing=schema.smoothing,
pattern_template_id=schema.pattern_template_id,
brightness=schema.brightness,
)
def _target_to_response(target) -> PictureTargetResponse:
"""Convert a PictureTarget to PictureTargetResponse."""
if isinstance(target, WledPictureTarget):
return PictureTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
device_id=target.device_id,
picture_source_id=target.picture_source_id,
settings=_settings_to_schema(target.settings),
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
)
elif isinstance(target, KeyColorsPictureTarget):
return PictureTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
)
else:
return PictureTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
)
# ===== CRUD ENDPOINTS =====
@router.post("/api/v1/picture-targets", response_model=PictureTargetResponse, tags=["Targets"], status_code=201)
async def create_target(
data: PictureTargetCreate,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Create a new picture target."""
try:
# Validate device exists if provided
if data.device_id:
device = device_store.get_device(data.device_id)
if not device:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
# Convert settings
core_settings = _settings_to_core(data.settings) if data.settings else None
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
# Create in store
target = target_store.create_target(
name=data.name,
target_type=data.target_type,
device_id=data.device_id,
picture_source_id=data.picture_source_id,
settings=core_settings,
key_colors_settings=kc_settings,
description=data.description,
)
# Register in processor manager
if isinstance(target, WledPictureTarget) and target.device_id:
try:
manager.add_target(
target_id=target.id,
device_id=target.device_id,
settings=target.settings,
picture_source_id=target.picture_source_id,
)
except ValueError as e:
logger.warning(f"Could not register target {target.id} in processor manager: {e}")
elif isinstance(target, KeyColorsPictureTarget):
try:
manager.add_kc_target(
target_id=target.id,
picture_source_id=target.picture_source_id,
settings=target.settings,
)
except ValueError as e:
logger.warning(f"Could not register KC target {target.id}: {e}")
return _target_to_response(target)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create target: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-targets", response_model=PictureTargetListResponse, tags=["Targets"])
async def list_targets(
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""List all picture targets."""
targets = target_store.get_all_targets()
responses = [_target_to_response(t) for t in targets]
return PictureTargetListResponse(targets=responses, count=len(responses))
@router.get("/api/v1/picture-targets/{target_id}", response_model=PictureTargetResponse, tags=["Targets"])
async def get_target(
target_id: str,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Get a picture target by ID."""
try:
target = target_store.get_target(target_id)
return _target_to_response(target)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/picture-targets/{target_id}", response_model=PictureTargetResponse, tags=["Targets"])
async def update_target(
target_id: str,
data: PictureTargetUpdate,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update a picture target."""
try:
# Validate device exists if changing
if data.device_id is not None and data.device_id:
device = device_store.get_device(data.device_id)
if not device:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
# Convert settings
core_settings = _settings_to_core(data.settings) if data.settings else None
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
# Update in store
target = target_store.update_target(
target_id=target_id,
name=data.name,
device_id=data.device_id,
picture_source_id=data.picture_source_id,
settings=core_settings,
key_colors_settings=kc_settings,
description=data.description,
)
# Sync processor manager (unified API handles both target types)
try:
if data.settings is not None or data.key_colors_settings is not None:
manager.update_target_settings(target_id, target.settings)
if data.picture_source_id is not None:
manager.update_target_source(target_id, target.picture_source_id)
if data.device_id is not None and isinstance(target, WledPictureTarget):
manager.update_target_device(target_id, target.device_id)
except ValueError:
pass
return _target_to_response(target)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update target: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/picture-targets/{target_id}", status_code=204, tags=["Targets"])
async def delete_target(
target_id: str,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Delete a picture target. Stops processing first if active."""
try:
# Stop processing if running
try:
await manager.stop_processing(target_id)
except ValueError:
pass
# Remove from manager
try:
manager.remove_target(target_id)
except (ValueError, RuntimeError):
pass
# Delete from store
target_store.delete_target(target_id)
logger.info(f"Deleted target {target_id}")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete target: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/picture-targets/{target_id}/start", tags=["Processing"])
async def start_processing(
target_id: str,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for a picture target."""
try:
# Verify target exists in store
target_store.get_target(target_id)
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}")
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error(f"Failed to start processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/picture-targets/{target_id}/stop", tags=["Processing"])
async def stop_processing(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for a picture target."""
try:
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}")
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== STATE & METRICS ENDPOINTS =====
@router.get("/api/v1/picture-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"])
async def get_target_state(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get current processing state for a target."""
try:
state = manager.get_target_state(target_id)
return TargetProcessingState(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target state: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-targets/{target_id}/settings", tags=["Settings"])
async def get_target_settings(
target_id: str,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Get processing settings for a target."""
try:
target = target_store.get_target(target_id)
if isinstance(target, KeyColorsPictureTarget):
return _kc_settings_to_schema(target.settings)
if isinstance(target, WledPictureTarget):
return _settings_to_schema(target.settings)
return ProcessingSettingsSchema()
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/picture-targets/{target_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
async def update_target_settings(
target_id: str,
settings: ProcessingSettingsSchema,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update processing settings for a target.
Merges with existing settings so callers can send partial updates.
"""
try:
target = target_store.get_target(target_id)
if not isinstance(target, WledPictureTarget):
raise HTTPException(status_code=400, detail="Target does not support processing settings")
existing = target.settings
sent = settings.model_fields_set
# Merge: only override fields the client explicitly provided
new_settings = ProcessingSettings(
display_index=settings.display_index if 'display_index' in sent else existing.display_index,
fps=settings.fps if 'fps' in sent else existing.fps,
interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
brightness=settings.brightness if 'brightness' in sent else existing.brightness,
gamma=existing.gamma,
saturation=existing.saturation,
smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing,
standby_interval=settings.standby_interval if 'standby_interval' in sent else existing.standby_interval,
state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval,
)
# Apply color_correction fields if explicitly sent
if 'color_correction' in sent and settings.color_correction:
cc_sent = settings.color_correction.model_fields_set
if 'brightness' in cc_sent:
new_settings.brightness = settings.color_correction.brightness
if 'gamma' in cc_sent:
new_settings.gamma = settings.color_correction.gamma
if 'saturation' in cc_sent:
new_settings.saturation = settings.color_correction.saturation
# Update in store
target_store.update_target(target_id, settings=new_settings)
# Update in manager
try:
manager.update_target_settings(target_id, new_settings)
except ValueError:
pass
return _settings_to_schema(new_settings)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update target settings: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
async def get_target_metrics(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get processing metrics for a target."""
try:
metrics = manager.get_target_metrics(target_id)
return TargetMetricsResponse(**metrics)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== KEY COLORS ENDPOINTS =====
@router.get("/api/v1/picture-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
async def get_target_colors(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get latest extracted colors for a key-colors target (polling)."""
try:
raw_colors = manager.get_kc_latest_colors(target_id)
colors = {}
for name, (r, g, b) in raw_colors.items():
colors[name] = ExtractedColorResponse(
r=r, g=g, b=b,
hex=f"#{r:02x}{g:02x}{b:02x}",
)
from datetime import datetime
return KeyColorsResponse(
target_id=target_id,
colors=colors,
timestamp=datetime.utcnow(),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/picture-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"])
async def test_kc_target(
target_id: str,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
source_store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
pattern_store: PatternTemplateStore = Depends(get_pattern_template_store),
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
pp_template_store=Depends(get_pp_template_store),
):
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
import httpx
stream = None
try:
# 1. Load and validate KC target
try:
target = target_store.get_target(target_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if not isinstance(target, KeyColorsPictureTarget):
raise HTTPException(status_code=400, detail="Target is not a key_colors target")
settings = target.settings
# 2. Resolve pattern template
if not settings.pattern_template_id:
raise HTTPException(status_code=400, detail="No pattern template configured")
try:
pattern_tmpl = pattern_store.get_template(settings.pattern_template_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}")
rectangles = pattern_tmpl.rectangles
if not rectangles:
raise HTTPException(status_code=400, detail="Pattern template has no rectangles")
# 3. Resolve picture source and capture a frame
if not target.picture_source_id:
raise HTTPException(status_code=400, detail="No picture source configured")
try:
chain = source_store.resolve_stream_chain(target.picture_source_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
else:
from pathlib import Path
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
pil_image = Image.open(path).convert("RGB")
elif isinstance(raw_stream, ScreenCapturePictureSource):
try:
capture_template = template_store.get_template(raw_stream.capture_template_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Capture template not found: {raw_stream.capture_template_id}",
)
display_index = raw_stream.display_index
if capture_template.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException(
status_code=400,
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
)
locked_device_id = processor_manager.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
raise HTTPException(
status_code=409,
detail=f"Display {display_index} is currently being captured by device '{device_name}'. "
f"Please stop the device processing before testing.",
)
stream = EngineRegistry.create_stream(
capture_template.engine_type, display_index, capture_template.engine_config
)
stream.initialize()
screen_capture = stream.capture_frame()
if screen_capture is None:
raise RuntimeError("No frame captured")
if isinstance(screen_capture.image, np.ndarray):
pil_image = Image.fromarray(screen_capture.image)
else:
raise ValueError("Unexpected image format from engine")
else:
raise HTTPException(status_code=400, detail="Unsupported picture source type")
# 3b. Apply postprocessing filters (if the picture source has a filter chain)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store.get_template(pp_id)
except ValueError:
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
continue
for fi in pp_template.filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
pil_image = Image.fromarray(img_array)
# 4. Extract colors from each rectangle
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append(KCTestRectangleResponse(
name=rect.name,
x=rect.x,
y=rect.y,
width=rect.width,
height=rect.height,
color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"),
))
# 5. Encode frame as base64 JPEG
full_buffer = io.BytesIO()
pil_image.save(full_buffer, format='JPEG', quality=90)
full_buffer.seek(0)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
image_data_uri = f"data:image/jpeg;base64,{full_b64}"
return KCTestResponse(
image=image_data_uri,
rectangles=result_rects,
interpolation_mode=settings.interpolation_mode,
pattern_template_name=pattern_tmpl.name,
)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}")
except Exception as e:
logger.error(f"Failed to test KC target: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
if stream:
try:
stream.cleanup()
except Exception as e:
logger.error(f"Error cleaning up test stream: {e}")
@router.websocket("/api/v1/picture-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
# Authenticate
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
try:
manager.add_kc_ws_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
# Keep alive — wait for client messages (or disconnect)
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)
# ===== STATE CHANGE EVENT STREAM =====
@router.websocket("/api/v1/events/ws")
async def events_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
queue = manager.subscribe_events()
try:
while True:
event = await queue.get()
await websocket.send_json(event)
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
manager.unsubscribe_events(queue)
# ===== OVERLAY VISUALIZATION =====
@router.post("/api/v1/picture-targets/{target_id}/overlay/start", tags=["Visualization"])
async def start_target_overlay(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Start screen overlay visualization for a target.
Displays a transparent overlay on the target display showing:
- Border sampling zones (colored rectangles)
- LED position markers (numbered dots)
- Pixel-to-LED mapping ranges (colored segments)
- Calibration info text
"""
try:
# Get target name from store
target = target_store.get_target(target_id)
if not target:
raise ValueError(f"Target {target_id} not found")
await manager.start_overlay(target_id, target.name)
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error(f"Failed to start overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/picture-targets/{target_id}/overlay/stop", tags=["Visualization"])
async def stop_target_overlay(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop screen overlay visualization for a target."""
try:
await manager.stop_overlay(target_id)
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-targets/{target_id}/overlay/status", tags=["Visualization"])
async def get_overlay_status(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Check if overlay is active for a target."""
try:
active = manager.is_overlay_active(target_id)
return {"target_id": target_id, "active": active}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -7,14 +7,13 @@ import time
import httpx
import numpy as np
from PIL import Image
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
fire_entity_event,
get_picture_source_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.common import (
@@ -32,13 +31,12 @@ from wled_controller.api.schemas.postprocessing import (
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -54,6 +52,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
)
@@ -63,13 +62,9 @@ async def list_pp_templates(
store: PostprocessingTemplateStore = Depends(get_pp_template_store),
):
"""List all postprocessing templates."""
try:
templates = store.get_all_templates()
responses = [_pp_template_to_response(t) for t in templates]
return PostprocessingTemplateListResponse(templates=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list postprocessing templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
templates = store.get_all_templates()
responses = [_pp_template_to_response(t) for t in templates]
return PostprocessingTemplateListResponse(templates=responses, count=len(responses))
@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201)
@@ -85,8 +80,13 @@ async def create_pp_template(
name=data.name,
filters=filters,
description=data.description,
tags=data.tags,
)
fire_entity_event("pp_template", "created", template.id)
return _pp_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -123,8 +123,13 @@ async def update_pp_template(
name=data.name,
filters=filters,
description=data.description,
tags=data.tags,
)
fire_entity_event("pp_template", "updated", template_id)
return _pp_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -142,15 +147,21 @@ async def delete_pp_template(
"""Delete a postprocessing template."""
try:
# Check if any picture source references this template
if store.is_referenced_by(template_id, stream_store):
source_names = store.get_sources_referencing(template_id, stream_store)
if source_names:
names = ", ".join(source_names)
raise HTTPException(
status_code=409,
detail="Cannot delete postprocessing template: it is referenced by one or more picture sources. "
detail=f"Cannot delete postprocessing template: it is referenced by picture source(s): {names}. "
"Please reassign those streams before deleting.",
)
store.delete_template(template_id)
fire_entity_event("pp_template", "deleted", template_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -166,8 +177,6 @@ async def test_pp_template(
pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store),
stream_store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
):
"""Test a postprocessing template by capturing from a source stream and applying filters."""
stream = None
@@ -181,6 +190,9 @@ async def test_pp_template(
# Resolve source stream chain to get the raw stream
try:
chain = stream_store.resolve_stream_chain(test_request.source_stream_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -225,19 +237,6 @@ async def test_pp_template(
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
)
@@ -281,13 +280,14 @@ async def test_pp_template(
thumbnail = pil_image.copy()
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
# Apply postprocessing filters
if pp_template.filters:
# Apply postprocessing filters (expand filter_template references)
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
if flat_filters:
pool = ImagePool()
def apply_filters(img):
arr = np.array(img)
for fi in pp_template.filters:
for fi in flat_filters:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(arr, pool)
if result is not None:
@@ -314,6 +314,7 @@ async def test_pp_template(
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
width, height = pil_image.size
thumb_w, thumb_h = thumbnail.size
return TemplateTestResponse(
full_capture=CaptureImage(
@@ -321,8 +322,8 @@ async def test_pp_template(
full_image=full_data_uri,
width=width,
height=height,
thumbnail_width=thumbnail_width,
thumbnail_height=thumbnail_height,
thumbnail_width=thumb_w,
thumbnail_height=thumb_h,
),
border_extraction=None,
performance=PerformanceMetrics(
@@ -335,6 +336,9 @@ async def test_pp_template(
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -346,3 +350,104 @@ async def test_pp_template(
stream.cleanup()
except Exception:
pass
# ===== REAL-TIME PP TEMPLATE TEST WEBSOCKET =====
@router.websocket("/api/v1/postprocessing-templates/{template_id}/test/ws")
async def test_pp_template_ws(
websocket: WebSocket,
template_id: str,
token: str = Query(""),
duration: float = Query(5.0),
source_stream_id: str = Query(""),
preview_width: int = Query(0),
):
"""WebSocket for PP template test with intermediate frame previews."""
from wled_controller.api.routes._test_helpers import (
authenticate_ws_token,
stream_capture_test,
)
from wled_controller.api.dependencies import (
get_picture_source_store as _get_ps_store,
get_template_store as _get_t_store,
get_pp_template_store as _get_pp_store,
)
if not authenticate_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
if not source_stream_id:
await websocket.close(code=4003, reason="source_stream_id is required")
return
pp_store = _get_pp_store()
stream_store = _get_ps_store()
template_store = _get_t_store()
# Get PP template
try:
pp_template = pp_store.get_template(template_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
# Resolve source stream chain
try:
chain = stream_store.resolve_stream_chain(source_stream_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
await websocket.close(code=4003, reason="Static image streams don't support live test")
return
if not isinstance(raw_stream, ScreenCapturePictureSource):
await websocket.close(code=4003, reason="Unsupported stream type for live test")
return
# Create capture engine
try:
capture_template = template_store.get_template(raw_stream.capture_template_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
if capture_template.engine_type not in EngineRegistry.get_available_engines():
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
return
# Resolve PP filters
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
# Engine factory — creates + initializes engine inside the capture thread
# to avoid thread-affinity issues (e.g. MSS uses thread-local state)
_engine_type = capture_template.engine_type
_display_index = raw_stream.display_index
_engine_config = capture_template.engine_config
def engine_factory():
s = EngineRegistry.create_stream(_engine_type, _display_index, _engine_config)
s.initialize()
return s
await websocket.accept()
logger.info(f"PP template test WS connected for {template_id} ({duration}s)")
try:
await stream_capture_test(
websocket, engine_factory, duration,
pp_filters=pp_filters,
preview_width=preview_width or None,
)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"PP template test WS error for {template_id}: {e}")
finally:
logger.info(f"PP template test WS disconnected for {template_id}")

View File

@@ -1,255 +0,0 @@
"""Profile management API routes."""
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_picture_target_store,
get_profile_engine,
get_profile_store,
)
from wled_controller.api.schemas.profiles import (
ConditionSchema,
ProfileCreate,
ProfileListResponse,
ProfileResponse,
ProfileUpdate,
)
from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.profile import ApplicationCondition, Condition
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ===== Helpers =====
def _condition_from_schema(s: ConditionSchema) -> Condition:
if s.condition_type == "application":
return ApplicationCondition(
apps=s.apps or [],
match_type=s.match_type or "running",
)
raise ValueError(f"Unknown condition type: {s.condition_type}")
def _condition_to_schema(c: Condition) -> ConditionSchema:
d = c.to_dict()
return ConditionSchema(**d)
def _profile_to_response(profile, engine: ProfileEngine) -> ProfileResponse:
state = engine.get_profile_state(profile.id)
return ProfileResponse(
id=profile.id,
name=profile.name,
enabled=profile.enabled,
condition_logic=profile.condition_logic,
conditions=[_condition_to_schema(c) for c in profile.conditions],
target_ids=profile.target_ids,
is_active=state["is_active"],
active_target_ids=state["active_target_ids"],
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
created_at=profile.created_at,
updated_at=profile.updated_at,
)
def _validate_condition_logic(logic: str) -> None:
if logic not in ("or", "and"):
raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.")
def _validate_target_ids(target_ids: list, target_store: PictureTargetStore) -> None:
for tid in target_ids:
try:
target_store.get_target(tid)
except ValueError:
raise HTTPException(status_code=400, detail=f"Target not found: {tid}")
# ===== CRUD Endpoints =====
@router.post(
"/api/v1/profiles",
response_model=ProfileResponse,
tags=["Profiles"],
status_code=201,
)
async def create_profile(
data: ProfileCreate,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Create a new profile."""
_validate_condition_logic(data.condition_logic)
_validate_target_ids(data.target_ids, target_store)
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
profile = store.create_profile(
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
target_ids=data.target_ids,
)
return _profile_to_response(profile, engine)
@router.get(
"/api/v1/profiles",
response_model=ProfileListResponse,
tags=["Profiles"],
)
async def list_profiles(
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
):
"""List all profiles."""
profiles = store.get_all_profiles()
return ProfileListResponse(
profiles=[_profile_to_response(p, engine) for p in profiles],
count=len(profiles),
)
@router.get(
"/api/v1/profiles/{profile_id}",
response_model=ProfileResponse,
tags=["Profiles"],
)
async def get_profile(
profile_id: str,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
):
"""Get a single profile."""
try:
profile = store.get_profile(profile_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _profile_to_response(profile, engine)
@router.put(
"/api/v1/profiles/{profile_id}",
response_model=ProfileResponse,
tags=["Profiles"],
)
async def update_profile(
profile_id: str,
data: ProfileUpdate,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Update a profile."""
if data.condition_logic is not None:
_validate_condition_logic(data.condition_logic)
if data.target_ids is not None:
_validate_target_ids(data.target_ids, target_store)
conditions = None
if data.conditions is not None:
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
try:
# If disabling, deactivate first
if data.enabled is False:
await engine.deactivate_if_active(profile_id)
profile = store.update_profile(
profile_id=profile_id,
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
target_ids=data.target_ids,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _profile_to_response(profile, engine)
@router.delete(
"/api/v1/profiles/{profile_id}",
status_code=204,
tags=["Profiles"],
)
async def delete_profile(
profile_id: str,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
):
"""Delete a profile."""
# Deactivate first (stop owned targets)
await engine.deactivate_if_active(profile_id)
try:
store.delete_profile(profile_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ===== Enable/Disable =====
@router.post(
"/api/v1/profiles/{profile_id}/enable",
response_model=ProfileResponse,
tags=["Profiles"],
)
async def enable_profile(
profile_id: str,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
):
"""Enable a profile."""
try:
profile = store.update_profile(profile_id=profile_id, enabled=True)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _profile_to_response(profile, engine)
@router.post(
"/api/v1/profiles/{profile_id}/disable",
response_model=ProfileResponse,
tags=["Profiles"],
)
async def disable_profile(
profile_id: str,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
):
"""Disable a profile and stop any targets it owns."""
await engine.deactivate_if_active(profile_id)
try:
profile = store.update_profile(profile_id=profile_id, enabled=False)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _profile_to_response(profile, engine)

View File

@@ -0,0 +1,271 @@
"""Scene preset API routes — CRUD, capture, activate, recapture."""
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_output_target_store,
get_processor_manager,
get_scene_preset_store,
)
from wled_controller.api.schemas.scene_presets import (
ActivateResponse,
ScenePresetCreate,
ScenePresetListResponse,
ScenePresetResponse,
ScenePresetUpdate,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.scenes.scene_activator import (
apply_scene_state,
capture_current_snapshot,
)
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
return ScenePresetResponse(
id=preset.id,
name=preset.name,
description=preset.description,
targets=[{
"target_id": t.target_id,
"running": t.running,
"color_strip_source_id": t.color_strip_source_id,
"brightness_value_source_id": t.brightness_value_source_id,
"fps": t.fps,
} for t in preset.targets],
order=preset.order,
tags=preset.tags,
created_at=preset.created_at,
updated_at=preset.updated_at,
)
# ===== CRUD =====
@router.post(
"/api/v1/scene-presets",
response_model=ScenePresetResponse,
tags=["Scene Presets"],
status_code=201,
)
async def create_scene_preset(
data: ScenePresetCreate,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Capture current state as a new scene preset."""
target_ids = set(data.target_ids) if data.target_ids is not None else None
targets = capture_current_snapshot(target_store, manager, target_ids)
now = datetime.now(timezone.utc)
preset = ScenePreset(
id=f"scene_{uuid.uuid4().hex[:8]}",
name=data.name,
description=data.description,
targets=targets,
order=store.count(),
tags=data.tags if data.tags is not None else [],
created_at=now,
updated_at=now,
)
try:
preset = store.create_preset(preset)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_preset", "created", preset.id)
return _preset_to_response(preset)
@router.get(
"/api/v1/scene-presets",
response_model=ScenePresetListResponse,
tags=["Scene Presets"],
)
async def list_scene_presets(
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""List all scene presets."""
presets = store.get_all_presets()
return ScenePresetListResponse(
presets=[_preset_to_response(p) for p in presets],
count=len(presets),
)
@router.get(
"/api/v1/scene-presets/{preset_id}",
response_model=ScenePresetResponse,
tags=["Scene Presets"],
)
async def get_scene_preset(
preset_id: str,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Get a single scene preset."""
try:
preset = store.get_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _preset_to_response(preset)
@router.put(
"/api/v1/scene-presets/{preset_id}",
response_model=ScenePresetResponse,
tags=["Scene Presets"],
)
async def update_scene_preset(
preset_id: str,
data: ScenePresetUpdate,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update scene preset metadata and optionally change targets."""
# If target_ids changed, update the snapshot: keep state for existing targets,
# capture fresh state for newly added targets, drop removed ones.
new_targets = None
if data.target_ids is not None:
try:
existing = store.get_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
existing_map = {t.target_id: t for t in existing.targets}
new_target_ids = set(data.target_ids)
# Capture fresh state for newly added targets
added_ids = new_target_ids - set(existing_map.keys())
fresh = capture_current_snapshot(target_store, manager, added_ids) if added_ids else []
fresh_map = {t.target_id: t for t in fresh}
# Build new target list preserving order from target_ids
new_targets = []
for tid in data.target_ids:
if tid in existing_map:
new_targets.append(existing_map[tid])
elif tid in fresh_map:
new_targets.append(fresh_map[tid])
try:
preset = store.update_preset(
preset_id,
name=data.name,
description=data.description,
order=data.order,
targets=new_targets,
tags=data.tags,
)
except ValueError as e:
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
fire_entity_event("scene_preset", "updated", preset_id)
return _preset_to_response(preset)
@router.delete(
"/api/v1/scene-presets/{preset_id}",
status_code=204,
tags=["Scene Presets"],
)
async def delete_scene_preset(
preset_id: str,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Delete a scene preset."""
try:
store.delete_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("scene_preset", "deleted", preset_id)
# ===== Recapture =====
@router.post(
"/api/v1/scene-presets/{preset_id}/recapture",
response_model=ScenePresetResponse,
tags=["Scene Presets"],
)
async def recapture_scene_preset(
preset_id: str,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Re-capture current state into an existing preset (updates snapshot)."""
try:
existing = store.get_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# Only recapture targets that are already in the preset
existing_ids = {t.target_id for t in existing.targets}
targets = capture_current_snapshot(target_store, manager, existing_ids)
new_snapshot = ScenePreset(
id=preset_id,
name="",
targets=targets,
)
try:
preset = store.recapture_preset(preset_id, new_snapshot)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _preset_to_response(preset)
# ===== Activate =====
@router.post(
"/api/v1/scene-presets/{preset_id}/activate",
response_model=ActivateResponse,
tags=["Scene Presets"],
)
async def activate_scene_preset(
preset_id: str,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Activate a scene preset — restore the captured state."""
try:
preset = store.get_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
status, errors = await apply_scene_state(preset, target_store, manager)
if not errors:
logger.info(f"Scene preset '{preset.name}' activated successfully")
fire_entity_event("scene_preset", "updated", preset_id)
return ActivateResponse(status=status, errors=errors)

View File

@@ -0,0 +1,204 @@
"""Sync clock routes: CRUD + runtime control for synchronization clocks."""
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_sync_clock_manager,
get_sync_clock_store,
)
from wled_controller.api.schemas.sync_clocks import (
SyncClockCreate,
SyncClockListResponse,
SyncClockResponse,
SyncClockUpdate,
)
from wled_controller.storage.sync_clock import SyncClock
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockResponse:
"""Convert a SyncClock to a SyncClockResponse (with runtime state)."""
rt = manager.get_runtime(clock.id)
return SyncClockResponse(
id=clock.id,
name=clock.name,
speed=rt.speed if rt else clock.speed,
description=clock.description,
tags=clock.tags,
is_running=rt.is_running if rt else True,
elapsed_time=rt.get_time() if rt else 0.0,
created_at=clock.created_at,
updated_at=clock.updated_at,
)
@router.get("/api/v1/sync-clocks", response_model=SyncClockListResponse, tags=["Sync Clocks"])
async def list_sync_clocks(
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""List all synchronization clocks."""
clocks = store.get_all_clocks()
return SyncClockListResponse(
clocks=[_to_response(c, manager) for c in clocks],
count=len(clocks),
)
@router.post("/api/v1/sync-clocks", response_model=SyncClockResponse, status_code=201, tags=["Sync Clocks"])
async def create_sync_clock(
data: SyncClockCreate,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Create a new synchronization clock."""
try:
clock = store.create_clock(
name=data.name,
speed=data.speed,
description=data.description,
tags=data.tags,
)
fire_entity_event("sync_clock", "created", clock.id)
return _to_response(clock, manager)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"])
async def get_sync_clock(
clock_id: str,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Get a synchronization clock by ID."""
try:
clock = store.get_clock(clock_id)
return _to_response(clock, manager)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"])
async def update_sync_clock(
clock_id: str,
data: SyncClockUpdate,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Update a synchronization clock. Speed changes are hot-applied to running streams."""
try:
clock = store.update_clock(
clock_id=clock_id,
name=data.name,
speed=data.speed,
description=data.description,
tags=data.tags,
)
# Hot-update runtime speed
if data.speed is not None:
manager.update_speed(clock_id, clock.speed)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/api/v1/sync-clocks/{clock_id}", status_code=204, tags=["Sync Clocks"])
async def delete_sync_clock(
clock_id: str,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Delete a synchronization clock (fails if referenced by CSS sources)."""
try:
# Check references
for source in css_store.get_all_sources():
if getattr(source, "clock_id", None) == clock_id:
raise ValueError(
f"Cannot delete: referenced by color strip source '{source.name}'"
)
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# ── Runtime control ──────────────────────────────────────────────────
@router.post("/api/v1/sync-clocks/{clock_id}/pause", response_model=SyncClockResponse, tags=["Sync Clocks"])
async def pause_sync_clock(
clock_id: str,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Pause a synchronization clock — all linked animations freeze."""
try:
clock = store.get_clock(clock_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
manager.pause(clock_id)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager)
@router.post("/api/v1/sync-clocks/{clock_id}/resume", response_model=SyncClockResponse, tags=["Sync Clocks"])
async def resume_sync_clock(
clock_id: str,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Resume a paused synchronization clock."""
try:
clock = store.get_clock(clock_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
manager.resume(clock_id)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager)
@router.post("/api/v1/sync-clocks/{clock_id}/reset", response_model=SyncClockResponse, tags=["Sync Clocks"])
async def reset_sync_clock(
clock_id: str,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Reset a synchronization clock to t=0 — all linked animations restart."""
try:
clock = store.get_clock(clock_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
manager.reset(clock_id)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager)

View File

@@ -1,43 +1,108 @@
"""System routes: health, version, displays, performance."""
"""System routes: health, version, displays, performance, backup/restore, ADB."""
import asyncio
import io
import json
import logging
import platform
import subprocess
import sys
from datetime import datetime
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import psutil
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from wled_controller import __version__
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_auto_backup_engine,
get_audio_source_store,
get_audio_template_store,
get_automation_store,
get_color_strip_store,
get_device_store,
get_output_target_store,
get_pattern_template_store,
get_picture_source_store,
get_pp_template_store,
get_processor_manager,
get_scene_preset_store,
get_sync_clock_store,
get_template_store,
get_value_source_store,
)
from wled_controller.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
BackupFileInfo,
BackupListResponse,
DisplayInfo,
DisplayListResponse,
ExternalUrlRequest,
ExternalUrlResponse,
GpuInfo,
HealthResponse,
LogLevelRequest,
LogLevelResponse,
MQTTSettingsRequest,
MQTTSettingsResponse,
PerformanceResponse,
ProcessListResponse,
RestoreResponse,
VersionResponse,
)
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.config import get_config, is_demo_mode
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.utils import get_logger
from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__)
# Prime psutil CPU counter (first call always returns 0.0)
psutil.cpu_percent(interval=None)
# Try to initialize NVIDIA GPU monitoring
_nvml_available = False
try:
import pynvml as _pynvml_mod # nvidia-ml-py (the pynvml wrapper is deprecated)
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
from wled_controller.storage.base_store import EntityNotFoundError
_pynvml_mod.nvmlInit()
_nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0)
_nvml_available = True
_nvml = _pynvml_mod
logger.info(f"NVIDIA GPU monitoring enabled: {_nvml.nvmlDeviceGetName(_nvml_handle)}")
except Exception:
_nvml = None
logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)")
def _get_cpu_name() -> str | None:
"""Get a human-friendly CPU model name (cached at module level)."""
try:
if platform.system() == "Windows":
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
)
name, _ = winreg.QueryValueEx(key, "ProcessorNameString")
winreg.CloseKey(key)
return name.strip()
elif platform.system() == "Linux":
with open("/proc/cpuinfo") as f:
for line in f:
if "model name" in line:
return line.split(":")[1].strip()
elif platform.system() == "Darwin":
return (
subprocess.check_output(
["sysctl", "-n", "machdep.cpu.brand_string"]
)
.decode()
.strip()
)
except Exception as e:
logger.warning("CPU name detection failed: %s", e)
return platform.processor() or None
_cpu_name: str | None = _get_cpu_name()
router = APIRouter()
@@ -52,8 +117,9 @@ async def health_check():
return HealthResponse(
status="healthy",
timestamp=datetime.utcnow(),
timestamp=datetime.now(timezone.utc),
version=__version__,
demo_mode=get_config().demo,
)
@@ -69,20 +135,65 @@ async def get_version():
version=__version__,
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
api_version="v1",
demo_mode=get_config().demo,
)
@router.get("/api/v1/tags", tags=["Tags"])
async def list_all_tags(_: AuthRequired):
"""Get all tags used across all entities."""
all_tags: set[str] = set()
store_getters = [
get_device_store, get_output_target_store, get_color_strip_store,
get_picture_source_store, get_audio_source_store, get_value_source_store,
get_sync_clock_store, get_automation_store, get_scene_preset_store,
get_template_store, get_audio_template_store, get_pp_template_store,
get_pattern_template_store,
]
for getter in store_getters:
try:
store = getter()
except RuntimeError:
continue
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
items = fn() if fn else None
if items:
for item in items:
all_tags.update(item.tags)
return {"tags": sorted(all_tags)}
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
async def get_displays(_: AuthRequired):
async def get_displays(
_: AuthRequired,
engine_type: Optional[str] = Query(None, description="Engine type to get displays for"),
):
"""Get list of available displays.
Returns information about all available monitors/displays that can be captured.
When ``engine_type`` is provided, returns displays specific to that engine
(e.g. ``scrcpy`` returns connected Android devices instead of desktop monitors).
"""
logger.info("Listing available displays")
logger.info(f"Listing available displays (engine_type={engine_type})")
try:
# Get available displays with all metadata (name, refresh rate, etc.)
display_dataclasses = get_available_displays()
from wled_controller.core.capture_engines import EngineRegistry
if engine_type:
engine_cls = EngineRegistry.get_engine(engine_type)
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
elif is_demo_mode():
# In demo mode, use the best available engine (demo engine at priority 1000)
# instead of the mss-based real display detection
best = EngineRegistry.get_best_available_engine()
if best:
engine_cls = EngineRegistry.get_engine(best)
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
else:
display_dataclasses = await asyncio.to_thread(get_available_displays)
else:
display_dataclasses = await asyncio.to_thread(get_available_displays)
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
displays = [
@@ -106,6 +217,12 @@ async def get_displays(_: AuthRequired):
count=len(displays),
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to get displays: {e}")
raise HTTPException(
@@ -118,9 +235,9 @@ async def get_displays(_: AuthRequired):
async def get_running_processes(_: AuthRequired):
"""Get list of currently running process names.
Returns a sorted list of unique process names for use in profile conditions.
Returns a sorted list of unique process names for use in automation conditions.
"""
from wled_controller.core.profiles.platform_detector import PlatformDetector
from wled_controller.core.automations.platform_detector import PlatformDetector
try:
detector = PlatformDetector()
@@ -140,8 +257,13 @@ async def get_running_processes(_: AuthRequired):
response_model=PerformanceResponse,
tags=["Config"],
)
async def get_system_performance(_: AuthRequired):
"""Get current system performance metrics (CPU, RAM, GPU)."""
def get_system_performance(_: AuthRequired):
"""Get current system performance metrics (CPU, RAM, GPU).
Uses sync ``def`` so FastAPI runs it in a thread pool — the psutil
and NVML calls are blocking and would stall the event loop if run
in an ``async def`` handler.
"""
mem = psutil.virtual_memory()
gpu = None
@@ -159,14 +281,714 @@ async def get_system_performance(_: AuthRequired):
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
temperature_c=float(temp),
)
except Exception:
pass
except Exception as e:
logger.debug("NVML query failed: %s", e)
return PerformanceResponse(
cpu_name=_cpu_name,
cpu_percent=psutil.cpu_percent(interval=None),
ram_used_mb=round(mem.used / 1024 / 1024, 1),
ram_total_mb=round(mem.total / 1024 / 1024, 1),
ram_percent=mem.percent,
gpu=gpu,
timestamp=datetime.utcnow(),
timestamp=datetime.now(timezone.utc),
)
@router.get("/api/v1/system/metrics-history", tags=["Config"])
async def get_metrics_history(
_: AuthRequired,
manager=Depends(get_processor_manager),
):
"""Return the last ~2 minutes of system and per-target metrics.
Used by the dashboard to seed charts on page load so history
survives browser refreshes.
"""
return manager.metrics_history.get_history()
# ---------------------------------------------------------------------------
# Configuration backup / restore
# ---------------------------------------------------------------------------
# Mapping: logical store name → StorageConfig attribute name
STORE_MAP = {
"devices": "devices_file",
"capture_templates": "templates_file",
"postprocessing_templates": "postprocessing_templates_file",
"picture_sources": "picture_sources_file",
"output_targets": "output_targets_file",
"pattern_templates": "pattern_templates_file",
"color_strip_sources": "color_strip_sources_file",
"audio_sources": "audio_sources_file",
"audio_templates": "audio_templates_file",
"value_sources": "value_sources_file",
"sync_clocks": "sync_clocks_file",
"color_strip_processing_templates": "color_strip_processing_templates_file",
"automations": "automations_file",
"scene_presets": "scene_presets_file",
}
_SERVER_DIR = Path(__file__).resolve().parents[4]
def _schedule_restart() -> None:
"""Spawn a restart script after a short delay so the HTTP response completes."""
def _restart():
import time
time.sleep(1)
if sys.platform == "win32":
subprocess.Popen(
["powershell", "-ExecutionPolicy", "Bypass", "-File",
str(_SERVER_DIR / "restart.ps1")],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
subprocess.Popen(
["bash", str(_SERVER_DIR / "restart.sh")],
start_new_session=True,
)
threading.Thread(target=_restart, daemon=True).start()
@router.get("/api/v1/system/api-keys", tags=["System"])
def list_api_keys(_: AuthRequired):
"""List API key labels (read-only; keys are defined in the YAML config file)."""
config = get_config()
keys = [
{"label": label, "masked": key[:4] + "****" + key[-4:] if len(key) >= 8 else "****"}
for label, key in config.auth.api_keys.items()
]
return {"keys": keys, "count": len(keys)}
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
def export_store(store_key: str, _: AuthRequired):
"""Download a single entity store as a JSON file."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
export = {
"meta": {
"format": "ledgrab-partial-export",
"format_version": 1,
"store_key": store_key,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
},
"store": data,
}
content = json.dumps(export, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-{store_key}-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
async def import_store(
store_key: str,
_: AuthRequired,
file: UploadFile = File(...),
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
):
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
payload = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
# Support both full-backup format and partial-export format
if "stores" in payload and isinstance(payload.get("meta"), dict):
# Full backup: extract the specific store
if payload["meta"].get("format") not in ("ledgrab-backup",):
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
stores = payload.get("stores", {})
if store_key not in stores:
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
incoming = stores[store_key]
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
# Partial export format
if payload["meta"].get("store_key") != store_key:
raise HTTPException(
status_code=400,
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
)
incoming = payload.get("store", {})
else:
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
if not isinstance(incoming, dict):
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
def _write():
if merge and file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
existing = json.load(f)
if isinstance(existing, dict):
existing.update(incoming)
atomic_write_json(file_path, existing)
return len(existing)
atomic_write_json(file_path, incoming)
return len(incoming)
count = await asyncio.to_thread(_write)
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
_schedule_restart()
return {
"status": "imported",
"store_key": store_key,
"entries": count,
"merge": merge,
"restart_scheduled": True,
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
}
@router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired):
"""Download all configuration as a single JSON backup file."""
config = get_config()
stores = {}
for store_key, config_attr in STORE_MAP.items():
file_path = Path(getattr(config.storage, config_attr))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
stores[store_key] = json.load(f)
else:
stores[store_key] = {}
backup = {
"meta": {
"format": "ledgrab-backup",
"format_version": 1,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
"store_count": len(stores),
},
"stores": stores,
}
content = json.dumps(backup, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
_schedule_restart()
return {"status": "restarting"}
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
async def restore_config(
_: AuthRequired,
file: UploadFile = File(...),
):
"""Upload a backup file to restore all configuration. Triggers server restart."""
# Read and parse
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024: # 10 MB limit
raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)")
backup = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
# Validate envelope
meta = backup.get("meta")
if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup":
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file")
fmt_version = meta.get("format_version", 0)
if fmt_version > 1:
raise HTTPException(
status_code=400,
detail=f"Backup format version {fmt_version} is not supported by this server version",
)
stores = backup.get("stores")
if not isinstance(stores, dict):
raise HTTPException(status_code=400, detail="Backup file missing 'stores' section")
known_keys = set(STORE_MAP.keys())
present_keys = known_keys & set(stores.keys())
if not present_keys:
raise HTTPException(status_code=400, detail="Backup contains no recognized store data")
for key in present_keys:
if not isinstance(stores[key], dict):
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
# Write store files atomically (in thread to avoid blocking event loop)
config = get_config()
def _write_stores():
count = 0
for store_key, config_attr in STORE_MAP.items():
if store_key in stores:
file_path = Path(getattr(config.storage, config_attr))
atomic_write_json(file_path, stores[store_key])
count += 1
logger.info(f"Restored store: {store_key} -> {file_path}")
return count
written = await asyncio.to_thread(_write_stores)
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
_schedule_restart()
missing = known_keys - present_keys
return RestoreResponse(
status="restored",
stores_written=written,
stores_total=len(STORE_MAP),
missing_stores=sorted(missing) if missing else [],
restart_scheduled=True,
message=f"Restored {written} stores. Server restarting...",
)
# ---------------------------------------------------------------------------
# Auto-backup settings & saved backups
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def get_auto_backup_settings(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Get auto-backup settings and status."""
return engine.get_settings()
@router.put(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def update_auto_backup_settings(
_: AuthRequired,
body: AutoBackupSettings,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings(
enabled=body.enabled,
interval_hours=body.interval_hours,
max_backups=body.max_backups,
)
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
async def trigger_backup(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Manually trigger a backup now."""
backup = await engine.trigger_backup()
return {"status": "ok", "backup": backup}
@router.get(
"/api/v1/system/backups",
response_model=BackupListResponse,
tags=["System"],
)
async def list_backups(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""List all saved backup files."""
backups = engine.list_backups()
return BackupListResponse(
backups=[BackupFileInfo(**b) for b in backups],
count=len(backups),
)
@router.get("/api/v1/system/backups/{filename}", tags=["System"])
def download_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Download a specific saved backup file."""
try:
path = engine.get_backup_path(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
content = path.read_bytes()
return StreamingResponse(
io.BytesIO(content),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete("/api/v1/system/backups/{filename}", tags=["System"])
async def delete_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Delete a specific saved backup file."""
try:
engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "deleted", "filename": filename}
# ---------------------------------------------------------------------------
# MQTT settings
# ---------------------------------------------------------------------------
_MQTT_SETTINGS_FILE: Path | None = None
def _get_mqtt_settings_path() -> Path:
global _MQTT_SETTINGS_FILE
if _MQTT_SETTINGS_FILE is None:
cfg = get_config()
# Derive the data directory from any known storage file path
data_dir = Path(cfg.storage.devices_file).parent
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
return _MQTT_SETTINGS_FILE
def _load_mqtt_settings() -> dict:
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
cfg = get_config()
defaults = {
"enabled": cfg.mqtt.enabled,
"broker_host": cfg.mqtt.broker_host,
"broker_port": cfg.mqtt.broker_port,
"username": cfg.mqtt.username,
"password": cfg.mqtt.password,
"client_id": cfg.mqtt.client_id,
"base_topic": cfg.mqtt.base_topic,
}
path = _get_mqtt_settings_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
overrides = json.load(f)
defaults.update(overrides)
except Exception as e:
logger.warning(f"Failed to load MQTT settings override file: {e}")
return defaults
def _save_mqtt_settings(settings: dict) -> None:
"""Persist MQTT settings to the JSON override file."""
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_mqtt_settings_path(), settings)
@router.get(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def get_mqtt_settings(_: AuthRequired):
"""Get current MQTT broker settings. Password is masked."""
s = _load_mqtt_settings()
return MQTTSettingsResponse(
enabled=s["enabled"],
broker_host=s["broker_host"],
broker_port=s["broker_port"],
username=s["username"],
password_set=bool(s.get("password")),
client_id=s["client_id"],
base_topic=s["base_topic"],
)
@router.put(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings()
# If caller sends an empty password, keep the existing one
password = body.password if body.password else current.get("password", "")
new_settings = {
"enabled": body.enabled,
"broker_host": body.broker_host,
"broker_port": body.broker_port,
"username": body.username,
"password": password,
"client_id": body.client_id,
"base_topic": body.base_topic,
}
_save_mqtt_settings(new_settings)
logger.info("MQTT settings updated")
return MQTTSettingsResponse(
enabled=new_settings["enabled"],
broker_host=new_settings["broker_host"],
broker_port=new_settings["broker_port"],
username=new_settings["username"],
password_set=bool(new_settings["password"]),
client_id=new_settings["client_id"],
base_topic=new_settings["base_topic"],
)
# ---------------------------------------------------------------------------
# External URL setting
# ---------------------------------------------------------------------------
_EXTERNAL_URL_FILE: Path | None = None
def _get_external_url_path() -> Path:
global _EXTERNAL_URL_FILE
if _EXTERNAL_URL_FILE is None:
cfg = get_config()
data_dir = Path(cfg.storage.devices_file).parent
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
return _EXTERNAL_URL_FILE
def load_external_url() -> str:
"""Load the external URL setting. Returns empty string if not set."""
path = _get_external_url_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("external_url", "")
except Exception:
pass
return ""
def _save_external_url(url: str) -> None:
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_external_url_path(), {"external_url": url})
@router.get(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def get_external_url(_: AuthRequired):
"""Get the configured external base URL."""
return ExternalUrlResponse(external_url=load_external_url())
@router.put(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/")
_save_external_url(url)
logger.info("External URL updated: %s", url or "(cleared)")
return ExternalUrlResponse(external_url=url)
# ---------------------------------------------------------------------------
# Live log viewer WebSocket
# ---------------------------------------------------------------------------
@router.websocket("/api/v1/system/logs/ws")
async def logs_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket that streams server log lines in real time.
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
lines as individual text messages, then pushes new lines as they appear.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.utils import log_broadcaster
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
# Ensure the broadcaster knows the event loop (may be first connection)
log_broadcaster.ensure_loop()
# Subscribe *before* reading the backlog so no lines slip through
queue = log_broadcaster.subscribe()
try:
# Send backlog first
for line in log_broadcaster.get_backlog():
await websocket.send_text(line)
# Stream new lines
while True:
try:
line = await asyncio.wait_for(queue.get(), timeout=30.0)
await websocket.send_text(line)
except asyncio.TimeoutError:
# Send a keepalive ping so the connection stays alive
try:
await websocket.send_text("")
except Exception:
break
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
log_broadcaster.unsubscribe(queue)
# ---------------------------------------------------------------------------
# ADB helpers (for Android / scrcpy engine)
# ---------------------------------------------------------------------------
class AdbConnectRequest(BaseModel):
address: str
def _get_adb_path() -> str:
"""Get the adb binary path from the scrcpy engine's resolver."""
from wled_controller.core.capture_engines.scrcpy_engine import _get_adb
return _get_adb()
@router.post("/api/v1/adb/connect", tags=["ADB"])
async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
"""Connect to a WiFi ADB device by IP address.
Appends ``:5555`` if no port is specified.
"""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
if ":" not in address:
address = f"{address}:5555"
adb = _get_adb_path()
logger.info(f"Connecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "connect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = (stdout.decode() + stderr.decode()).strip()
if "connected" in output.lower():
return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed")
except FileNotFoundError:
raise HTTPException(
status_code=500,
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB connect timed out")
@router.post("/api/v1/adb/disconnect", tags=["ADB"])
async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
"""Disconnect a WiFi ADB device."""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
adb = _get_adb_path()
logger.info(f"Disconnecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "disconnect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
return {"status": "disconnected", "message": stdout.decode().strip()}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
# ─── Log level ─────────────────────────────────────────────────
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
@router.get("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def get_log_level(_: AuthRequired):
"""Get the current root logger log level."""
level_int = logging.getLogger().getEffectiveLevel()
return LogLevelResponse(level=logging.getLevelName(level_int))
@router.put("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def set_log_level(_: AuthRequired, body: LogLevelRequest):
"""Change the root logger log level at runtime (no server restart required)."""
level_name = body.level.upper()
if level_name not in _VALID_LOG_LEVELS:
raise HTTPException(
status_code=400,
detail=f"Invalid log level '{body.level}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}",
)
level_int = getattr(logging, level_name)
root = logging.getLogger()
root.setLevel(level_int)
# Also update all handlers so they actually emit at the new level
for handler in root.handlers:
handler.setLevel(level_int)
logger.info("Log level changed to %s", level_name)
return LogLevelResponse(level=level_name)

View File

@@ -6,13 +6,14 @@ import time
import numpy as np
from PIL import Image
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
fire_entity_event,
get_cspt_store,
get_picture_source_store,
get_processor_manager,
get_pp_template_store,
get_template_store,
)
from wled_controller.api.schemas.common import (
@@ -36,12 +37,11 @@ from wled_controller.api.schemas.filters import (
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -65,7 +65,7 @@ async def list_templates(
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
@@ -96,19 +96,25 @@ async def create_template(
engine_type=template_data.engine_type,
engine_config=template_data.engine_config,
description=template_data.description,
tags=template_data.tags,
)
fire_entity_event("capture_template", "created", template.id)
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -133,6 +139,7 @@ async def get_template(
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
@@ -154,19 +161,25 @@ async def update_template(
engine_type=update_data.engine_type,
engine_config=update_data.engine_config,
description=update_data.description,
tags=update_data.tags,
)
fire_entity_event("capture_template", "updated", template_id)
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -202,9 +215,13 @@ async def delete_template(
# Proceed with deletion
template_store.delete_template(template_id)
fire_entity_event("capture_template", "deleted", template_id)
except HTTPException:
raise # Re-raise HTTP exceptions as-is
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -214,22 +231,24 @@ async def delete_template(
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
async def list_engines(_auth: AuthRequired):
"""List available capture engines on this system.
"""List all registered capture engines.
Returns all registered engines that are available on the current platform.
Returns every registered engine with an ``available`` flag showing
whether it can be used on the current system.
"""
try:
available_engine_types = EngineRegistry.get_available_engines()
available_set = set(EngineRegistry.get_available_engines())
all_engines = EngineRegistry.get_all_engines()
engines = []
for engine_type in available_engine_types:
engine_class = EngineRegistry.get_engine(engine_type)
for engine_type, engine_class in all_engines.items():
engines.append(
EngineInfo(
type=engine_type,
name=engine_type.upper(),
default_config=engine_class.get_default_config(),
available=True,
available=(engine_type in available_set),
has_own_displays=getattr(engine_class, 'HAS_OWN_DISPLAYS', False),
)
)
@@ -241,14 +260,16 @@ async def list_engines(_auth: AuthRequired):
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
async def test_template(
def test_template(
test_request: TemplateTestRequest,
_auth: AuthRequired,
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
):
"""Test a capture template configuration.
Uses sync ``def`` so FastAPI runs it in a thread pool — the engine
initialisation and capture loop are blocking and would stall the
event loop if run in an ``async def`` handler.
Temporarily instantiates an engine with the provided configuration,
captures frames for the specified duration, and returns actual FPS metrics.
"""
@@ -261,24 +282,6 @@ async def test_template(
detail=f"Engine '{test_request.engine_type}' is not available on this system"
)
# Check if display is already being captured
locked_device_id = processor_manager.get_display_lock_info(test_request.display_index)
if locked_device_id:
# Get device info for better error message
try:
device = device_store.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
raise HTTPException(
status_code=409,
detail=(
f"Display {test_request.display_index} is currently being captured by device "
f"'{device_name}'. Please stop the device processing before testing this template."
)
)
# Create and initialize capture stream
stream = EngineRegistry.create_stream(
test_request.engine_type, test_request.display_index, test_request.engine_config
@@ -300,8 +303,9 @@ async def test_template(
screen_capture = stream.capture_frame()
capture_elapsed = time.perf_counter() - capture_start
# Skip if no new frame (screen unchanged)
# Skip if no new frame (screen unchanged); yield CPU
if screen_capture is None:
time.sleep(0.005)
continue
total_capture_time += capture_elapsed
@@ -367,6 +371,10 @@ async def test_template(
),
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
@@ -382,29 +390,154 @@ async def test_template(
logger.error(f"Error cleaning up test stream: {e}")
# ===== REAL-TIME CAPTURE TEMPLATE TEST WEBSOCKET =====
@router.websocket("/api/v1/capture-templates/test/ws")
async def test_template_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket for capture template test with intermediate frame previews.
Config is sent as the first client message (JSON with engine_type,
engine_config, display_index, capture_duration).
"""
from wled_controller.api.routes._test_helpers import (
authenticate_ws_token,
stream_capture_test,
)
if not authenticate_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
# Read config from first client message
try:
config = await websocket.receive_json()
except Exception as e:
await websocket.send_json({"type": "error", "detail": f"Expected JSON config: {e}"})
await websocket.close(code=4003)
return
engine_type = config.get("engine_type", "")
engine_config = config.get("engine_config", {})
display_index = config.get("display_index", 0)
duration = float(config.get("capture_duration", 5.0))
pw = int(config.get("preview_width", 0)) or None
if engine_type not in EngineRegistry.get_available_engines():
await websocket.send_json({"type": "error", "detail": f"Engine '{engine_type}' not available"})
await websocket.close(code=4003)
return
# Engine factory — creates + initializes engine inside the capture thread
# to avoid thread-affinity issues (e.g. MSS uses thread-local state)
def engine_factory():
s = EngineRegistry.create_stream(engine_type, display_index, engine_config)
s.initialize()
return s
logger.info(f"Capture template test WS connected ({engine_type}, display {display_index}, {duration}s)")
try:
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Capture template test WS error: {e}")
finally:
logger.info("Capture template test WS disconnected")
# ===== FILTER TYPE ENDPOINTS =====
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
async def list_filter_types(_auth: AuthRequired):
async def list_filter_types(
_auth: AuthRequired,
pp_store=Depends(get_pp_template_store),
):
"""List all available postprocessing filter types and their options schemas."""
all_filters = FilterRegistry.get_all()
# Pre-build template choices for the filter_template filter
template_choices = None
if pp_store:
try:
templates = pp_store.get_all_templates()
template_choices = [{"value": t.id, "label": t.name} for t in templates]
except Exception:
template_choices = []
responses = []
for filter_id, filter_cls in all_filters.items():
schema = filter_cls.get_options_schema()
opt_schemas = []
for opt in schema:
choices = opt.choices
# Enrich filter_template choices with current template list
if filter_id == "filter_template" and opt.key == "template_id" and template_choices is not None:
choices = template_choices
opt_schemas.append(FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
choices=choices,
))
responses.append(FilterTypeResponse(
filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name,
options_schema=[
FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
)
for opt in schema
],
options_schema=opt_schemas,
))
return FilterTypeListResponse(filters=responses, count=len(responses))
@router.get("/api/v1/strip-filters", response_model=FilterTypeListResponse, tags=["Filters"])
async def list_strip_filter_types(
_auth: AuthRequired,
cspt_store=Depends(get_cspt_store),
):
"""List filter types that support 1D LED strip processing."""
all_filters = FilterRegistry.get_all()
# Pre-build template choices for the css_filter_template filter
cspt_choices = None
if cspt_store:
try:
templates = cspt_store.get_all_templates()
cspt_choices = [{"value": t.id, "label": t.name} for t in templates]
except Exception:
cspt_choices = []
responses = []
for filter_id, filter_cls in all_filters.items():
if not getattr(filter_cls, "supports_strip", True):
continue
schema = filter_cls.get_options_schema()
opt_schemas = []
for opt in schema:
choices = opt.choices
if filter_id == "css_filter_template" and opt.key == "template_id" and cspt_choices is not None:
choices = cspt_choices
opt_schemas.append(FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
choices=choices,
))
responses.append(FilterTypeResponse(
filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name,
options_schema=opt_schemas,
))
return FilterTypeListResponse(filters=responses, count=len(responses))

View File

@@ -0,0 +1,253 @@
"""Value source routes: CRUD for value sources."""
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_output_target_store,
get_processor_manager,
get_value_source_store,
)
from wled_controller.api.schemas.value_sources import (
ValueSourceCreate,
ValueSourceListResponse,
ValueSourceResponse,
ValueSourceUpdate,
)
from wled_controller.storage.value_source import ValueSource
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
def _to_response(source: ValueSource) -> ValueSourceResponse:
"""Convert a ValueSource to a ValueSourceResponse."""
d = source.to_dict()
return ValueSourceResponse(
id=d["id"],
name=d["name"],
source_type=d["source_type"],
value=d.get("value"),
waveform=d.get("waveform"),
speed=d.get("speed"),
min_value=d.get("min_value"),
max_value=d.get("max_value"),
audio_source_id=d.get("audio_source_id"),
mode=d.get("mode"),
sensitivity=d.get("sensitivity"),
smoothing=d.get("smoothing"),
auto_gain=d.get("auto_gain"),
schedule=d.get("schedule"),
picture_source_id=d.get("picture_source_id"),
scene_behavior=d.get("scene_behavior"),
use_real_time=d.get("use_real_time"),
latitude=d.get("latitude"),
description=d.get("description"),
tags=d.get("tags", []),
created_at=source.created_at,
updated_at=source.updated_at,
)
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
async def list_value_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(None, description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene"),
store: ValueSourceStore = Depends(get_value_source_store),
):
"""List all value sources, optionally filtered by type."""
sources = store.get_all_sources()
if source_type:
sources = [s for s in sources if s.source_type == source_type]
return ValueSourceListResponse(
sources=[_to_response(s) for s in sources],
count=len(sources),
)
@router.post("/api/v1/value-sources", response_model=ValueSourceResponse, status_code=201, tags=["Value Sources"])
async def create_value_source(
data: ValueSourceCreate,
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
):
"""Create a new value source."""
try:
source = store.create_source(
name=data.name,
source_type=data.source_type,
value=data.value,
waveform=data.waveform,
speed=data.speed,
min_value=data.min_value,
max_value=data.max_value,
audio_source_id=data.audio_source_id,
mode=data.mode,
sensitivity=data.sensitivity,
smoothing=data.smoothing,
description=data.description,
schedule=data.schedule,
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
)
fire_entity_event("value_source", "created", source.id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
async def get_value_source(
source_id: str,
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
):
"""Get a value source by ID."""
try:
source = store.get_source(source_id)
return _to_response(source)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
async def update_value_source(
source_id: str,
data: ValueSourceUpdate,
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
pm: ProcessorManager = Depends(get_processor_manager),
):
"""Update an existing value source."""
try:
source = store.update_source(
source_id=source_id,
name=data.name,
value=data.value,
waveform=data.waveform,
speed=data.speed,
min_value=data.min_value,
max_value=data.max_value,
audio_source_id=data.audio_source_id,
mode=data.mode,
sensitivity=data.sensitivity,
smoothing=data.smoothing,
description=data.description,
schedule=data.schedule,
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
)
# Hot-reload running value streams
pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/api/v1/value-sources/{source_id}", status_code=204, tags=["Value Sources"])
async def delete_value_source(
source_id: str,
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
):
"""Delete a value source."""
try:
# Check if any targets reference this value source
from wled_controller.storage.wled_output_target import WledOutputTarget
for target in target_store.get_all_targets():
if isinstance(target, WledOutputTarget):
if getattr(target, "brightness_value_source_id", "") == source_id:
raise ValueError(
f"Cannot delete: referenced by target '{target.name}'"
)
store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# ===== REAL-TIME VALUE SOURCE TEST WEBSOCKET =====
@router.websocket("/api/v1/value-sources/{source_id}/test/ws")
async def test_value_source_ws(
websocket: WebSocket,
source_id: str,
token: str = Query(""),
):
"""WebSocket for real-time value source output. Auth via ?token=<api_key>.
Acquires a ValueStream for the given source, polls get_value() at ~20 Hz,
and streams {value: float} JSON to the client.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Validate source exists
store = get_value_source_store()
try:
store.get_source(source_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
# Acquire a value stream
manager = get_processor_manager()
vsm = manager.value_stream_manager
if vsm is None:
await websocket.close(code=4003, reason="Value stream manager not available")
return
try:
stream = vsm.acquire(source_id)
except Exception as e:
await websocket.close(code=4003, reason=str(e))
return
await websocket.accept()
logger.info(f"Value source test WebSocket connected for {source_id}")
try:
while True:
value = stream.get_value()
await websocket.send_json({"value": round(value, 4)})
await asyncio.sleep(0.05)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Value source test WebSocket error for {source_id}: {e}")
finally:
vsm.release(source_id)
logger.info(f"Value source test WebSocket disconnected for {source_id}")

View File

@@ -0,0 +1,57 @@
"""Webhook endpoint for automation triggers.
External services call POST /api/v1/webhooks/{token} with a JSON body
containing {"action": "activate"} or {"action": "deactivate"} to control
automations that have a webhook condition. No API-key auth is required —
the secret token itself authenticates the caller.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from wled_controller.api.dependencies import get_automation_engine, get_automation_store
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import WebhookCondition
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
class WebhookPayload(BaseModel):
action: str = Field(description="'activate' or 'deactivate'")
@router.post(
"/api/v1/webhooks/{token}",
tags=["Webhooks"],
)
async def handle_webhook(
token: str,
body: WebhookPayload,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Receive a webhook call and set the corresponding condition state."""
if body.action not in ("activate", "deactivate"):
raise HTTPException(status_code=400, detail="action must be 'activate' or 'deactivate'")
# Find the automation that owns this token
for automation in store.get_all_automations():
for condition in automation.conditions:
if isinstance(condition, WebhookCondition) and condition.token == token:
active = body.action == "activate"
await engine.set_webhook_state(token, active)
logger.info(
"Webhook %s: automation '%s' (%s) → %s",
token[:8], automation.name, automation.id, body.action,
)
return {
"ok": True,
"automation_id": automation.id,
"automation_name": automation.name,
"action": body.action,
}
raise HTTPException(status_code=404, detail="Webhook token not found")

View File

@@ -23,13 +23,18 @@ from .devices import (
DeviceStateResponse,
DeviceUpdate,
)
from .picture_targets import (
ColorCorrection,
PictureTargetCreate,
PictureTargetListResponse,
PictureTargetResponse,
PictureTargetUpdate,
ProcessingSettings,
from .color_strip_sources import (
ColorStripSourceCreate,
ColorStripSourceListResponse,
ColorStripSourceResponse,
ColorStripSourceUpdate,
CSSCalibrationTestRequest,
)
from .output_targets import (
OutputTargetCreate,
OutputTargetListResponse,
OutputTargetResponse,
OutputTargetUpdate,
TargetMetricsResponse,
TargetProcessingState,
)
@@ -90,12 +95,15 @@ __all__ = [
"DeviceResponse",
"DeviceStateResponse",
"DeviceUpdate",
"ColorCorrection",
"PictureTargetCreate",
"PictureTargetListResponse",
"PictureTargetResponse",
"PictureTargetUpdate",
"ProcessingSettings",
"ColorStripSourceCreate",
"ColorStripSourceListResponse",
"ColorStripSourceResponse",
"ColorStripSourceUpdate",
"CSSCalibrationTestRequest",
"OutputTargetCreate",
"OutputTargetListResponse",
"OutputTargetResponse",
"OutputTargetUpdate",
"TargetMetricsResponse",
"TargetProcessingState",
"EngineInfo",

View File

@@ -0,0 +1,59 @@
"""Audio source schemas (CRUD)."""
from datetime import datetime
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
class AudioSourceCreate(BaseModel):
"""Request to create an audio source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["multichannel", "mono"] = Field(description="Source type")
# multichannel fields
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
# mono fields
audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class AudioSourceUpdate(BaseModel):
"""Request to update an audio source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class AudioSourceResponse(BaseModel):
"""Audio source response."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type: multichannel or mono")
device_index: Optional[int] = Field(None, description="Audio device index")
is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_source_id: Optional[str] = Field(None, description="Parent multichannel source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class AudioSourceListResponse(BaseModel):
"""List of audio sources."""
sources: List[AudioSourceResponse] = Field(description="List of audio sources")
count: int = Field(description="Number of sources")

View File

@@ -0,0 +1,62 @@
"""Audio capture template and engine schemas."""
from datetime import datetime
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class AudioTemplateCreate(BaseModel):
"""Request to create an audio capture template."""
name: str = Field(description="Template name", min_length=1, max_length=100)
engine_type: str = Field(description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class AudioTemplateUpdate(BaseModel):
"""Request to update an audio template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: Optional[str] = Field(None, description="Audio engine type")
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
class AudioTemplateResponse(BaseModel):
"""Audio template information response."""
id: str = Field(description="Template ID")
name: str = Field(description="Template name")
engine_type: str = Field(description="Engine type identifier")
engine_config: Dict = Field(description="Engine-specific configuration")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
class AudioTemplateListResponse(BaseModel):
"""List of audio templates response."""
templates: List[AudioTemplateResponse] = Field(description="List of audio templates")
count: int = Field(description="Number of templates")
class AudioEngineInfo(BaseModel):
"""Audio capture engine information."""
type: str = Field(description="Engine type identifier (e.g., 'wasapi', 'sounddevice')")
name: str = Field(description="Human-readable engine name")
default_config: Dict = Field(description="Default configuration for this engine")
available: bool = Field(description="Whether engine is available on this system")
class AudioEngineListResponse(BaseModel):
"""List of audio engines response."""
engines: List[AudioEngineInfo] = Field(description="Available audio engines")
count: int = Field(description="Number of engines")

View File

@@ -0,0 +1,82 @@
"""Automation-related schemas."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class ConditionSchema(BaseModel):
"""A single condition within an automation."""
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
# Application condition fields
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
# Time-of-day condition fields
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day condition)")
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
# System idle condition fields
idle_minutes: Optional[int] = Field(None, description="Idle timeout in minutes (for system_idle condition)")
when_idle: Optional[bool] = Field(None, description="True=active when idle (for system_idle condition)")
# Display state condition fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
# MQTT condition fields
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)")
# Webhook condition fields
token: Optional[str] = Field(None, description="Secret token for webhook URL (for webhook condition)")
class AutomationCreate(BaseModel):
"""Request to create an automation."""
name: str = Field(description="Automation name", min_length=1, max_length=100)
enabled: bool = Field(default=True, description="Whether the automation is enabled")
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class AutomationUpdate(BaseModel):
"""Request to update an automation."""
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: Optional[str] = Field(None, description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
tags: Optional[List[str]] = None
class AutomationResponse(BaseModel):
"""Automation information response."""
id: str = Field(description="Automation ID")
name: str = Field(description="Automation name")
enabled: bool = Field(description="Whether the automation is enabled")
condition_logic: str = Field(description="Condition combination logic")
conditions: List[ConditionSchema] = Field(description="List of conditions")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
webhook_url: Optional[str] = Field(None, description="Webhook URL for the first webhook condition (if any)")
is_active: bool = Field(default=False, description="Whether the automation is currently active")
last_activated_at: Optional[datetime] = Field(None, description="Last time this automation was activated")
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this automation was deactivated")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class AutomationListResponse(BaseModel):
"""List of automations response."""
automations: List[AutomationResponse] = Field(description="List of automations")
count: int = Field(description="Number of automations")

View File

@@ -0,0 +1,45 @@
"""Color strip processing template schemas."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from .filters import FilterInstanceSchema
class ColorStripProcessingTemplateCreate(BaseModel):
"""Request to create a color strip processing template."""
name: str = Field(description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class ColorStripProcessingTemplateUpdate(BaseModel):
"""Request to update a color strip processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
class ColorStripProcessingTemplateResponse(BaseModel):
"""Color strip processing template information response."""
id: str = Field(description="Template ID")
name: str = Field(description="Template name")
filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
class ColorStripProcessingTemplateListResponse(BaseModel):
"""List of color strip processing templates response."""
templates: List[ColorStripProcessingTemplateResponse] = Field(description="List of templates")
count: int = Field(description="Number of templates")

View File

@@ -0,0 +1,307 @@
"""Color strip source schemas (CRUD)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field, model_validator
from wled_controller.api.schemas.devices import Calibration
class AnimationConfig(BaseModel):
"""Procedural animation configuration for static/gradient color strip sources."""
enabled: bool = True
type: str = "breathing" # breathing | color_cycle | gradient_shift | wave
speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.110.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)")
color_right: Optional[List[int]] = Field(
None,
description="Optional right-side RGB color for a hard edge (bidirectional stop)",
)
class CompositeLayer(BaseModel):
"""A single layer in a composite color strip source."""
source_id: str = Field(description="ID of the layer's color strip source")
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen|override")
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
enabled: bool = Field(default=True, description="Whether this layer is active")
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID")
class MappedZone(BaseModel):
"""A single zone in a mapped color strip source."""
source_id: str = Field(description="ID of the zone's color strip source")
start: int = Field(default=0, ge=0, description="First LED index (0-based)")
end: int = Field(default=0, ge=0, description="Last LED index (exclusive); 0 = auto-fill")
reverse: bool = Field(default=False, description="Reverse zone output")
class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice)")
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor)")
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# mapped-type fields
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
# shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0)
# notification-type fields
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB) for notifications")
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color (#RRGGBB)")
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class ColorStripSourceUpdate(BaseModel):
"""Request to update a color strip source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
# picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
palette: Optional[str] = Field(None, description="Named palette")
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# mapped-type fields
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
# shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0)
# notification-type fields
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)")
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color")
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
tags: Optional[List[str]] = None
class ColorStripSourceResponse(BaseModel):
"""Color strip source response."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type")
# picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B]")
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
intensity: Optional[float] = Field(None, description="Effect intensity")
scale: Optional[float] = Field(None, description="Spatial scale")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
# composite-type fields
layers: Optional[List[dict]] = Field(None, description="Layers for composite type")
# mapped-type fields
zones: Optional[List[dict]] = Field(None, description="Zones for mapped type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity")
color_peak: Optional[List[int]] = Field(None, description="Peak color [R,G,B]")
# shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)")
# notification-type fields
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
duration_ms: Optional[int] = Field(None, description="Effect duration in milliseconds")
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)")
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color")
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources")
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID")
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class ColorStripSourceListResponse(BaseModel):
"""List of color strip sources."""
sources: List[ColorStripSourceResponse] = Field(description="List of color strip sources")
count: int = Field(description="Number of sources")
class SegmentPayload(BaseModel):
"""A single segment for segment-based LED color updates."""
start: int = Field(ge=0, description="Starting LED index")
length: int = Field(ge=1, description="Number of LEDs in segment")
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field(None, description="Colors for per_pixel/gradient [[R,G,B],...]")
@model_validator(mode="after")
def _validate_mode_fields(self) -> "SegmentPayload":
if self.mode == "solid":
if self.color is None or len(self.color) != 3:
raise ValueError("solid mode requires 'color' as a list of 3 ints [R,G,B]")
if not all(0 <= c <= 255 for c in self.color):
raise ValueError("solid color values must be 0-255")
elif self.mode == "per_pixel":
if not self.colors:
raise ValueError("per_pixel mode requires non-empty 'colors' list")
for c in self.colors:
if len(c) != 3:
raise ValueError("each color in per_pixel must be [R,G,B]")
elif self.mode == "gradient":
if not self.colors or len(self.colors) < 2:
raise ValueError("gradient mode requires 'colors' with at least 2 stops")
for c in self.colors:
if len(c) != 3:
raise ValueError("each color stop in gradient must be [R,G,B]")
return self
class ColorPushRequest(BaseModel):
"""Request to push raw LED colors to an api_input source.
Accepts either 'colors' (legacy flat array) or 'segments' (new segment-based).
At least one must be provided.
"""
colors: Optional[List[List[int]]] = Field(None, description="LED color array [[R,G,B], ...] (0-255 each)")
segments: Optional[List[SegmentPayload]] = Field(None, description="Segment-based color updates")
@model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest":
if self.colors is None and self.segments is None:
raise ValueError("Either 'colors' or 'segments' must be provided")
return self
class NotifyRequest(BaseModel):
"""Request to trigger a notification on a notification color strip source."""
app: Optional[str] = Field(None, description="App name for color lookup")
color: Optional[str] = Field(None, description="Hex color override (#RRGGBB)")
class CSSCalibrationTestRequest(BaseModel):
"""Request to run a calibration test for a color strip source on a specific device."""
device_id: str = Field(description="Device ID to send test pixels to")
edges: dict = Field(
default_factory=lambda: {
"top": [255, 0, 0],
"right": [0, 255, 0],
"bottom": [0, 100, 255],
"left": [255, 255, 0],
},
description="Map of edge names to RGB colors. Empty dict = exit test mode.",
)

View File

@@ -15,6 +15,29 @@ class DeviceCreate(BaseModel):
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)")
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(default=None, description="Turn off device when server stops (defaults to true for adalight)")
send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)")
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
# DMX (Art-Net / sACN) fields
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
# ESP-NOW fields
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)")
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)")
# Philips Hue fields
hue_username: Optional[str] = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: Optional[str] = Field(None, description="Hue entertainment group/zone ID")
# SPI Direct fields
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed in Hz")
spi_led_type: Optional[str] = Field(None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW")
# Razer Chroma fields
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad")
# SteelSeries GameSense fields
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator")
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
class DeviceUpdate(BaseModel):
@@ -26,20 +49,50 @@ class DeviceUpdate(BaseModel):
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (for devices with manual_led_count capability)")
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops")
send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)")
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
tags: Optional[List[str]] = None
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address")
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
hue_username: Optional[str] = Field(None, description="Hue bridge username")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: Optional[str] = Field(None, description="Hue entertainment group ID")
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
class StaticColorUpdate(BaseModel):
"""Request to set or clear the static idle color."""
class CalibrationLineSchema(BaseModel):
"""One LED line in advanced calibration."""
color: Optional[List[int]] = Field(
None,
description="RGB color [R, G, B] with values 0-255, or null to clear",
)
picture_source_id: str = Field(description="Picture source (monitor) to sample from")
edge: Literal["top", "right", "bottom", "left"] = Field(description="Screen edge to sample")
led_count: int = Field(ge=1, description="Number of LEDs in this line")
span_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start fraction along edge")
span_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End fraction along edge")
reverse: bool = Field(default=False, description="Reverse LED direction")
border_width: int = Field(default=10, ge=1, le=100, description="Sampling depth in pixels")
class Calibration(BaseModel):
"""Calibration configuration for pixel-to-LED mapping."""
mode: Literal["simple", "advanced"] = Field(
default="simple",
description="Calibration mode: simple (4-edge) or advanced (multi-source lines)"
)
# Advanced mode: ordered list of lines
lines: Optional[List[CalibrationLineSchema]] = Field(
default=None,
description="Line list for advanced mode (ignored in simple mode)"
)
# Simple mode fields
layout: Literal["clockwise", "counterclockwise"] = Field(
default="clockwise",
description="LED strip layout direction"
@@ -102,9 +155,24 @@ class DeviceResponse(BaseModel):
enabled: bool = Field(description="Whether device is enabled")
baud_rate: Optional[int] = Field(None, description="Serial baud rate")
auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop")
static_color: Optional[List[int]] = Field(None, description="Static idle color [R, G, B]")
send_latency_ms: int = Field(default=0, description="Simulated send latency in ms (mock devices)")
rgbw: bool = Field(default=False, description="RGBW mode (mock devices)")
zone_mode: str = Field(default="combined", description="OpenRGB zone mode: combined or separate")
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
dmx_start_universe: int = Field(default=0, description="DMX start universe")
dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)")
espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address")
espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel")
hue_username: str = Field(default="", description="Hue bridge username")
hue_client_key: str = Field(default="", description="Hue entertainment client key")
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
gamesense_device_type: str = Field(default="keyboard", description="GameSense device type")
default_css_processing_template_id: str = Field(default="", description="Default color strip processing template ID")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -154,3 +222,18 @@ class DiscoverDevicesResponse(BaseModel):
devices: List[DiscoveredDeviceResponse] = Field(description="Discovered devices")
count: int = Field(description="Total devices found")
scan_duration_ms: float = Field(description="How long the scan took in milliseconds")
class OpenRGBZoneResponse(BaseModel):
"""A single zone on an OpenRGB device."""
name: str = Field(description="Zone name (e.g. JRAINBOW2)")
led_count: int = Field(description="Number of LEDs in this zone")
zone_type: str = Field(description="Zone type (linear, single, matrix)")
class OpenRGBZonesResponse(BaseModel):
"""Response from OpenRGB zone listing."""
device_name: str = Field(description="OpenRGB device name")
zones: List[OpenRGBZoneResponse] = Field(description="Available zones")

View File

@@ -1,6 +1,6 @@
"""Filter-related schemas."""
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
@@ -17,11 +17,13 @@ class FilterOptionDefSchema(BaseModel):
key: str = Field(description="Option key")
label: str = Field(description="Display label")
type: str = Field(description="Option type (float or int)")
type: str = Field(description="Option type (float, int, bool, select, or string)")
default: Any = Field(description="Default value")
min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value")
step: Any = Field(description="Step increment")
choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type")
max_length: Optional[int] = Field(default=None, description="Maximum string length for string type")
class FilterTypeResponse(BaseModel):

View File

@@ -1,38 +1,11 @@
"""Picture target schemas (CRUD, processing state, settings, metrics)."""
"""Output target schemas (CRUD, processing state, metrics)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from typing import Dict, Optional, List
from pydantic import BaseModel, Field
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
class ColorCorrection(BaseModel):
"""Color correction settings."""
gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0)
saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0)
brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0)
class ProcessingSettings(BaseModel):
"""Processing settings for a picture target."""
display_index: int = Field(default=0, description="Display to capture", ge=0)
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
standby_interval: float = Field(default=1.0, description="Seconds between keepalive sends when screen is static (0.5-5.0)", ge=0.5, le=5.0)
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
description="Seconds between WLED health checks"
)
color_correction: Optional[ColorCorrection] = Field(
default_factory=ColorCorrection,
description="Color correction settings"
)
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
class KeyColorRectangleSchema(BaseModel):
@@ -53,6 +26,7 @@ class KeyColorsSettingsSchema(BaseModel):
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
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")
class ExtractedColorResponse(BaseModel):
@@ -72,71 +46,105 @@ class KeyColorsResponse(BaseModel):
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
class PictureTargetCreate(BaseModel):
"""Request to create a picture target."""
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")
picture_source_id: str = Field(default="", description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
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 PictureTargetUpdate(BaseModel):
"""Request to update a picture target."""
class OutputTargetUpdate(BaseModel):
"""Request to update an output target."""
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
device_id: Optional[str] = Field(None, description="WLED device ID")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
# 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 PictureTargetResponse(BaseModel):
"""Picture target response."""
class OutputTargetResponse(BaseModel):
"""Output target response."""
id: str = Field(description="Target ID")
name: str = Field(description="Target name")
target_type: str = Field(description="Target type")
device_id: str = Field(default="", description="WLED device ID")
picture_source_id: str = Field(default="", description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (wled)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (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: 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 PictureTargetListResponse(BaseModel):
"""List of picture targets response."""
class OutputTargetListResponse(BaseModel):
"""List of output targets response."""
targets: List[PictureTargetResponse] = Field(description="List of picture targets")
targets: List[OutputTargetResponse] = Field(description="List of output targets")
count: int = Field(description="Number of targets")
class TargetProcessingState(BaseModel):
"""Processing state for a picture target."""
"""Processing state for an output target."""
target_id: str = Field(description="Target ID")
device_id: Optional[str] = Field(None, description="Device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
fps_target: int = Field(default=0, description="Target FPS")
fps_target: Optional[int] = Field(None, description="Target FPS")
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
frames_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_extract_ms: Optional[float] = Field(None, description="Border extraction time (ms)")
timing_map_leds_ms: Optional[float] = Field(None, description="LED mapping time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Smoothing time (ms)")
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)")
timing_extract_ms: Optional[float] = Field(None, description="Border pixel extraction time (ms)")
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
timing_total_ms: Optional[float] = Field(None, description="Total processing time per frame (ms)")
timing_audio_read_ms: Optional[float] = Field(None, description="Audio device read time (ms)")
timing_audio_fft_ms: Optional[float] = Field(None, description="Audio FFT analysis time (ms)")
timing_audio_render_ms: Optional[float] = Field(None, description="Audio visualization render time (ms)")
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
display_index: int = Field(default=0, description="Current display index")
display_index: Optional[int] = Field(None, description="Current display index")
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
@@ -150,6 +158,8 @@ class TargetProcessingState(BaseModel):
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
device_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)")
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
class TargetMetricsResponse(BaseModel):
@@ -159,7 +169,7 @@ class TargetMetricsResponse(BaseModel):
device_id: Optional[str] = Field(None, description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS")
fps_target: int = Field(description="Target FPS")
fps_target: Optional[int] = Field(None, description="Target FPS")
uptime_seconds: float = Field(description="Processing uptime in seconds")
frames_processed: int = Field(description="Total frames processed")
errors_count: int = Field(description="Total error count")
@@ -167,6 +177,20 @@ class TargetMetricsResponse(BaseModel):
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
class BulkTargetRequest(BaseModel):
"""Request body for bulk start/stop operations."""
ids: List[str] = Field(description="List of target IDs to operate on")
class BulkTargetResponse(BaseModel):
"""Response for bulk start/stop operations."""
started: List[str] = Field(default_factory=list, description="IDs that were successfully started")
stopped: List[str] = Field(default_factory=list, description="IDs that were successfully stopped")
errors: Dict[str, str] = Field(default_factory=dict, description="Map of target ID to error message for failures")
class KCTestRectangleResponse(BaseModel):
"""A rectangle with its extracted color from a KC test."""

View File

@@ -5,7 +5,7 @@ from typing import List, Optional
from pydantic import BaseModel, Field
from .picture_targets import KeyColorRectangleSchema
from .output_targets import KeyColorRectangleSchema
class PatternTemplateCreate(BaseModel):
@@ -14,6 +14,7 @@ class PatternTemplateCreate(BaseModel):
name: str = Field(description="Template name", min_length=1, max_length=100)
rectangles: List[KeyColorRectangleSchema] = Field(default_factory=list, description="List of named rectangles")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class PatternTemplateUpdate(BaseModel):
@@ -22,6 +23,7 @@ class PatternTemplateUpdate(BaseModel):
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
rectangles: Optional[List[KeyColorRectangleSchema]] = Field(None, description="List of named rectangles")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
class PatternTemplateResponse(BaseModel):
@@ -30,6 +32,7 @@ class PatternTemplateResponse(BaseModel):
id: str = Field(description="Template ID")
name: str = Field(description="Template name")
rectangles: List[KeyColorRectangleSchema] = Field(description="List of named rectangles")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")

View File

@@ -10,14 +10,23 @@ class PictureSourceCreate(BaseModel):
"""Request to create a picture source."""
name: str = Field(description="Stream name", min_length=1, max_length=100)
stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type")
stream_type: Literal["raw", "processed", "static_image", "video"] = Field(description="Stream type")
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90)
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
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):
@@ -26,11 +35,20 @@ class PictureSourceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90)
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
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):
@@ -38,16 +56,25 @@ class PictureSourceResponse(BaseModel):
id: str = Field(description="Stream ID")
name: str = Field(description="Stream name")
stream_type: str = Field(description="Stream type (raw, processed, or static_image)")
stream_type: str = Field(description="Stream type (raw, processed, static_image, or video)")
display_index: Optional[int] = Field(None, description="Display index")
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS")
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")
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")
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
end_time: Optional[float] = Field(None, description="Trim end time in seconds")
resolution_limit: Optional[int] = Field(None, description="Max width for decode")
clock_id: Optional[str] = Field(None, description="Sync clock ID")
class PictureSourceListResponse(BaseModel):

View File

@@ -14,6 +14,7 @@ class PostprocessingTemplateCreate(BaseModel):
name: str = Field(description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class PostprocessingTemplateUpdate(BaseModel):
@@ -22,6 +23,7 @@ class PostprocessingTemplateUpdate(BaseModel):
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
class PostprocessingTemplateResponse(BaseModel):
@@ -30,6 +32,7 @@ class PostprocessingTemplateResponse(BaseModel):
id: str = Field(description="Template ID")
name: str = Field(description="Template name")
filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")

View File

@@ -1,58 +0,0 @@
"""Profile-related schemas."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class ConditionSchema(BaseModel):
"""A single condition within a profile."""
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
class ProfileCreate(BaseModel):
"""Request to create a profile."""
name: str = Field(description="Profile name", min_length=1, max_length=100)
enabled: bool = Field(default=True, description="Whether the profile is enabled")
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
target_ids: List[str] = Field(default_factory=list, description="Target IDs to activate")
class ProfileUpdate(BaseModel):
"""Request to update a profile."""
name: Optional[str] = Field(None, description="Profile name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the profile is enabled")
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
target_ids: Optional[List[str]] = Field(None, description="Target IDs to activate")
class ProfileResponse(BaseModel):
"""Profile information response."""
id: str = Field(description="Profile ID")
name: str = Field(description="Profile name")
enabled: bool = Field(description="Whether the profile is enabled")
condition_logic: str = Field(description="Condition combination logic")
conditions: List[ConditionSchema] = Field(description="List of conditions")
target_ids: List[str] = Field(description="Target IDs to activate")
is_active: bool = Field(default=False, description="Whether the profile is currently active")
active_target_ids: List[str] = Field(default_factory=list, description="Targets currently owned by this profile")
last_activated_at: Optional[datetime] = Field(None, description="Last time this profile was activated")
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this profile was deactivated")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class ProfileListResponse(BaseModel):
"""List of profiles response."""
profiles: List[ProfileResponse] = Field(description="List of profiles")
count: int = Field(description="Number of profiles")

View File

@@ -0,0 +1,56 @@
"""Scene preset API schemas."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class TargetSnapshotSchema(BaseModel):
target_id: str
running: bool = False
color_strip_source_id: str = ""
brightness_value_source_id: str = ""
fps: int = 30
class ScenePresetCreate(BaseModel):
"""Create a scene preset by capturing current state."""
name: str = Field(description="Preset name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
target_ids: Optional[List[str]] = Field(None, description="Target IDs to capture (all if omitted)")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class ScenePresetUpdate(BaseModel):
"""Update scene preset metadata and optionally change which targets are included."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
order: Optional[int] = None
target_ids: Optional[List[str]] = Field(None, description="Update target list: keep state for existing, capture fresh for new, drop removed")
tags: Optional[List[str]] = None
class ScenePresetResponse(BaseModel):
"""Scene preset with full snapshot data."""
id: str
name: str
description: str
targets: List[TargetSnapshotSchema]
order: int
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime
updated_at: datetime
class ScenePresetListResponse(BaseModel):
presets: List[ScenePresetResponse]
count: int
class ActivateResponse(BaseModel):
status: str = Field(description="'activated' or 'partial'")
errors: List[str] = Field(default_factory=list)

View File

@@ -0,0 +1,45 @@
"""Sync clock schemas (CRUD + control)."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class SyncClockCreate(BaseModel):
"""Request to create a synchronization clock."""
name: str = Field(description="Clock name", min_length=1, max_length=100)
speed: float = Field(default=1.0, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class SyncClockUpdate(BaseModel):
"""Request to update a synchronization clock."""
name: Optional[str] = Field(None, description="Clock name", min_length=1, max_length=100)
speed: Optional[float] = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class SyncClockResponse(BaseModel):
"""Synchronization clock response."""
id: str = Field(description="Clock ID")
name: str = Field(description="Clock name")
speed: float = Field(description="Speed multiplier")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
is_running: bool = Field(True, description="Whether clock is currently running")
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class SyncClockListResponse(BaseModel):
"""List of synchronization clocks."""
clocks: List[SyncClockResponse] = Field(description="List of sync clocks")
count: int = Field(description="Number of clocks")

View File

@@ -12,6 +12,7 @@ class HealthResponse(BaseModel):
status: Literal["healthy", "unhealthy"] = Field(description="Service health status")
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")
class VersionResponse(BaseModel):
@@ -20,6 +21,7 @@ class VersionResponse(BaseModel):
version: str = Field(description="Application version")
python_version: str = Field(description="Python version")
api_version: str = Field(description="API version")
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
class DisplayInfo(BaseModel):
@@ -62,9 +64,110 @@ class GpuInfo(BaseModel):
class PerformanceResponse(BaseModel):
"""System performance metrics."""
cpu_name: str | None = Field(default=None, description="CPU model name")
cpu_percent: float = Field(description="System-wide CPU usage percent")
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")
gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
timestamp: datetime = Field(description="Measurement timestamp")
class RestoreResponse(BaseModel):
"""Response after restoring configuration backup."""
status: str = Field(description="Status of restore operation")
stores_written: int = Field(description="Number of stores successfully written")
stores_total: int = Field(description="Total number of known stores")
missing_stores: List[str] = Field(default_factory=list, description="Store keys not found in backup")
restart_scheduled: bool = Field(description="Whether server restart was scheduled")
message: str = Field(description="Human-readable status message")
# ─── Auto-backup schemas ──────────────────────────────────────
class AutoBackupSettings(BaseModel):
"""Settings for automatic backup."""
enabled: bool = Field(description="Whether auto-backup is enabled")
interval_hours: float = Field(ge=0.5, le=168, description="Backup interval in hours")
max_backups: int = Field(ge=1, le=100, description="Maximum number of backup files to keep")
class AutoBackupStatusResponse(BaseModel):
"""Auto-backup settings plus runtime status."""
enabled: bool
interval_hours: float
max_backups: int
last_backup_time: str | None = None
next_backup_time: str | None = None
class BackupFileInfo(BaseModel):
"""Information about a saved backup file."""
filename: str
size_bytes: int
created_at: str
class BackupListResponse(BaseModel):
"""List of saved backup files."""
backups: List[BackupFileInfo]
count: int
# ─── MQTT schemas ──────────────────────────────────────────────
class MQTTSettingsResponse(BaseModel):
"""MQTT broker settings response (password is masked)."""
enabled: bool = Field(description="Whether MQTT is enabled")
broker_host: str = Field(description="MQTT broker hostname or IP")
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
username: str = Field(description="MQTT username (empty = anonymous)")
password_set: bool = Field(description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
class MQTTSettingsRequest(BaseModel):
"""MQTT broker settings update request."""
enabled: bool = Field(description="Whether MQTT is enabled")
broker_host: str = Field(description="MQTT broker hostname or IP")
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
username: str = Field(default="", description="MQTT username (empty = anonymous)")
password: str = Field(default="", description="MQTT password (empty = keep existing if omitted)")
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
# ─── External URL schema ───────────────────────────────────────
class ExternalUrlResponse(BaseModel):
"""External URL setting response."""
external_url: str = Field(description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL.")
class ExternalUrlRequest(BaseModel):
"""External URL setting update request."""
external_url: str = Field(default="", description="External base URL. Empty string to clear.")
# ─── Log level schemas ─────────────────────────────────────────
class LogLevelResponse(BaseModel):
"""Current log level response."""
level: str = Field(description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)")
class LogLevelRequest(BaseModel):
"""Request to change the log level."""
level: str = Field(description="New log level name (DEBUG, INFO, WARNING, ERROR, CRITICAL)")

View File

@@ -13,6 +13,7 @@ class TemplateCreate(BaseModel):
engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class TemplateUpdate(BaseModel):
@@ -22,6 +23,7 @@ class TemplateUpdate(BaseModel):
engine_type: Optional[str] = Field(None, description="Capture engine type (mss, dxcam, wgc)")
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
class TemplateResponse(BaseModel):
@@ -31,6 +33,7 @@ class TemplateResponse(BaseModel):
name: str = Field(description="Template name")
engine_type: str = Field(description="Engine type identifier")
engine_config: Dict = Field(description="Engine-specific configuration")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
@@ -50,6 +53,7 @@ class EngineInfo(BaseModel):
name: str = Field(description="Human-readable engine name")
default_config: Dict = Field(description="Default configuration for this engine")
available: bool = Field(description="Whether engine is available on this system")
has_own_displays: bool = Field(default=False, description="Engine has its own device list (not desktop monitors)")
class EngineListResponse(BaseModel):

View File

@@ -0,0 +1,97 @@
"""Value source schemas (CRUD)."""
from datetime import datetime
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
class ValueSourceCreate(BaseModel):
"""Request to create a value source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"] = Field(description="Source type")
# static fields
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
# adaptive fields
schedule: Optional[list] = Field(None, description="Time-of-day schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
# daylight fields
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class ValueSourceUpdate(BaseModel):
"""Request to update a value source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
# static fields
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
# adaptive fields
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
# daylight fields
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class ValueSourceResponse(BaseModel):
"""Value source response."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type: static, animated, audio, adaptive_time, or adaptive_scene")
value: Optional[float] = Field(None, description="Static value")
waveform: Optional[str] = Field(None, description="Waveform type")
speed: Optional[float] = Field(None, description="Cycles per minute")
min_value: Optional[float] = Field(None, description="Minimum output")
max_value: Optional[float] = Field(None, description="Maximum output")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode")
sensitivity: Optional[float] = Field(None, description="Gain multiplier")
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class ValueSourceListResponse(BaseModel):
"""List of value sources."""
sources: List[ValueSourceResponse] = Field(description="List of value sources")
count: int = Field(description="Number of sources")

View File

@@ -31,9 +31,28 @@ class StorageConfig(BaseSettings):
templates_file: str = "data/capture_templates.json"
postprocessing_templates_file: str = "data/postprocessing_templates.json"
picture_sources_file: str = "data/picture_sources.json"
picture_targets_file: str = "data/picture_targets.json"
output_targets_file: str = "data/output_targets.json"
pattern_templates_file: str = "data/pattern_templates.json"
profiles_file: str = "data/profiles.json"
color_strip_sources_file: str = "data/color_strip_sources.json"
audio_sources_file: str = "data/audio_sources.json"
audio_templates_file: str = "data/audio_templates.json"
value_sources_file: str = "data/value_sources.json"
automations_file: str = "data/automations.json"
scene_presets_file: str = "data/scene_presets.json"
color_strip_processing_templates_file: str = "data/color_strip_processing_templates.json"
sync_clocks_file: str = "data/sync_clocks.json"
class MQTTConfig(BaseSettings):
"""MQTT broker configuration."""
enabled: bool = False
broker_host: str = "localhost"
broker_port: int = 1883
username: str = ""
password: str = ""
client_id: str = "ledgrab"
base_topic: str = "ledgrab"
class LoggingConfig(BaseSettings):
@@ -54,11 +73,22 @@ class Config(BaseSettings):
case_sensitive=False,
)
demo: bool = False
server: ServerConfig = Field(default_factory=ServerConfig)
auth: AuthConfig = Field(default_factory=AuthConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
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."""
if self.demo:
for field_name in self.storage.model_fields:
value = getattr(self.storage, field_name)
if isinstance(value, str) and value.startswith("data/"):
setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1))
@classmethod
def from_yaml(cls, config_path: str | Path) -> "Config":
"""Load configuration from YAML file.
@@ -73,7 +103,7 @@ class Config(BaseSettings):
if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with open(config_path, "r") as f:
with open(config_path, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f)
return cls(**config_data)
@@ -84,8 +114,9 @@ class Config(BaseSettings):
Tries to load from:
1. Environment variable WLED_CONFIG_PATH
2. ./config/default_config.yaml
3. Default values
2. WLED_DEMO=true → ./config/demo_config.yaml (if it exists)
3. ./config/default_config.yaml
4. Default values
Returns:
Config instance
@@ -95,6 +126,12 @@ class Config(BaseSettings):
if config_path:
return cls.from_yaml(config_path)
# Demo mode: try dedicated demo config first
if os.getenv("WLED_DEMO", "").lower() in ("true", "1", "yes"):
demo_path = Path("config/demo_config.yaml")
if demo_path.exists():
return cls.from_yaml(demo_path)
# Try default location
default_path = Path("config/default_config.yaml")
if default_path.exists():
@@ -129,3 +166,8 @@ def reload_config() -> Config:
global config
config = Config.load()
return config
def is_demo_mode() -> bool:
"""Check whether the application is running in demo mode."""
return get_config().demo

View File

@@ -0,0 +1,41 @@
"""Audio capture engine abstraction layer."""
from wled_controller.core.audio.base import (
AudioCaptureEngine,
AudioCaptureStreamBase,
AudioDeviceInfo,
)
from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.core.audio.analysis import (
AudioAnalysis,
AudioAnalyzer,
NUM_BANDS,
DEFAULT_SAMPLE_RATE,
DEFAULT_CHUNK_SIZE,
)
from wled_controller.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
from wled_controller.core.audio.sounddevice_engine import SounddeviceEngine, SounddeviceCaptureStream
from wled_controller.core.audio.demo_engine import DemoAudioEngine, DemoAudioCaptureStream
# Auto-register available engines
AudioEngineRegistry.register(WasapiEngine)
AudioEngineRegistry.register(SounddeviceEngine)
AudioEngineRegistry.register(DemoAudioEngine)
__all__ = [
"AudioCaptureEngine",
"AudioCaptureStreamBase",
"AudioDeviceInfo",
"AudioEngineRegistry",
"AudioAnalysis",
"AudioAnalyzer",
"NUM_BANDS",
"DEFAULT_SAMPLE_RATE",
"DEFAULT_CHUNK_SIZE",
"WasapiEngine",
"WasapiCaptureStream",
"SounddeviceEngine",
"SounddeviceCaptureStream",
"DemoAudioEngine",
"DemoAudioCaptureStream",
]

View File

@@ -0,0 +1,252 @@
"""Shared audio analysis — FFT spectrum, RMS, beat detection.
Engines provide raw audio chunks; AudioAnalyzer processes them into
AudioAnalysis snapshots consumed by visualization streams.
"""
import math
import time
from dataclasses import dataclass, field
from typing import List, Tuple
import numpy as np
# Number of logarithmic frequency bands for spectrum analysis
NUM_BANDS = 64
# Audio defaults
DEFAULT_SAMPLE_RATE = 44100
DEFAULT_CHUNK_SIZE = 2048 # ~46 ms at 44100 Hz
@dataclass
class AudioAnalysis:
"""Snapshot of audio analysis results.
Written by the capture thread, read by visualization streams.
Mono fields contain the mixed-down signal (all channels averaged).
Per-channel fields (left/right) are populated when the source is stereo+.
For mono sources, left/right are copies of the mono data.
"""
timestamp: float = 0.0
# Mono (mixed) — backward-compatible fields
rms: float = 0.0
peak: float = 0.0
spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32))
beat: bool = False
beat_intensity: float = 0.0
# Per-channel
left_rms: float = 0.0
left_spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32))
right_rms: float = 0.0
right_spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32))
def _build_log_bands(num_bands: int, fft_size: int, sample_rate: int) -> List[Tuple[int, int]]:
"""Build logarithmically-spaced frequency band boundaries for FFT bins.
Returns list of (start_bin, end_bin) pairs.
"""
nyquist = sample_rate / 2
min_freq = 20.0
max_freq = min(nyquist, 20000.0)
log_min = math.log10(min_freq)
log_max = math.log10(max_freq)
freqs = np.logspace(log_min, log_max, num_bands + 1)
bin_width = sample_rate / fft_size
bands = []
for i in range(num_bands):
start_bin = max(1, int(freqs[i] / bin_width))
end_bin = max(start_bin + 1, int(freqs[i + 1] / bin_width))
end_bin = min(end_bin, fft_size // 2)
bands.append((start_bin, end_bin))
return bands
class AudioAnalyzer:
"""Stateful audio analyzer — call analyze() per raw chunk.
Maintains smoothing buffers, energy history for beat detection,
and pre-allocated FFT scratch buffers. Thread-safe only if a single
thread calls analyze() (the capture thread).
"""
def __init__(self, sample_rate: int = DEFAULT_SAMPLE_RATE, chunk_size: int = DEFAULT_CHUNK_SIZE):
self._sample_rate = sample_rate
self._chunk_size = chunk_size
# FFT helpers
self._window = np.hanning(chunk_size).astype(np.float32)
self._bands = _build_log_bands(NUM_BANDS, chunk_size, sample_rate)
# Beat detection state
self._energy_history: np.ndarray = np.zeros(43, dtype=np.float64) # ~1s at 44100/2048
self._energy_idx = 0
# Smoothed spectrum (exponential decay)
self._smooth_spectrum = np.zeros(NUM_BANDS, dtype=np.float32)
self._smooth_spectrum_left = np.zeros(NUM_BANDS, dtype=np.float32)
self._smooth_spectrum_right = np.zeros(NUM_BANDS, dtype=np.float32)
self._smoothing_alpha = 0.3
# Pre-allocated scratch buffers
self._fft_windowed = np.empty(chunk_size, dtype=np.float32)
self._spectrum_buf = np.zeros(NUM_BANDS, dtype=np.float32)
self._spectrum_buf_left = np.zeros(NUM_BANDS, dtype=np.float32)
self._spectrum_buf_right = np.zeros(NUM_BANDS, dtype=np.float32)
self._sq_buf = np.empty(chunk_size, dtype=np.float32)
# Double-buffered output spectra — avoids allocating new arrays each
# analyze() call. Consumers hold a reference to the "old" buffer while
# the analyzer writes into the alternate one.
self._out_spectrum = [np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32)]
self._out_spectrum_left = [np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32)]
self._out_spectrum_right = [np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32)]
self._out_idx = 0 # toggles 0/1 each analyze() call
# Pre-compute band start/end arrays and widths for vectorized binning
self._band_starts = np.array([s for s, _ in self._bands], dtype=np.intp)
self._band_ends = np.array([e for _, e in self._bands], dtype=np.intp)
self._band_widths = (self._band_ends - self._band_starts).astype(np.float32)
self._band_widths[self._band_widths == 0] = 1.0 # avoid divide-by-zero
# Pre-allocated channel buffers for stereo
self._left_buf = np.empty(chunk_size, dtype=np.float32)
self._right_buf = np.empty(chunk_size, dtype=np.float32)
self._mono_buf = np.empty(chunk_size, dtype=np.float32)
@property
def sample_rate(self) -> int:
return self._sample_rate
@sample_rate.setter
def sample_rate(self, value: int):
if value != self._sample_rate:
self._sample_rate = value
self._bands = _build_log_bands(NUM_BANDS, self._chunk_size, value)
def analyze(self, raw_data: np.ndarray, channels: int) -> AudioAnalysis:
"""Analyze a raw audio chunk and return an AudioAnalysis snapshot.
Args:
raw_data: 1-D float32 array of interleaved samples (length = chunk_size * channels)
channels: Number of audio channels
Returns:
AudioAnalysis with spectrum, RMS, beat, etc.
"""
chunk_size = self._chunk_size
alpha = self._smoothing_alpha
one_minus_alpha = 1.0 - alpha
# Split channels and mix to mono
if channels > 1:
data = raw_data.reshape(-1, channels)
np.copyto(self._left_buf[:len(data)], data[:, 0])
right_col = data[:, 1] if channels >= 2 else data[:, 0]
np.copyto(self._right_buf[:len(data)], right_col)
np.add(data[:, 0], right_col, out=self._mono_buf[:len(data)])
self._mono_buf[:len(data)] *= 0.5
samples = self._mono_buf[:len(data)]
left_samples = self._left_buf[:len(data)]
right_samples = self._right_buf[:len(data)]
else:
samples = raw_data
left_samples = samples
right_samples = samples
# RMS and peak
n = len(samples)
np.multiply(samples, samples, out=self._sq_buf[:n])
rms = float(np.sqrt(np.mean(self._sq_buf[:n])))
peak = float(np.max(np.abs(samples)))
if channels > 1:
np.multiply(left_samples, left_samples, out=self._sq_buf[:n])
left_rms = float(np.sqrt(np.mean(self._sq_buf[:n])))
np.multiply(right_samples, right_samples, out=self._sq_buf[:n])
right_rms = float(np.sqrt(np.mean(self._sq_buf[:n])))
else:
left_rms = rms
right_rms = rms
# FFT for mono, left, right
self._fft_bands(samples, self._spectrum_buf, self._smooth_spectrum,
alpha, one_minus_alpha)
if channels > 1:
self._fft_bands(left_samples, self._spectrum_buf_left, self._smooth_spectrum_left,
alpha, one_minus_alpha)
self._fft_bands(right_samples, self._spectrum_buf_right, self._smooth_spectrum_right,
alpha, one_minus_alpha)
else:
np.copyto(self._smooth_spectrum_left, self._smooth_spectrum)
np.copyto(self._smooth_spectrum_right, self._smooth_spectrum)
# Beat detection — compare current energy to rolling average (mono)
np.multiply(samples, samples, out=self._sq_buf[:n])
energy = float(np.sum(self._sq_buf[:n]))
self._energy_history[self._energy_idx] = energy
self._energy_idx = (self._energy_idx + 1) % len(self._energy_history)
avg_energy = float(np.mean(self._energy_history))
beat = False
beat_intensity = 0.0
if avg_energy > 1e-8:
ratio = energy / avg_energy
if ratio > 1.5:
beat = True
beat_intensity = min(1.0, (ratio - 1.0) / 2.0)
# Snapshot spectra into double-buffered output arrays (no allocation)
idx = self._out_idx
self._out_idx = 1 - idx
out_spec = self._out_spectrum[idx]
out_left = self._out_spectrum_left[idx]
out_right = self._out_spectrum_right[idx]
np.copyto(out_spec, self._smooth_spectrum)
np.copyto(out_left, self._smooth_spectrum_left)
np.copyto(out_right, self._smooth_spectrum_right)
return AudioAnalysis(
timestamp=time.perf_counter(),
rms=rms,
peak=peak,
spectrum=out_spec,
beat=beat,
beat_intensity=beat_intensity,
left_rms=left_rms,
left_spectrum=out_left,
right_rms=right_rms,
right_spectrum=out_right,
)
def _fft_bands(self, samps, buf, smooth_buf, alpha, one_minus_alpha):
"""Compute FFT, bin into bands, normalize, and smooth."""
chunk_size = self._chunk_size
chunk = samps[:chunk_size]
if len(chunk) < chunk_size:
chunk = np.pad(chunk, (0, chunk_size - len(chunk)))
np.multiply(chunk, self._window, out=self._fft_windowed)
fft_mag = np.abs(np.fft.rfft(self._fft_windowed))
fft_mag *= (1.0 / chunk_size)
fft_len = len(fft_mag)
# Vectorized band binning using cumulative sum
valid = (self._band_starts < fft_len) & (self._band_ends <= fft_len) & (self._band_ends > 0)
buf[:] = 0.0
if valid.any():
cumsum = np.cumsum(fft_mag)
band_sums = cumsum[self._band_ends[valid] - 1] - np.where(
self._band_starts[valid] > 0, cumsum[self._band_starts[valid] - 1], 0.0
)
buf[valid] = band_sums / self._band_widths[valid]
spec_max = float(np.max(buf))
if spec_max > 1e-6:
buf *= (1.0 / spec_max)
smooth_buf *= one_minus_alpha
smooth_buf += alpha * buf

View File

@@ -0,0 +1,322 @@
"""Audio capture service — shared audio analysis with ref counting.
Provides real-time FFT spectrum, RMS level, and beat detection from
system audio or microphone/line-in. Multiple AudioColorStripStreams
sharing the same device reuse a single capture thread via
AudioCaptureManager.
Engine-agnostic: uses AudioEngineRegistry to create the underlying
capture stream (WASAPI, sounddevice, etc.).
"""
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from wled_controller.core.audio.analysis import (
AudioAnalysis,
AudioAnalyzer,
DEFAULT_CHUNK_SIZE,
DEFAULT_SAMPLE_RATE,
)
from wled_controller.core.audio.base import AudioCaptureStreamBase
from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Re-export for backward compatibility
__all__ = [
"AudioAnalysis",
"ManagedAudioStream",
"AudioCaptureManager",
]
# ---------------------------------------------------------------------------
# ManagedAudioStream — wraps engine stream + analyzer in background thread
# ---------------------------------------------------------------------------
class ManagedAudioStream:
"""Wraps an AudioCaptureStreamBase + AudioAnalyzer in a background thread.
Public API is the same as the old AudioCaptureStream:
start(), stop(), get_latest_analysis(), get_last_timing().
"""
def __init__(
self,
engine_type: str,
device_index: int,
is_loopback: bool,
engine_config: Optional[Dict[str, Any]] = None,
):
self._engine_type = engine_type
self._device_index = device_index
self._is_loopback = is_loopback
self._engine_config = engine_config or {}
self._running = False
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
self._latest: Optional[AudioAnalysis] = None
self._last_timing: dict = {}
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._capture_loop, daemon=True,
name=f"AudioCapture-{self._engine_type}-{self._device_index}-"
f"{'lb' if self._is_loopback else 'in'}",
)
self._thread.start()
logger.info(
f"ManagedAudioStream started: engine={self._engine_type} "
f"device={self._device_index} loopback={self._is_loopback}"
)
def stop(self) -> None:
self._running = False
if self._thread is not None:
self._thread.join(timeout=5.0)
self._thread = None
with self._lock:
self._latest = None
logger.info(
f"ManagedAudioStream stopped: engine={self._engine_type} "
f"device={self._device_index}"
)
def get_latest_analysis(self) -> Optional[AudioAnalysis]:
with self._lock:
return self._latest
def get_last_timing(self) -> dict:
return dict(self._last_timing)
def _capture_loop(self) -> None:
stream: Optional[AudioCaptureStreamBase] = None
try:
stream = AudioEngineRegistry.create_stream(
self._engine_type, self._device_index,
self._is_loopback, self._engine_config,
)
stream.initialize()
sample_rate = stream.sample_rate
chunk_size = stream.chunk_size
channels = stream.channels
analyzer = AudioAnalyzer(sample_rate=sample_rate, chunk_size=chunk_size)
logger.info(
f"Audio stream opened: engine={self._engine_type} "
f"device={self._device_index} loopback={self._is_loopback} "
f"channels={channels} sr={sample_rate}"
)
while self._running:
t_read_start = time.perf_counter()
raw_data = stream.read_chunk()
if raw_data is None:
time.sleep(0.05)
continue
t_read_end = time.perf_counter()
analysis = analyzer.analyze(raw_data, channels)
t_fft_end = time.perf_counter()
self._last_timing = {
"read_ms": (t_read_end - t_read_start) * 1000,
"fft_ms": (t_fft_end - t_read_end) * 1000,
}
with self._lock:
self._latest = analysis
except Exception as e:
logger.error(f"ManagedAudioStream fatal error: {e}", exc_info=True)
finally:
if stream is not None:
try:
stream.cleanup()
except Exception:
pass
self._running = False
logger.info(
f"ManagedAudioStream loop ended: engine={self._engine_type} "
f"device={self._device_index}"
)
# ---------------------------------------------------------------------------
# AudioCaptureManager — ref-counted shared capture streams
# ---------------------------------------------------------------------------
class AudioCaptureManager:
"""Manages shared ManagedAudioStream instances with reference counting.
Multiple AudioColorStripStreams using the same audio device share a
single capture thread. Key: (engine_type, device_index, is_loopback).
"""
def __init__(self):
self._streams: Dict[
Tuple[str, int, bool],
Tuple[ManagedAudioStream, int],
] = {}
self._lock = threading.Lock()
def acquire(
self,
device_index: int,
is_loopback: bool,
engine_type: Optional[str] = None,
engine_config: Optional[Dict[str, Any]] = None,
) -> ManagedAudioStream:
"""Get or create a ManagedAudioStream for the given device.
Args:
device_index: Audio device index
is_loopback: Whether to capture loopback audio
engine_type: Engine type (falls back to best available if None)
engine_config: Engine-specific configuration
Returns:
Shared ManagedAudioStream instance.
"""
if engine_type is None:
engine_type = AudioEngineRegistry.get_best_available_engine()
if engine_type is None:
raise RuntimeError("No audio capture engines available")
key = (engine_type, device_index, is_loopback)
with self._lock:
if key in self._streams:
stream, ref_count = self._streams[key]
self._streams[key] = (stream, ref_count + 1)
logger.info(f"Reusing audio capture {key} (ref_count={ref_count + 1})")
return stream
stream = ManagedAudioStream(
engine_type, device_index, is_loopback, engine_config,
)
stream.start()
self._streams[key] = (stream, 1)
logger.info(f"Created audio capture {key}")
return stream
def release(
self,
device_index: int,
is_loopback: bool,
engine_type: Optional[str] = None,
) -> None:
"""Release a reference to a ManagedAudioStream."""
if engine_type is None:
engine_type = AudioEngineRegistry.get_best_available_engine()
if engine_type is None:
return
key = (engine_type, device_index, is_loopback)
stream_to_stop = None
with self._lock:
if key not in self._streams:
logger.warning(f"Attempted to release unknown audio capture: {key}")
return
stream, ref_count = self._streams[key]
ref_count -= 1
if ref_count <= 0:
stream_to_stop = stream
del self._streams[key]
logger.info(f"Removed audio capture {key}")
else:
self._streams[key] = (stream, ref_count)
logger.debug(f"Released audio capture {key} (ref_count={ref_count})")
# Stop outside the lock — stream.stop() joins a thread (up to 5s)
if stream_to_stop is not None:
stream_to_stop.stop()
def release_all(self) -> None:
"""Stop and remove all capture streams. Called on shutdown."""
with self._lock:
streams_to_stop = list(self._streams.items())
self._streams.clear()
# Stop outside the lock — each stop() joins a thread
for key, (stream, _) in streams_to_stop:
try:
stream.stop()
except Exception as e:
logger.error(f"Error stopping audio capture {key}: {e}")
logger.info("Released all audio capture streams")
@staticmethod
def enumerate_devices() -> List[dict]:
"""List available audio devices from all registered engines.
Returns list of dicts with device info, each tagged with engine_type.
Deduplicates by (name, is_loopback), keeping the entry from the
highest-priority engine.
"""
# Collect from all engines, sorted by descending priority
engines = [
(engine_class.ENGINE_PRIORITY, engine_type, engine_class)
for engine_type, engine_class in AudioEngineRegistry.get_all_engines().items()
]
engines.sort(key=lambda x: x[0], reverse=True)
seen: set = set()
result = []
for _priority, engine_type, engine_class in engines:
try:
if not engine_class.is_available():
continue
for dev in engine_class.enumerate_devices():
key = (dev.name, dev.is_loopback)
if key in seen:
continue
seen.add(key)
result.append({
"index": dev.index,
"name": dev.name,
"is_input": dev.is_input,
"is_loopback": dev.is_loopback,
"channels": dev.channels,
"default_samplerate": dev.default_samplerate,
"engine_type": engine_type,
})
except Exception as e:
logger.error(f"Error enumerating devices for engine '{engine_type}': {e}")
return result
@staticmethod
def enumerate_devices_by_engine() -> Dict[str, List[dict]]:
"""List available audio devices grouped by engine type.
Unlike enumerate_devices(), does NOT deduplicate across engines.
Each engine's devices are returned with their engine-specific indices.
"""
result: Dict[str, List[dict]] = {}
for engine_type, engine_class in AudioEngineRegistry.get_all_engines().items():
try:
if not engine_class.is_available():
continue
devices = []
for dev in engine_class.enumerate_devices():
devices.append({
"index": dev.index,
"name": dev.name,
"is_input": dev.is_input,
"is_loopback": dev.is_loopback,
"channels": dev.channels,
"default_samplerate": dev.default_samplerate,
"engine_type": engine_type,
})
result[engine_type] = devices
except Exception as e:
logger.error(f"Error enumerating devices for engine '{engine_type}': {e}")
return result

View File

@@ -0,0 +1,165 @@
"""Base classes for audio capture engines."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import numpy as np
@dataclass
class AudioDeviceInfo:
"""Information about an audio device."""
index: int
name: str
is_input: bool
is_loopback: bool
channels: int
default_samplerate: float
class AudioCaptureStreamBase(ABC):
"""Abstract base class for an audio capture session.
An AudioCaptureStreamBase is a stateful session bound to a specific
audio device. It holds device-specific resources and provides raw
audio chunk reading.
Created by AudioCaptureEngine.create_stream().
Lifecycle:
stream = engine.create_stream(device_index, is_loopback, config)
stream.initialize()
chunk = stream.read_chunk()
stream.cleanup()
Or via context manager:
with engine.create_stream(device_index, is_loopback, config) as stream:
chunk = stream.read_chunk()
"""
def __init__(
self,
device_index: int,
is_loopback: bool,
config: Dict[str, Any],
):
self.device_index = device_index
self.is_loopback = is_loopback
self.config = config
self._initialized = False
@property
@abstractmethod
def channels(self) -> int:
"""Number of audio channels in the stream."""
pass
@property
@abstractmethod
def sample_rate(self) -> int:
"""Sample rate of the audio stream."""
pass
@property
@abstractmethod
def chunk_size(self) -> int:
"""Number of frames per read_chunk() call."""
pass
@abstractmethod
def initialize(self) -> None:
"""Initialize audio capture resources.
Raises:
RuntimeError: If initialization fails
"""
pass
@abstractmethod
def cleanup(self) -> None:
"""Release all audio capture resources."""
pass
@abstractmethod
def read_chunk(self) -> Optional[np.ndarray]:
"""Read one chunk of raw audio data.
Returns:
1-D float32 ndarray of interleaved samples (length = chunk_size * channels),
or None if no data available.
Raises:
RuntimeError: If read fails
"""
pass
def __enter__(self):
"""Context manager entry — initialize stream."""
self.initialize()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit — cleanup stream."""
self.cleanup()
class AudioCaptureEngine(ABC):
"""Abstract base class for audio capture engines.
An AudioCaptureEngine is a stateless factory that knows about an audio
capture technology. It can enumerate devices, check availability, and
create AudioCaptureStreamBase instances.
All methods are classmethods — no instance creation needed.
"""
ENGINE_TYPE: str = "base"
ENGINE_PRIORITY: int = 0
@classmethod
@abstractmethod
def is_available(cls) -> bool:
"""Check if this engine is available on the current system."""
pass
@classmethod
@abstractmethod
def get_default_config(cls) -> Dict[str, Any]:
"""Get default configuration for this engine."""
pass
@classmethod
@abstractmethod
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
"""Get list of available audio devices.
Returns:
List of AudioDeviceInfo objects
Raises:
RuntimeError: If unable to detect devices
"""
pass
@classmethod
@abstractmethod
def create_stream(
cls,
device_index: int,
is_loopback: bool,
config: Dict[str, Any],
) -> AudioCaptureStreamBase:
"""Create a capture stream for the specified device.
Args:
device_index: Index of audio device
is_loopback: Whether to capture loopback audio
config: Engine-specific configuration dict
Returns:
Uninitialized AudioCaptureStreamBase. Caller must call
initialize() or use as context manager.
"""
pass

View File

@@ -0,0 +1,153 @@
"""Demo audio engine — virtual audio devices with synthetic audio data."""
import time
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.config import is_demo_mode
from wled_controller.core.audio.base import (
AudioCaptureEngine,
AudioCaptureStreamBase,
AudioDeviceInfo,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Virtual audio device definitions: (name, is_loopback, channels, samplerate)
_VIRTUAL_DEVICES = [
("Demo Microphone", False, 2, 44100.0),
("Demo System Audio", True, 2, 44100.0),
]
class DemoAudioCaptureStream(AudioCaptureStreamBase):
"""Demo audio capture stream that produces synthetic music-like audio.
Generates a mix of sine waves with slowly varying frequencies to
simulate beat-like patterns suitable for audio-reactive visualizations.
"""
def __init__(self, device_index: int, is_loopback: bool, config: Dict[str, Any]):
super().__init__(device_index, is_loopback, config)
self._channels = 2
self._sample_rate = 44100
self._chunk_size = 1024
self._phase = 0.0 # Accumulated phase in samples for continuity
@property
def channels(self) -> int:
return self._channels
@property
def sample_rate(self) -> int:
return self._sample_rate
@property
def chunk_size(self) -> int:
return self._chunk_size
def initialize(self) -> None:
if self._initialized:
return
self._phase = 0.0
self._initialized = True
logger.info(
f"Demo audio stream initialized "
f"(device={self.device_index}, loopback={self.is_loopback})"
)
def cleanup(self) -> None:
self._initialized = False
logger.info(f"Demo audio stream cleaned up (device={self.device_index})")
def read_chunk(self) -> Optional[np.ndarray]:
if not self._initialized:
return None
t_now = time.time()
n = self._chunk_size
sr = self._sample_rate
# Sample indices for this chunk (continuous across calls)
t = (self._phase + np.arange(n, dtype=np.float64)) / sr
self._phase += n
# --- Synthetic "music" signal ---
# Bass drum: ~80 Hz with slow amplitude envelope (~2 Hz beat)
bass_freq = 80.0
beat_rate = 2.0 # beats per second
bass_env = np.maximum(0.0, np.sin(2.0 * np.pi * beat_rate * t)) ** 4
bass = 0.5 * bass_env * np.sin(2.0 * np.pi * bass_freq * t)
# Mid-range tone: slowly sweeping between 300-600 Hz
mid_freq = 450.0 + 150.0 * np.sin(2.0 * np.pi * 0.1 * t_now)
mid = 0.25 * np.sin(2.0 * np.pi * mid_freq * t)
# High shimmer: ~3 kHz with faster modulation
hi_freq = 3000.0 + 500.0 * np.sin(2.0 * np.pi * 0.3 * t_now)
hi_env = 0.5 + 0.5 * np.sin(2.0 * np.pi * 4.0 * t)
hi = 0.1 * hi_env * np.sin(2.0 * np.pi * hi_freq * t)
# Mix mono signal
mono = (bass + mid + hi).astype(np.float32)
# Interleave stereo (identical L/R)
stereo = np.empty(n * self._channels, dtype=np.float32)
stereo[0::2] = mono
stereo[1::2] = mono
return stereo
class DemoAudioEngine(AudioCaptureEngine):
"""Virtual audio engine for demo mode.
Provides virtual audio devices and produces synthetic audio data
so the full audio-reactive pipeline works without real audio hardware.
"""
ENGINE_TYPE = "demo"
ENGINE_PRIORITY = 1000 # Highest priority in demo mode
@classmethod
def is_available(cls) -> bool:
return is_demo_mode()
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
return {
"sample_rate": 44100,
"chunk_size": 1024,
}
@classmethod
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
devices = []
for idx, (name, is_loopback, channels, samplerate) in enumerate(_VIRTUAL_DEVICES):
devices.append(AudioDeviceInfo(
index=idx,
name=name,
is_input=True,
is_loopback=is_loopback,
channels=channels,
default_samplerate=samplerate,
))
logger.debug(f"Demo audio engine: {len(devices)} virtual device(s)")
return devices
@classmethod
def create_stream(
cls,
device_index: int,
is_loopback: bool,
config: Dict[str, Any],
) -> DemoAudioCaptureStream:
if device_index < 0 or device_index >= len(_VIRTUAL_DEVICES):
raise ValueError(
f"Invalid demo audio device index {device_index}. "
f"Available: 0-{len(_VIRTUAL_DEVICES) - 1}"
)
merged = {**cls.get_default_config(), **config}
return DemoAudioCaptureStream(device_index, is_loopback, merged)

View File

@@ -0,0 +1,168 @@
"""Engine registry and factory for audio capture engines."""
from typing import Any, Dict, List, Optional, Type
from wled_controller.core.audio.base import AudioCaptureEngine, AudioCaptureStreamBase
from wled_controller.config import is_demo_mode
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class AudioEngineRegistry:
"""Registry for available audio capture engines.
Maintains a registry of all audio engine implementations
and provides factory methods for creating capture streams.
"""
_engines: Dict[str, Type[AudioCaptureEngine]] = {}
@classmethod
def register(cls, engine_class: Type[AudioCaptureEngine]):
"""Register an audio capture engine.
Args:
engine_class: Engine class to register (must inherit from AudioCaptureEngine)
Raises:
ValueError: If engine_class is not a subclass of AudioCaptureEngine
"""
if not issubclass(engine_class, AudioCaptureEngine):
raise ValueError(f"{engine_class} must be a subclass of AudioCaptureEngine")
engine_type = engine_class.ENGINE_TYPE
if engine_type == "base":
raise ValueError("Cannot register base engine type")
if engine_type in cls._engines:
logger.warning(f"Audio engine '{engine_type}' already registered, overwriting")
cls._engines[engine_type] = engine_class
logger.info(f"Registered audio engine: {engine_type}")
@classmethod
def get_engine(cls, engine_type: str) -> Type[AudioCaptureEngine]:
"""Get engine class by type.
Args:
engine_type: Engine type identifier (e.g., "wasapi", "sounddevice")
Returns:
Engine class
Raises:
ValueError: If engine type not found
"""
if engine_type not in cls._engines:
available = ", ".join(cls._engines.keys()) or "none"
raise ValueError(
f"Unknown audio engine type: '{engine_type}'. Available engines: {available}"
)
return cls._engines[engine_type]
@classmethod
def get_available_engines(cls) -> List[str]:
"""Get list of available engine types on this system.
Returns:
List of engine type identifiers that are available
"""
demo = is_demo_mode()
available = []
for engine_type, engine_class in cls._engines.items():
try:
# In demo mode, only demo engines are available
if demo and engine_type != "demo":
continue
if engine_class.is_available():
available.append(engine_type)
except Exception as e:
logger.error(
f"Error checking availability for audio engine '{engine_type}': {e}"
)
return available
@classmethod
def get_best_available_engine(cls) -> Optional[str]:
"""Get the highest-priority available engine type.
Returns:
Engine type string, or None if no engines are available.
"""
demo = is_demo_mode()
best_type = None
best_priority = -1
for engine_type, engine_class in cls._engines.items():
try:
if demo and engine_type != "demo":
continue
if engine_class.is_available() and engine_class.ENGINE_PRIORITY > best_priority:
best_priority = engine_class.ENGINE_PRIORITY
best_type = engine_type
except Exception as e:
logger.error(
f"Error checking availability for audio engine '{engine_type}': {e}"
)
return best_type
@classmethod
def get_all_engines(cls) -> Dict[str, Type[AudioCaptureEngine]]:
"""Get all registered engines (available or not).
In demo mode, only demo engines are returned.
Returns:
Dictionary mapping engine type to engine class
"""
if is_demo_mode():
return {k: v for k, v in cls._engines.items() if k == "demo"}
return cls._engines.copy()
@classmethod
def create_stream(
cls,
engine_type: str,
device_index: int,
is_loopback: bool,
config: Dict[str, Any],
) -> AudioCaptureStreamBase:
"""Create an AudioCaptureStreamBase for the specified engine and device.
Args:
engine_type: Engine type identifier
device_index: Audio device index
is_loopback: Whether to capture loopback audio
config: Engine-specific configuration
Returns:
Uninitialized AudioCaptureStreamBase instance
Raises:
ValueError: If engine type not found or not available
"""
engine_class = cls.get_engine(engine_type)
if not engine_class.is_available():
raise ValueError(
f"Audio engine '{engine_type}' is not available on this system"
)
try:
stream = engine_class.create_stream(device_index, is_loopback, config)
logger.debug(
f"Created audio stream: {engine_type} "
f"(device={device_index}, loopback={is_loopback})"
)
return stream
except Exception as e:
logger.error(f"Failed to create stream for audio engine '{engine_type}': {e}")
raise RuntimeError(
f"Failed to create stream for audio engine '{engine_type}': {e}"
)
@classmethod
def clear_registry(cls):
"""Clear all registered engines (for testing)."""
cls._engines.clear()
logger.debug("Cleared audio engine registry")

View File

@@ -0,0 +1,159 @@
"""Sounddevice audio capture engine (cross-platform, via PortAudio)."""
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.audio.base import (
AudioCaptureEngine,
AudioCaptureStreamBase,
AudioDeviceInfo,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class SounddeviceCaptureStream(AudioCaptureStreamBase):
"""Audio capture stream using sounddevice (PortAudio)."""
def __init__(self, device_index: int, is_loopback: bool, config: Dict[str, Any]):
super().__init__(device_index, is_loopback, config)
self._sd_stream = None
self._channels = config.get("channels", 2)
self._sample_rate = config.get("sample_rate", 44100)
self._chunk_size = config.get("chunk_size", 2048)
@property
def channels(self) -> int:
return self._channels
@property
def sample_rate(self) -> int:
return self._sample_rate
@property
def chunk_size(self) -> int:
return self._chunk_size
def initialize(self) -> None:
if self._initialized:
return
try:
import sounddevice as sd
except ImportError:
raise RuntimeError("sounddevice is not installed — sounddevice engine unavailable")
# Resolve device
device_id = self.device_index if self.device_index >= 0 else None
if device_id is not None:
dev_info = sd.query_devices(device_id)
self._channels = min(self._channels, int(dev_info["max_input_channels"]))
if self._channels < 1:
raise RuntimeError(
f"Device {device_id} ({dev_info['name']}) has no input channels"
)
self._sample_rate = int(dev_info["default_samplerate"])
self._sd_stream = sd.InputStream(
device=device_id,
channels=self._channels,
samplerate=self._sample_rate,
blocksize=self._chunk_size,
dtype="float32",
)
self._sd_stream.start()
self._initialized = True
logger.info(
f"sounddevice stream opened: device={device_id} loopback={self.is_loopback} "
f"channels={self._channels} sr={self._sample_rate}"
)
def cleanup(self) -> None:
if self._sd_stream is not None:
try:
self._sd_stream.stop()
self._sd_stream.close()
except Exception:
pass
self._sd_stream = None
self._initialized = False
def read_chunk(self) -> Optional[np.ndarray]:
if self._sd_stream is None:
return None
try:
# sd.InputStream.read() returns (data, overflowed)
data, _ = self._sd_stream.read(self._chunk_size)
# data shape: (chunk_size, channels) — flatten to interleaved 1-D
return data.flatten().astype(np.float32)
except Exception as e:
logger.warning(f"sounddevice read error: {e}")
return None
class SounddeviceEngine(AudioCaptureEngine):
"""Sounddevice (PortAudio) audio capture engine — cross-platform."""
ENGINE_TYPE = "sounddevice"
ENGINE_PRIORITY = 5
@classmethod
def is_available(cls) -> bool:
try:
import sounddevice # noqa: F401
return True
except ImportError:
return False
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
return {
"sample_rate": 44100,
"chunk_size": 2048,
}
@classmethod
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
try:
import sounddevice as sd
except ImportError:
return []
try:
devices = sd.query_devices()
result = []
for i, dev in enumerate(devices):
max_in = int(dev["max_input_channels"])
if max_in < 1:
continue
name = dev["name"]
# On PulseAudio/PipeWire, monitor sources are loopback-capable
is_loopback = "monitor" in name.lower()
result.append(AudioDeviceInfo(
index=i,
name=name,
is_input=True,
is_loopback=is_loopback,
channels=max_in,
default_samplerate=dev["default_samplerate"],
))
return result
except Exception as e:
logger.error(f"Failed to enumerate sounddevice devices: {e}", exc_info=True)
return []
@classmethod
def create_stream(
cls,
device_index: int,
is_loopback: bool,
config: Dict[str, Any],
) -> SounddeviceCaptureStream:
merged = {**cls.get_default_config(), **config}
return SounddeviceCaptureStream(device_index, is_loopback, merged)

View File

@@ -0,0 +1,237 @@
"""WASAPI audio capture engine (Windows only, via PyAudioWPatch)."""
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.audio.base import (
AudioCaptureEngine,
AudioCaptureStreamBase,
AudioDeviceInfo,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class WasapiCaptureStream(AudioCaptureStreamBase):
"""Audio capture stream using PyAudioWPatch (WASAPI)."""
def __init__(self, device_index: int, is_loopback: bool, config: Dict[str, Any]):
super().__init__(device_index, is_loopback, config)
self._pa = None
self._stream = None
self._channels = config.get("channels", 2)
self._sample_rate = config.get("sample_rate", 44100)
self._chunk_size = config.get("chunk_size", 2048)
@property
def channels(self) -> int:
return self._channels
@property
def sample_rate(self) -> int:
return self._sample_rate
@property
def chunk_size(self) -> int:
return self._chunk_size
def initialize(self) -> None:
if self._initialized:
return
try:
import pyaudiowpatch as pyaudio
except ImportError:
raise RuntimeError("PyAudioWPatch is not installed — WASAPI engine unavailable")
self._pa = pyaudio.PyAudio()
if self.is_loopback:
loopback_device = self._find_loopback_device(self._pa, self.device_index)
if loopback_device is None:
self._pa.terminate()
self._pa = None
raise RuntimeError(
f"No loopback device found for output device {self.device_index}"
)
device_idx = loopback_device["index"]
self._channels = loopback_device["maxInputChannels"]
self._sample_rate = int(loopback_device["defaultSampleRate"])
else:
device_idx = self.device_index if self.device_index >= 0 else None
if device_idx is not None:
dev_info = self._pa.get_device_info_by_index(device_idx)
self._channels = max(1, dev_info["maxInputChannels"])
self._sample_rate = int(dev_info["defaultSampleRate"])
self._stream = self._pa.open(
format=pyaudio.paFloat32,
channels=self._channels,
rate=self._sample_rate,
input=True,
input_device_index=device_idx,
frames_per_buffer=self._chunk_size,
)
self._initialized = True
logger.info(
f"WASAPI stream opened: device={device_idx} loopback={self.is_loopback} "
f"channels={self._channels} sr={self._sample_rate}"
)
def cleanup(self) -> None:
if self._stream is not None:
try:
self._stream.stop_stream()
self._stream.close()
except Exception:
pass
self._stream = None
if self._pa is not None:
try:
self._pa.terminate()
except Exception:
pass
self._pa = None
self._initialized = False
def read_chunk(self) -> Optional[np.ndarray]:
if self._stream is None:
return None
try:
raw_data = self._stream.read(self._chunk_size, exception_on_overflow=False)
return np.frombuffer(raw_data, dtype=np.float32)
except Exception as e:
logger.warning(f"WASAPI read error: {e}")
return None
@staticmethod
def _find_loopback_device(pa, output_device_index: int) -> Optional[dict]:
"""Find the PyAudioWPatch loopback device for a given output device."""
try:
first_loopback = None
for loopback in pa.get_loopback_device_info_generator():
if first_loopback is None:
first_loopback = loopback
if output_device_index < 0:
return loopback
target_info = pa.get_device_info_by_index(output_device_index)
if target_info["name"] in loopback["name"]:
return loopback
return first_loopback
except Exception as e:
logger.error(f"Error finding loopback device: {e}")
return None
class WasapiEngine(AudioCaptureEngine):
"""WASAPI audio capture engine (Windows only)."""
ENGINE_TYPE = "wasapi"
ENGINE_PRIORITY = 10
@classmethod
def is_available(cls) -> bool:
try:
import pyaudiowpatch # noqa: F401
return True
except ImportError:
return False
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
return {
"sample_rate": 44100,
"chunk_size": 2048,
}
@classmethod
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
try:
import pyaudiowpatch as pyaudio
except ImportError:
return []
pa = None
try:
pa = pyaudio.PyAudio()
wasapi_info = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
wasapi_idx = wasapi_info["index"]
result = []
loopback_names: set = set()
device_count = pa.get_device_count()
# First pass: collect input devices. PyAudioWPatch creates
# dedicated loopback input endpoints for output devices; these
# show up as input devices whose name already contains
# "[Loopback]". We mark them as loopback and remember the name
# so the second pass won't duplicate them.
for i in range(device_count):
dev = pa.get_device_info_by_index(i)
if dev["hostApi"] != wasapi_idx:
continue
if dev["maxInputChannels"] <= 0:
continue
name = dev["name"]
is_loopback = "[Loopback]" in name
if is_loopback:
loopback_names.add(name)
result.append(AudioDeviceInfo(
index=i,
name=name,
is_input=True,
is_loopback=is_loopback,
channels=dev["maxInputChannels"],
default_samplerate=dev["defaultSampleRate"],
))
# Second pass: add loopback entries for output devices that
# don't already have a dedicated loopback input endpoint.
for i in range(device_count):
dev = pa.get_device_info_by_index(i)
if dev["hostApi"] != wasapi_idx:
continue
if dev["maxOutputChannels"] <= 0:
continue
loopback_name = f"{dev['name']} [Loopback]"
if loopback_name in loopback_names:
continue # already covered by a dedicated loopback endpoint
result.append(AudioDeviceInfo(
index=i,
name=loopback_name,
is_input=False,
is_loopback=True,
channels=dev["maxOutputChannels"],
default_samplerate=dev["defaultSampleRate"],
))
return result
except Exception as e:
logger.error(f"Failed to enumerate WASAPI devices: {e}", exc_info=True)
return []
finally:
if pa is not None:
try:
pa.terminate()
except Exception:
pass
@classmethod
def create_stream(
cls,
device_index: int,
is_loopback: bool,
config: Dict[str, Any],
) -> WasapiCaptureStream:
merged = {**cls.get_default_config(), **config}
return WasapiCaptureStream(device_index, is_loopback, merged)

View File

@@ -0,0 +1 @@
"""Automation engine — condition evaluation and scene activation."""

View File

@@ -0,0 +1,441 @@
"""Automation engine — background loop that evaluates conditions and activates scenes."""
import asyncio
import re
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set
from wled_controller.core.automations.platform_detector import PlatformDetector
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
Automation,
Condition,
DisplayStateCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class AutomationEngine:
"""Evaluates automation conditions and activates/deactivates scene presets."""
def __init__(
self,
automation_store: AutomationStore,
processor_manager,
poll_interval: float = 1.0,
mqtt_service=None,
scene_preset_store=None,
target_store=None,
device_store=None,
):
self._store = automation_store
self._manager = processor_manager
self._poll_interval = poll_interval
self._detector = PlatformDetector()
self._mqtt_service = mqtt_service
self._scene_preset_store = scene_preset_store
self._target_store = target_store
self._device_store = device_store
self._task: Optional[asyncio.Task] = None
self._eval_lock = asyncio.Lock()
# Runtime state (not persisted)
# automation_id → True when automation is currently active
self._active_automations: Dict[str, bool] = {}
# automation_id → snapshot captured before activation (for "revert" mode)
self._pre_activation_snapshots: Dict[str, ScenePreset] = {}
# automation_id → datetime of last activation / deactivation
self._last_activated: Dict[str, datetime] = {}
self._last_deactivated: Dict[str, datetime] = {}
# webhook_token → bool (volatile state set by webhook calls)
self._webhook_states: Dict[str, bool] = {}
async def start(self) -> None:
if self._task is not None:
return
self._task = asyncio.create_task(self._poll_loop())
logger.info("Automation engine started")
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
# Deactivate all automations
for automation_id in list(self._active_automations.keys()):
await self._deactivate_automation(automation_id)
logger.info("Automation engine stopped")
async def _poll_loop(self) -> None:
try:
while True:
try:
await self._evaluate_all()
except Exception as e:
logger.error(f"Automation evaluation error: {e}", exc_info=True)
await asyncio.sleep(self._poll_interval)
except asyncio.CancelledError:
pass
async def _evaluate_all(self) -> None:
async with self._eval_lock:
await self._evaluate_all_locked()
def _detect_all_sync(
self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool,
needs_idle: bool, needs_display_state: bool,
) -> tuple:
"""Run all platform detection in a single thread call.
Batching detection calls into one executor submission reduces
event-loop wake-ups, minimising asyncio.sleep() jitter in
latency-sensitive processing loops.
"""
running_procs = self._detector._get_running_processes_sync() if needs_running else set()
if needs_topmost:
topmost_proc, topmost_fullscreen = self._detector._get_topmost_process_sync()
else:
topmost_proc, topmost_fullscreen = None, False
fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None
display_state = self._detector._get_display_power_state_sync() if needs_display_state else None
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state
async def _evaluate_all_locked(self) -> None:
automations = self._store.get_all_automations()
if not automations:
# No automations — deactivate any stale state
for aid in list(self._active_automations.keys()):
await self._deactivate_automation(aid)
return
# Determine which detection methods are actually needed
match_types_used: set = set()
needs_idle = False
needs_display_state = False
for a in automations:
if a.enabled:
for c in a.conditions:
if isinstance(c, ApplicationCondition):
match_types_used.add(c.match_type)
elif isinstance(c, SystemIdleCondition):
needs_idle = True
elif isinstance(c, DisplayStateCondition):
needs_display_state = True
needs_running = "running" in match_types_used
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
needs_fullscreen = "fullscreen" in match_types_used
# Single executor call for all platform detection
loop = asyncio.get_event_loop()
(running_procs, topmost_proc, topmost_fullscreen,
fullscreen_procs, idle_seconds, display_state) = (
await loop.run_in_executor(
None, self._detect_all_sync,
needs_running, needs_topmost, needs_fullscreen,
needs_idle, needs_display_state,
)
)
active_automation_ids = set()
for automation in automations:
should_be_active = (
automation.enabled
and (len(automation.conditions) == 0
or self._evaluate_conditions(
automation, running_procs, topmost_proc, topmost_fullscreen,
fullscreen_procs, idle_seconds, display_state))
)
is_active = automation.id in self._active_automations
if should_be_active and not is_active:
await self._activate_automation(automation)
active_automation_ids.add(automation.id)
elif should_be_active and is_active:
active_automation_ids.add(automation.id)
elif not should_be_active and is_active:
await self._deactivate_automation(automation.id)
# Deactivate automations that were removed from store while active
for aid in list(self._active_automations.keys()):
if aid not in active_automation_ids:
await self._deactivate_automation(aid)
def _evaluate_conditions(
self, automation: Automation, running_procs: Set[str],
topmost_proc: Optional[str], topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float], display_state: Optional[str],
) -> bool:
results = [
self._evaluate_condition(
c, running_procs, topmost_proc, topmost_fullscreen,
fullscreen_procs, idle_seconds, display_state,
)
for c in automation.conditions
]
if automation.condition_logic == "and":
return all(results)
return any(results) # "or" is default
def _evaluate_condition(
self, condition: Condition, running_procs: Set[str],
topmost_proc: Optional[str], topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float], display_state: Optional[str],
) -> bool:
if isinstance(condition, (AlwaysCondition, StartupCondition)):
return True
if isinstance(condition, ApplicationCondition):
return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
if isinstance(condition, TimeOfDayCondition):
return self._evaluate_time_of_day(condition)
if isinstance(condition, SystemIdleCondition):
return self._evaluate_idle(condition, idle_seconds)
if isinstance(condition, DisplayStateCondition):
return self._evaluate_display_state(condition, display_state)
if isinstance(condition, MQTTCondition):
return self._evaluate_mqtt(condition)
if isinstance(condition, WebhookCondition):
return self._webhook_states.get(condition.token, False)
return False
@staticmethod
def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool:
now = datetime.now()
current = now.hour * 60 + now.minute
parts_s = condition.start_time.split(":")
parts_e = condition.end_time.split(":")
start = int(parts_s[0]) * 60 + int(parts_s[1])
end = int(parts_e[0]) * 60 + int(parts_e[1])
if start <= end:
return start <= current <= end
# Overnight range (e.g. 22:00 → 06:00)
return current >= start or current <= end
@staticmethod
def _evaluate_idle(condition: SystemIdleCondition, idle_seconds: Optional[float]) -> bool:
if idle_seconds is None:
return False
is_idle = idle_seconds >= (condition.idle_minutes * 60)
return is_idle if condition.when_idle else not is_idle
@staticmethod
def _evaluate_display_state(condition: DisplayStateCondition, display_state: Optional[str]) -> bool:
if display_state is None:
return False
return display_state == condition.state
def _evaluate_mqtt(self, condition: MQTTCondition) -> bool:
if self._mqtt_service is None or not self._mqtt_service.is_connected:
return False
value = self._mqtt_service.get_last_value(condition.topic)
if value is None:
return False
if condition.match_mode == "exact":
return value == condition.payload
if condition.match_mode == "contains":
return condition.payload in value
if condition.match_mode == "regex":
try:
return bool(re.search(condition.payload, value))
except re.error:
return False
return False
def _evaluate_app_condition(
self,
condition: ApplicationCondition,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
) -> bool:
if not condition.apps:
return False
apps_lower = [a.lower() for a in condition.apps]
if condition.match_type == "fullscreen":
return any(app in fullscreen_procs for app in apps_lower)
if condition.match_type == "topmost_fullscreen":
if topmost_proc is None or not topmost_fullscreen:
return False
return any(app == topmost_proc for app in apps_lower)
if condition.match_type == "topmost":
if topmost_proc is None:
return False
return any(app == topmost_proc for app in apps_lower)
# Default: "running"
return any(app in running_procs for app in apps_lower)
async def _activate_automation(self, automation: Automation) -> None:
if not automation.scene_preset_id:
# No scene configured — just mark active (conditions matched but nothing to do)
self._active_automations[automation.id] = True
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "activated")
logger.info(f"Automation '{automation.name}' activated (no scene configured)")
return
if not self._scene_preset_store or not self._target_store or not self._device_store:
logger.warning(f"Automation '{automation.name}' matched but scene stores not available")
return
# Load the scene preset
try:
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
except ValueError:
logger.warning(f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found")
return
# For "revert" mode, capture current state before activating
if automation.deactivation_mode == "revert":
from wled_controller.core.scenes.scene_activator import capture_current_snapshot
targets = capture_current_snapshot(self._target_store, self._manager)
self._pre_activation_snapshots[automation.id] = ScenePreset(
id=f"_revert_{automation.id}",
name=f"Pre-activation snapshot for {automation.name}",
targets=targets,
)
# Apply the scene
from wled_controller.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state(
preset, self._target_store, self._manager,
)
self._active_automations[automation.id] = True
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "activated")
if errors:
logger.warning(f"Automation '{automation.name}' activated with errors: {errors}")
else:
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_automations.pop(automation_id, False)
if not was_active:
return
# Look up the automation for deactivation settings
try:
automation = self._store.get_automation(automation_id)
except ValueError:
automation = None
deactivation_mode = automation.deactivation_mode if automation else "none"
if deactivation_mode == "revert":
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
if snapshot and self._target_store:
from wled_controller.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state(
snapshot, self._target_store, self._manager,
)
if errors:
logger.warning(f"Automation {automation_id} revert errors: {errors}")
else:
logger.info(f"Automation {automation_id} deactivated (reverted to previous state)")
else:
logger.warning(f"Automation {automation_id}: no snapshot available for revert")
elif deactivation_mode == "fallback_scene":
fallback_id = automation.deactivation_scene_preset_id if automation else None
if fallback_id and self._scene_preset_store and self._target_store:
try:
fallback = self._scene_preset_store.get_preset(fallback_id)
from wled_controller.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state(
fallback, self._target_store, self._manager,
)
if errors:
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
else:
logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)")
except ValueError:
logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found")
else:
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
else:
# "none" mode — just clear active state
logger.info(f"Automation {automation_id} deactivated")
self._last_deactivated[automation_id] = datetime.now(timezone.utc)
self._fire_event(automation_id, "deactivated")
# Clean up any leftover snapshot
self._pre_activation_snapshots.pop(automation_id, None)
def _fire_event(self, automation_id: str, action: str) -> None:
try:
self._manager.fire_event({
"type": "automation_state_changed",
"automation_id": automation_id,
"action": action,
})
except Exception as e:
logger.error("Automation action failed: %s", e, exc_info=True)
# ===== Public query methods (used by API) =====
def get_automation_state(self, automation_id: str) -> dict:
"""Get runtime state of a single automation."""
is_active = automation_id in self._active_automations
return {
"is_active": is_active,
"last_activated_at": self._last_activated.get(automation_id),
"last_deactivated_at": self._last_deactivated.get(automation_id),
}
def get_all_automation_states(self) -> Dict[str, dict]:
"""Get runtime states of all automations."""
result = {}
for automation in self._store.get_all_automations():
result[automation.id] = self.get_automation_state(automation.id)
return result
async def trigger_evaluate(self) -> None:
"""Run a single evaluation cycle immediately (used after enabling an automation)."""
try:
await self._evaluate_all()
except Exception as e:
logger.error(f"Immediate automation evaluation error: {e}", exc_info=True)
async def set_webhook_state(self, token: str, active: bool) -> None:
"""Set webhook condition state and trigger immediate evaluation."""
self._webhook_states[token] = active
await self.trigger_evaluate()
def get_webhook_state(self, token: str) -> bool:
"""Read current webhook state (False if never called)."""
return self._webhook_states.get(token, False)
async def deactivate_if_active(self, automation_id: str) -> None:
"""Deactivate an automation immediately (used when disabling/deleting)."""
if automation_id in self._active_automations:
await self._deactivate_automation(automation_id)

View File

@@ -0,0 +1,410 @@
"""Platform-specific process and window detection.
Windows: uses wmi for process listing, ctypes for foreground window detection.
Non-Windows: graceful degradation (returns empty results).
"""
import asyncio
import ctypes
import ctypes.wintypes
import os
import sys
import threading
from typing import Optional, Set
from wled_controller.utils import get_logger
logger = get_logger(__name__)
_IS_WINDOWS = sys.platform == "win32"
class PlatformDetector:
"""Detect running processes and the foreground window's process."""
def __init__(self) -> None:
self._display_on: bool = True
self._display_listener_started = False
if _IS_WINDOWS:
t = threading.Thread(target=self._display_power_listener, daemon=True)
t.start()
# ---- Display power state (event-driven) ----
def _display_power_listener(self) -> None:
"""Background thread: hidden window that receives display power events."""
try:
user32 = ctypes.windll.user32
WNDPROC = ctypes.WINFUNCTYPE(
ctypes.c_long,
ctypes.wintypes.HWND,
ctypes.c_uint,
ctypes.wintypes.WPARAM,
ctypes.wintypes.LPARAM,
)
WM_POWERBROADCAST = 0x0218
PBT_POWERSETTINGCHANGE = 0x8013
class POWERBROADCAST_SETTING(ctypes.Structure):
_fields_ = [
("PowerSetting", ctypes.c_ubyte * 16), # GUID
("DataLength", ctypes.wintypes.DWORD),
("Data", ctypes.c_ubyte * 1),
]
# GUID_CONSOLE_DISPLAY_STATE = {6FE69556-704A-47A0-8F24-C28D936FDA47}
GUID_CONSOLE_DISPLAY_STATE = (ctypes.c_ubyte * 16)(
0x56, 0x95, 0xE6, 0x6F, 0x4A, 0x70, 0xA0, 0x47,
0x8F, 0x24, 0xC2, 0x8D, 0x93, 0x6F, 0xDA, 0x47,
)
def wnd_proc(hwnd, msg, wparam, lparam):
if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE:
try:
setting = ctypes.cast(
lparam, ctypes.POINTER(POWERBROADCAST_SETTING)
).contents
# Data: 0=off, 1=on, 2=dimmed (treat dimmed as on)
value = setting.Data[0]
self._display_on = value != 0
except Exception:
pass
return 0
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
wnd_proc_cb = WNDPROC(wnd_proc)
# Register window class
class WNDCLASSEXW(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.c_uint),
("style", ctypes.c_uint),
("lpfnWndProc", WNDPROC),
("cbClsExtra", ctypes.c_int),
("cbWndExtra", ctypes.c_int),
("hInstance", ctypes.wintypes.HINSTANCE),
("hIcon", ctypes.wintypes.HICON),
("hCursor", ctypes.wintypes.HANDLE),
("hbrBackground", ctypes.wintypes.HBRUSH),
("lpszMenuName", ctypes.wintypes.LPCWSTR),
("lpszClassName", ctypes.wintypes.LPCWSTR),
("hIconSm", ctypes.wintypes.HICON),
]
wc = WNDCLASSEXW()
wc.cbSize = ctypes.sizeof(WNDCLASSEXW)
wc.lpfnWndProc = wnd_proc_cb
wc.lpszClassName = "LedGrabDisplayMonitor"
wc.hInstance = ctypes.windll.kernel32.GetModuleHandleW(None)
atom = user32.RegisterClassExW(ctypes.byref(wc))
if not atom:
logger.warning("Failed to register display monitor window class")
return
HWND_MESSAGE = ctypes.wintypes.HWND(-3)
hwnd = user32.CreateWindowExW(
0, wc.lpszClassName, "LedGrab Display Monitor",
0, 0, 0, 0, 0, HWND_MESSAGE, None, wc.hInstance, None,
)
if not hwnd:
logger.warning("Failed to create display monitor hidden window")
return
# Register for display power notifications
user32.RegisterPowerSettingNotification(
hwnd, ctypes.byref(GUID_CONSOLE_DISPLAY_STATE), 0
)
self._display_listener_started = True
logger.debug("Display power listener started")
# Message pump
msg = ctypes.wintypes.MSG()
while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
user32.TranslateMessage(ctypes.byref(msg))
user32.DispatchMessageW(ctypes.byref(msg))
except Exception as e:
logger.error(f"Display power listener failed: {e}")
def _get_display_power_state_sync(self) -> Optional[str]:
"""Get display power state: 'on' or 'off'. Returns None if unavailable."""
if not _IS_WINDOWS:
return None
return "on" if self._display_on else "off"
# ---- System idle detection ----
def _get_idle_seconds_sync(self) -> Optional[float]:
"""Get system idle time in seconds (keyboard/mouse inactivity).
Returns None if detection is unavailable.
"""
if not _IS_WINDOWS:
return None
try:
class LASTINPUTINFO(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.c_uint),
("dwTime", ctypes.c_uint),
]
lii = LASTINPUTINFO()
lii.cbSize = ctypes.sizeof(LASTINPUTINFO)
if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(lii)):
return None
millis = ctypes.windll.kernel32.GetTickCount() - lii.dwTime
return millis / 1000.0
except Exception as e:
logger.error(f"Failed to get idle time: {e}")
return None
# ---- Process detection ----
def _get_running_processes_sync(self) -> Set[str]:
"""Get set of lowercase process names via Win32 EnumProcesses.
Uses PROCESS_QUERY_LIMITED_INFORMATION + QueryFullProcessImageNameW
which is ~300x faster than WMI (~8ms vs ~3s). System services
running under protected accounts are not visible, but all
user-facing applications are covered.
"""
if not _IS_WINDOWS:
return set()
try:
psapi = ctypes.windll.psapi
kernel32 = ctypes.windll.kernel32
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
# Enumerate all PIDs
pid_array = (ctypes.wintypes.DWORD * 2048)()
cb_needed = ctypes.wintypes.DWORD()
psapi.EnumProcesses(
ctypes.byref(pid_array), ctypes.sizeof(pid_array),
ctypes.byref(cb_needed),
)
n_pids = cb_needed.value // ctypes.sizeof(ctypes.wintypes.DWORD)
procs: Set[str] = set()
name_buf = ctypes.create_unicode_buffer(512)
for i in range(n_pids):
pid = pid_array[i]
if pid == 0:
continue
handle = kernel32.OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION, False, pid,
)
if not handle:
continue
try:
buf_size = ctypes.wintypes.DWORD(512)
if kernel32.QueryFullProcessImageNameW(
handle, 0, name_buf, ctypes.byref(buf_size),
):
procs.add(os.path.basename(name_buf.value).lower())
finally:
kernel32.CloseHandle(handle)
return procs
except Exception as e:
logger.error(f"Failed to enumerate processes: {e}")
return set()
def _get_topmost_process_sync(self) -> tuple:
"""Get (process_name, is_fullscreen) of the foreground window.
Returns (None, False) when detection fails.
Blocking — call via executor.
"""
if not _IS_WINDOWS:
return (None, False)
try:
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
psapi = ctypes.windll.psapi
hwnd = user32.GetForegroundWindow()
if not hwnd:
return (None, False)
pid = ctypes.wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if not pid.value:
return (None, False)
PROCESS_QUERY_INFORMATION = 0x0400
PROCESS_VM_READ = 0x0010
handle = kernel32.OpenProcess(
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid.value
)
if not handle:
return (None, False)
proc_name = None
try:
buf = ctypes.create_unicode_buffer(512)
psapi.GetModuleFileNameExW(handle, None, buf, 512)
full_path = buf.value
if full_path:
proc_name = os.path.basename(full_path).lower()
finally:
kernel32.CloseHandle(handle)
if proc_name is None:
return (None, False)
# Check if the foreground window covers its entire monitor
is_fullscreen = self._is_window_fullscreen(user32, hwnd)
return (proc_name, is_fullscreen)
except Exception as e:
logger.error(f"Failed to get foreground process: {e}")
return (None, False)
@staticmethod
def _is_window_fullscreen(user32, hwnd) -> bool:
"""Check whether *hwnd* covers its monitor completely."""
try:
# Get window rectangle
win_rect = ctypes.wintypes.RECT()
user32.GetWindowRect(hwnd, ctypes.byref(win_rect))
# Get the monitor this window is on
MONITOR_DEFAULTTONEAREST = 2
hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
if not hmon:
return False
# MONITORINFO struct
class MONITORINFO(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.wintypes.DWORD),
("rcMonitor", ctypes.wintypes.RECT),
("rcWork", ctypes.wintypes.RECT),
("dwFlags", ctypes.wintypes.DWORD),
]
mi = MONITORINFO()
mi.cbSize = ctypes.sizeof(MONITORINFO)
if not user32.GetMonitorInfoW(hmon, ctypes.byref(mi)):
return False
mr = mi.rcMonitor
return (
win_rect.left <= mr.left
and win_rect.top <= mr.top
and win_rect.right >= mr.right
and win_rect.bottom >= mr.bottom
)
except Exception:
return False
def _get_fullscreen_processes_sync(self) -> Set[str]:
"""Get set of lowercase process names that have a fullscreen window.
Enumerates all top-level windows and checks each for fullscreen.
Returns process names (lowercase) whose window covers an entire monitor.
"""
if not _IS_WINDOWS:
return set()
try:
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
psapi = ctypes.windll.psapi
PROCESS_QUERY_INFORMATION = 0x0400
PROCESS_VM_READ = 0x0010
fullscreen_procs: Set[str] = set()
# Callback receives (hwnd, lparam); return True to continue enumeration
WNDENUMPROC = ctypes.WINFUNCTYPE(
ctypes.wintypes.BOOL,
ctypes.wintypes.HWND,
ctypes.wintypes.LPARAM,
)
# Skip the desktop and shell windows (always cover the full monitor)
desktop_hwnd = user32.GetDesktopWindow()
shell_hwnd = user32.GetShellWindow()
# Get shell process PID to filter all explorer desktop windows
shell_pid = 0
if shell_hwnd:
_spid = ctypes.wintypes.DWORD()
user32.GetWindowThreadProcessId(shell_hwnd, ctypes.byref(_spid))
shell_pid = _spid.value
GWL_EXSTYLE = -20
WS_EX_TOOLWINDOW = 0x00000080
WS_EX_NOACTIVATE = 0x08000000
def _enum_callback(hwnd, _lparam):
# Skip invisible windows
if not user32.IsWindowVisible(hwnd):
return True
if hwnd == desktop_hwnd:
return True
# Skip tool/overlay/non-activatable windows (system UI, input hosts)
ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
if ex_style & (WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE):
return True
# Quick fullscreen check before expensive process name lookup
if not self._is_window_fullscreen(user32, hwnd):
return True
# Get PID; skip shell process windows (desktop, taskbar)
pid = ctypes.wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if not pid.value or pid.value == shell_pid:
return True
handle = kernel32.OpenProcess(
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid.value
)
if not handle:
return True
try:
buf = ctypes.create_unicode_buffer(512)
psapi.GetModuleFileNameExW(handle, None, buf, 512)
full_path = buf.value
if full_path:
fullscreen_procs.add(os.path.basename(full_path).lower())
finally:
kernel32.CloseHandle(handle)
return True
callback = WNDENUMPROC(_enum_callback)
user32.EnumWindows(callback, 0)
return fullscreen_procs
except Exception as e:
logger.error(f"Failed to enumerate fullscreen windows: {e}")
return set()
async def get_running_processes(self) -> Set[str]:
"""Get set of lowercase process names (async-safe)."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_running_processes_sync)
async def get_topmost_process(self) -> tuple:
"""Get (process_name, is_fullscreen) of the foreground window (async-safe)."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_topmost_process_sync)
async def get_fullscreen_processes(self) -> Set[str]:
"""Get set of process names that have a fullscreen window (async-safe)."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_fullscreen_processes_sync)

View File

@@ -0,0 +1,233 @@
"""Auto-backup engine — periodic background backups of all configuration stores."""
import asyncio
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from wled_controller import __version__
from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__)
DEFAULT_SETTINGS = {
"enabled": False,
"interval_hours": 24,
"max_backups": 10,
}
class AutoBackupEngine:
"""Creates periodic backups of all configuration stores."""
def __init__(
self,
settings_path: Path,
backup_dir: Path,
store_map: Dict[str, str],
storage_config: Any,
):
self._settings_path = Path(settings_path)
self._backup_dir = Path(backup_dir)
self._store_map = store_map
self._storage_config = storage_config
self._task: Optional[asyncio.Task] = None
self._last_backup_time: Optional[datetime] = None
self._settings = self._load_settings()
self._backup_dir.mkdir(parents=True, exist_ok=True)
# ─── Settings persistence ──────────────────────────────────
def _load_settings(self) -> dict:
if self._settings_path.exists():
try:
with open(self._settings_path, "r", encoding="utf-8") as f:
data = json.load(f)
return {**DEFAULT_SETTINGS, **data}
except Exception as e:
logger.warning(f"Failed to load auto-backup settings: {e}")
return dict(DEFAULT_SETTINGS)
def _save_settings(self) -> None:
atomic_write_json(self._settings_path, {
"enabled": self._settings["enabled"],
"interval_hours": self._settings["interval_hours"],
"max_backups": self._settings["max_backups"],
})
# ─── Lifecycle ─────────────────────────────────────────────
async def start(self) -> None:
if self._settings["enabled"]:
self._start_loop()
logger.info(
f"Auto-backup engine started (every {self._settings['interval_hours']}h, "
f"max {self._settings['max_backups']})"
)
else:
logger.info("Auto-backup engine initialized (disabled)")
async def stop(self) -> None:
self._cancel_loop()
logger.info("Auto-backup engine stopped")
def _start_loop(self) -> None:
self._cancel_loop()
self._task = asyncio.create_task(self._backup_loop())
def _cancel_loop(self) -> None:
if self._task is not None:
self._task.cancel()
self._task = None
async def _backup_loop(self) -> None:
try:
# Perform first backup immediately on start
await self._perform_backup()
self._prune_old_backups()
interval_secs = self._settings["interval_hours"] * 3600
while True:
await asyncio.sleep(interval_secs)
try:
await self._perform_backup()
self._prune_old_backups()
except Exception as e:
logger.error(f"Auto-backup failed: {e}", exc_info=True)
except asyncio.CancelledError:
pass
# ─── Backup operations ─────────────────────────────────────
async def _perform_backup(self) -> None:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._perform_backup_sync)
def _perform_backup_sync(self) -> None:
stores = {}
for store_key, config_attr in self._store_map.items():
file_path = Path(getattr(self._storage_config, config_attr))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
stores[store_key] = json.load(f)
else:
stores[store_key] = {}
now = datetime.now(timezone.utc)
backup = {
"meta": {
"format": "ledgrab-backup",
"format_version": 1,
"app_version": __version__,
"created_at": now.isoformat(),
"store_count": len(stores),
"auto_backup": True,
},
"stores": stores,
}
timestamp = now.strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-autobackup-{timestamp}.json"
file_path = self._backup_dir / filename
content = json.dumps(backup, indent=2, ensure_ascii=False)
file_path.write_text(content, encoding="utf-8")
self._last_backup_time = now
logger.info(f"Auto-backup created: {filename}")
def _prune_old_backups(self) -> None:
max_backups = self._settings["max_backups"]
files = sorted(self._backup_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
excess = len(files) - max_backups
if excess > 0:
for f in files[:excess]:
try:
f.unlink()
logger.info(f"Pruned old backup: {f.name}")
except Exception as e:
logger.warning(f"Failed to prune {f.name}: {e}")
# ─── Public API ────────────────────────────────────────────
def get_settings(self) -> dict:
next_backup = None
if self._settings["enabled"] and self._last_backup_time:
from datetime import timedelta
next_backup = (
self._last_backup_time + timedelta(hours=self._settings["interval_hours"])
).isoformat()
return {
"enabled": self._settings["enabled"],
"interval_hours": self._settings["interval_hours"],
"max_backups": self._settings["max_backups"],
"last_backup_time": self._last_backup_time.isoformat() if self._last_backup_time else None,
"next_backup_time": next_backup,
}
async def update_settings(self, enabled: bool, interval_hours: float, max_backups: int) -> dict:
self._settings["enabled"] = enabled
self._settings["interval_hours"] = interval_hours
self._settings["max_backups"] = max_backups
self._save_settings()
# Restart or stop the loop
if enabled:
self._start_loop()
logger.info(
f"Auto-backup enabled (every {interval_hours}h, max {max_backups})"
)
else:
self._cancel_loop()
logger.info("Auto-backup disabled")
# Prune if max_backups was reduced
self._prune_old_backups()
return self.get_settings()
def list_backups(self) -> List[dict]:
backups = []
for f in sorted(self._backup_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
stat = f.stat()
backups.append({
"filename": f.name,
"size_bytes": stat.st_size,
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
})
return backups
def _safe_backup_path(self, filename: str) -> Path:
"""Resolve a backup filename to an absolute path, guarding against path traversal."""
if not filename or os.sep in filename or "/" in filename or ".." in filename:
raise ValueError("Invalid filename")
target = (self._backup_dir / filename).resolve()
# Ensure resolved path is still inside the backup directory
if not target.is_relative_to(self._backup_dir.resolve()):
raise ValueError("Invalid filename")
return target
async def trigger_backup(self) -> dict:
"""Manually trigger a backup and prune old ones. Returns the created backup info."""
await self._perform_backup()
self._prune_old_backups()
# Return the most recent backup entry
backups = self.list_backups()
return backups[0] if backups else {}
def delete_backup(self, filename: str) -> None:
target = self._safe_backup_path(filename)
if not target.exists():
raise FileNotFoundError(f"Backup not found: {filename}")
target.unlink()
logger.info(f"Deleted backup: {filename}")
def get_backup_path(self, filename: str) -> Path:
target = self._safe_backup_path(filename)
if not target.exists():
raise FileNotFoundError(f"Backup not found: {filename}")
return target

View File

@@ -1,13 +1,12 @@
"""Calibration system for mapping screen pixels to LED positions."""
from dataclasses import dataclass, field
from typing import Dict, List, Literal, Tuple
from typing import Dict, List, Literal, Optional, Set, Tuple
import numpy as np
from wled_controller.core.capture.screen_capture import (
BorderPixels,
get_edge_segments,
calculate_average_color,
calculate_median_color,
calculate_dominant_color,
@@ -52,6 +51,19 @@ class CalibrationSegment:
reverse: bool = False
@dataclass
class CalibrationLine:
"""One LED line in advanced calibration — references one picture source edge."""
picture_source_id: str
edge: Literal["top", "right", "bottom", "left"]
led_count: int
span_start: float = 0.0
span_end: float = 1.0
reverse: bool = False
border_width: int = 10
@dataclass
class CalibrationConfig:
"""Complete calibration configuration.
@@ -60,8 +72,14 @@ class CalibrationConfig:
are derived at runtime via the `segments` property.
"""
layout: Literal["clockwise", "counterclockwise"]
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
# Mode: "simple" = 4-edge model (backward compat), "advanced" = generic line list
mode: Literal["simple", "advanced"] = "simple"
# Advanced mode: ordered list of CalibrationLine objects (ignored in simple mode)
lines: List[CalibrationLine] = field(default_factory=list)
# Simple mode fields (also used as defaults for CalibrationConfig constructor)
layout: Literal["clockwise", "counterclockwise"] = "clockwise"
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = "bottom_left"
offset: int = 0
leds_top: int = 0
leds_right: int = 0
@@ -110,10 +128,15 @@ class CalibrationConfig:
return segments
def __post_init__(self):
self._cached_segments: List[CalibrationSegment] | None = None
@property
def segments(self) -> List[CalibrationSegment]:
"""Get derived segment list."""
return self.build_segments()
"""Get derived segment list (cached after first call)."""
if self._cached_segments is None:
self._cached_segments = self.build_segments()
return self._cached_segments
def get_edge_span(self, edge: str) -> tuple[float, float]:
"""Get span (start, end) for a given edge."""
@@ -131,6 +154,20 @@ class CalibrationConfig:
Raises:
ValueError: If configuration is invalid
"""
if self.mode == "advanced":
if not self.lines:
raise ValueError("Advanced calibration must have at least one line")
for i, line in enumerate(self.lines):
if line.led_count <= 0:
raise ValueError(f"Line {i}: LED count must be positive, got {line.led_count}")
if not (0.0 <= line.span_start <= 1.0) or not (0.0 <= line.span_end <= 1.0):
raise ValueError(f"Line {i}: span must be in [0.0, 1.0]")
if line.span_end <= line.span_start:
raise ValueError(f"Line {i}: span_end must be greater than span_start")
if line.border_width < 1:
raise ValueError(f"Line {i}: border_width must be at least 1")
return True
total = self.get_total_leds()
if total <= 0:
raise ValueError("Calibration must have at least one LED")
@@ -150,9 +187,26 @@ class CalibrationConfig:
return True
def get_total_leds(self) -> int:
"""Get total number of LEDs across all edges."""
"""Get total number of LEDs across all edges/lines."""
if self.mode == "advanced":
return sum(line.led_count for line in self.lines)
return self.leds_top + self.leds_right + self.leds_bottom + self.leds_left
def get_required_picture_source_ids(self) -> List[str]:
"""Get deduplicated list of picture source IDs referenced by lines.
Returns empty list for simple mode (the stream provides the source).
"""
if self.mode != "advanced":
return []
seen: Set[str] = set()
result: List[str] = []
for line in self.lines:
if line.picture_source_id not in seen:
seen.add(line.picture_source_id)
result.append(line.picture_source_id)
return result
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
"""Get segment configuration for a specific edge."""
for seg in self.segments:
@@ -191,8 +245,42 @@ class PixelMapper:
else:
raise ValueError(f"Invalid interpolation mode: {interpolation_mode}")
# Pre-allocate LED output buffer (reused every call)
total_leds = calibration.get_total_leds()
self._total_leds = total_leds
self._led_buf = np.zeros((total_leds, 3), dtype=np.uint8)
self._use_fast_avg = interpolation_mode == "average"
# Pre-compute offset-adjusted index arrays per segment (avoids np.roll)
offset = calibration.offset % total_leds if total_leds > 0 else 0
self._segment_indices: List[np.ndarray] = []
for segment in calibration.segments:
indices = np.arange(segment.led_start, segment.led_start + segment.led_count)
if segment.reverse:
indices = indices[::-1]
if offset > 0:
indices = (indices + offset) % total_leds
self._segment_indices.append(indices)
# Pre-compute Phase 3 skip arrays (static geometry)
skip_start = calibration.skip_leds_start
skip_end = calibration.skip_leds_end
self._skip_start = skip_start
self._skip_end = skip_end
self._active_count = max(0, total_leds - skip_start - skip_end)
if 0 < self._active_count < total_leds:
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
self._skip_x = np.arange(total_leds, dtype=np.float64)
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
else:
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
# Per-edge average computation cache (lazy-initialized on first frame)
self._edge_cache: Dict[str, tuple] = {}
logger.info(
f"Initialized pixel mapper with {self.calibration.get_total_leds()} LEDs "
f"Initialized pixel mapper with {total_leds} LEDs "
f"using {interpolation_mode} interpolation"
)
@@ -219,38 +307,73 @@ class PixelMapper:
edge_pixels = edge_pixels[s:e, :, :]
return edge_pixels
def _map_edge_fallback(
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
) -> np.ndarray:
"""Per-LED color mapping for median/dominant modes. Returns (led_count, 3) uint8."""
if edge_name in ("top", "bottom"):
edge_len = edge_pixels.shape[1]
else:
edge_len = edge_pixels.shape[0]
step = edge_len / led_count
result = np.empty((led_count, 3), dtype=np.uint8)
for i in range(led_count):
start = int(i * step)
end = max(start + 1, int((i + 1) * step))
end = min(end, edge_len)
if edge_name in ("top", "bottom"):
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
color = self._calc_color(segment)
result[i] = color
return result
def _map_edge_average(
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
) -> np.ndarray:
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8."""
# Reduce border dimension → 1D array of shape (edge_length, 3)
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8.
Uses pre-allocated cumsum/mean buffers (lazy-initialized per edge) to
avoid per-frame allocations that cause GC-induced timing spikes.
"""
if edge_name in ("top", "bottom"):
edge_1d = edge_pixels.mean(axis=0) # mean across border_width
axis = 0
edge_len = edge_pixels.shape[1]
else:
edge_1d = edge_pixels.mean(axis=1) # mean across border_width
axis = 1
edge_len = edge_pixels.shape[0]
edge_len = edge_1d.shape[0]
# Lazy-init / resize per-edge scratch buffers
cache = self._edge_cache.get(edge_name)
if cache is None or cache[0] != edge_len:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
np.minimum(boundaries, edge_len, out=boundaries)
starts = boundaries[:-1]
ends = boundaries[1:]
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
self._edge_cache[edge_name] = cache
# Compute segment boundaries (matching get_edge_segments float stepping)
step = edge_len / led_count
boundaries = np.empty(led_count + 1, dtype=np.int64)
for i in range(led_count + 1):
boundaries[i] = int(i * step)
# Ensure each segment has at least 1 pixel
for i in range(led_count):
if boundaries[i + 1] <= boundaries[i]:
boundaries[i + 1] = boundaries[i] + 1
# Clamp all boundaries to edge_len (not just the last one)
boundaries = np.minimum(boundaries, edge_len)
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
# Cumulative sum for O(1) range means — no per-LED Python numpy calls
cumsum = np.zeros((edge_len + 1, 3), dtype=np.float64)
cumsum[1:] = np.cumsum(edge_1d.astype(np.float64), axis=0)
# Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
starts = boundaries[:-1]
ends = boundaries[1:]
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
segment_sums = cumsum[ends] - cumsum[starts]
# Cumsum into pre-allocated buffer
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
def map_border_to_leds(
@@ -259,6 +382,9 @@ class PixelMapper:
) -> np.ndarray:
"""Map screen border pixels to LED colors.
Uses pre-allocated buffers and pre-computed index arrays to avoid
per-frame allocations (np.zeros, np.roll, np.arange, np.linspace).
Args:
border_pixels: Extracted border pixels from screen
@@ -268,98 +394,38 @@ class PixelMapper:
Raises:
ValueError: If border pixels don't match calibration
"""
total_leds = self.calibration.get_total_leds()
skip_start = self.calibration.skip_leds_start
skip_end = self.calibration.skip_leds_end
active_count = max(0, total_leds - skip_start - skip_end)
use_fast_avg = self.interpolation_mode == "average"
led_array = self._led_buf
led_array[:] = 0
# Phase 1: Map full perimeter to total_leds positions
if use_fast_avg:
led_array = np.zeros((total_leds, 3), dtype=np.uint8)
else:
led_colors = [(0, 0, 0)] * total_leds
# Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll)
for i, segment in enumerate(self.calibration.segments):
edge_pixels = self._get_edge_pixels(border_pixels, segment.edge)
for edge_name in ["top", "right", "bottom", "left"]:
segment = self.calibration.get_segment_for_edge(edge_name)
if not segment:
continue
edge_pixels = self._get_edge_pixels(border_pixels, edge_name)
if use_fast_avg:
# Vectorized: compute all LED colors for this edge at once
if self._use_fast_avg:
colors = self._map_edge_average(
edge_pixels, edge_name, segment.led_count
edge_pixels, segment.edge, segment.led_count
)
led_indices = np.arange(segment.led_start, segment.led_start + segment.led_count)
if segment.reverse:
led_indices = led_indices[::-1]
led_array[led_indices] = colors
else:
# Per-LED fallback for median/dominant modes
try:
pixel_segments = get_edge_segments(
edge_pixels, segment.led_count, edge_name
)
except ValueError as e:
logger.error(f"Failed to segment {edge_name} edge: {e}")
raise
colors = self._map_edge_fallback(
edge_pixels, segment.edge, segment.led_count
)
led_indices = list(range(segment.led_start, segment.led_start + segment.led_count))
if segment.reverse:
led_indices = list(reversed(led_indices))
led_array[self._segment_indices[i]] = colors
for led_idx, pixel_segment in zip(led_indices, pixel_segments):
color = self._calc_color(pixel_segment)
led_colors[led_idx] = color
# Phase 3: Physical skip — resample full perimeter to active LEDs
if self._skip_src is not None:
np.copyto(self._skip_float, led_array, casting='unsafe')
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start:end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
# Phase 2: Offset rotation
offset = self.calibration.offset % total_leds if total_leds > 0 else 0
if use_fast_avg:
if offset > 0:
led_array = np.roll(led_array, offset, axis=0)
# Phase 3: Physical skip — resample full perimeter to active LEDs
# Maps the entire screen to active_count positions so each active LED
# covers a proportionally larger slice of the perimeter.
if active_count > 0 and active_count < total_leds:
src = np.linspace(0, total_leds - 1, active_count)
full_f = led_array.astype(np.float64)
x = np.arange(total_leds, dtype=np.float64)
resampled = np.empty((active_count, 3), dtype=np.uint8)
for ch in range(3):
resampled[:, ch] = np.round(
np.interp(src, x, full_f[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = total_leds - skip_end
led_array[skip_start:end_idx] = resampled
elif active_count <= 0:
led_array[:] = 0
return led_array
else:
if offset > 0:
led_colors = led_colors[total_leds - offset:] + led_colors[:total_leds - offset]
# Phase 3: Physical skip — resample full perimeter to active LEDs
if active_count > 0 and active_count < total_leds:
arr = np.array(led_colors, dtype=np.float64)
src = np.linspace(0, total_leds - 1, active_count)
x = np.arange(total_leds, dtype=np.float64)
resampled = np.empty((active_count, 3), dtype=np.float64)
for ch in range(3):
resampled[:, ch] = np.interp(src, x, arr[:, ch])
led_colors = [(0, 0, 0)] * total_leds
for i in range(active_count):
r, g, b = resampled[i]
led_colors[skip_start + i] = (int(round(r)), int(round(g)), int(round(b)))
elif active_count <= 0:
led_colors = [(0, 0, 0)] * total_leds
return np.array(led_colors, dtype=np.uint8)
return led_array
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
"""Generate test pattern to light up specific edge.
@@ -392,6 +458,216 @@ class PixelMapper:
return led_colors
class AdvancedPixelMapper:
"""Maps multi-source screen pixels to LED colors for advanced calibration.
Each CalibrationLine references a picture source and an edge, with its own
span and border_width. Frames from multiple sources are passed in as a dict.
"""
def __init__(
self,
calibration: CalibrationConfig,
interpolation_mode: Literal["average", "median", "dominant"] = "average",
):
self.calibration = calibration
self.interpolation_mode = interpolation_mode
calibration.validate()
if interpolation_mode == "average":
self._calc_color = calculate_average_color
elif interpolation_mode == "median":
self._calc_color = calculate_median_color
elif interpolation_mode == "dominant":
self._calc_color = calculate_dominant_color
else:
raise ValueError(f"Invalid interpolation mode: {interpolation_mode}")
total_leds = calibration.get_total_leds()
self._total_leds = total_leds
self._led_buf = np.zeros((total_leds, 3), dtype=np.uint8)
self._use_fast_avg = interpolation_mode == "average"
# Build segment-like metadata from lines (led_start for each line)
offset = calibration.offset % total_leds if total_leds > 0 else 0
self._line_indices: List[np.ndarray] = []
led_start = 0
for line in calibration.lines:
indices = np.arange(led_start, led_start + line.led_count)
if line.reverse:
indices = indices[::-1]
if offset > 0:
indices = (indices + offset) % total_leds
self._line_indices.append(indices)
led_start += line.led_count
# Skip arrays (same logic as PixelMapper)
skip_start = calibration.skip_leds_start
skip_end = calibration.skip_leds_end
self._skip_start = skip_start
self._skip_end = skip_end
self._active_count = max(0, total_leds - skip_start - skip_end)
if 0 < self._active_count < total_leds:
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
self._skip_x = np.arange(total_leds, dtype=np.float64)
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
else:
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
# Per-line edge cache (keyed by line index to avoid collision)
self._edge_cache: Dict[int, tuple] = {}
logger.info(
f"Initialized advanced pixel mapper with {total_leds} LEDs, "
f"{len(calibration.lines)} lines, {interpolation_mode} interpolation"
)
@staticmethod
def _extract_edge_strip(
frame: np.ndarray, edge: str, border_width: int,
span_start: float, span_end: float,
) -> np.ndarray:
"""Extract a border strip from a frame for the given edge and span."""
h, w = frame.shape[:2]
bw = min(border_width, h // 4, w // 4)
if edge == "top":
strip = frame[:bw, :, :]
elif edge == "bottom":
strip = frame[-bw:, :, :]
elif edge == "right":
strip = frame[:, -bw:, :]
else: # left
strip = frame[:, :bw, :]
# Apply span
if span_start > 0.0 or span_end < 1.0:
if edge in ("top", "bottom"):
total_w = strip.shape[1]
s, e = int(span_start * total_w), int(span_end * total_w)
strip = strip[:, s:e, :]
else:
total_h = strip.shape[0]
s, e = int(span_start * total_h), int(span_end * total_h)
strip = strip[s:e, :, :]
return strip
def _map_edge_average(
self, edge_pixels: np.ndarray, edge_name: str, led_count: int,
cache_key: int,
) -> np.ndarray:
"""Vectorized average-color mapping (same algo as PixelMapper)."""
if edge_name in ("top", "bottom"):
axis = 0
edge_len = edge_pixels.shape[1]
else:
axis = 1
edge_len = edge_pixels.shape[0]
cache = self._edge_cache.get(cache_key)
if cache is None or cache[0] != edge_len:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
np.minimum(boundaries, edge_len, out=boundaries)
starts = boundaries[:-1]
ends = boundaries[1:]
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
self._edge_cache[cache_key] = cache
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
def _map_edge_fallback(
self, edge_pixels: np.ndarray, edge_name: str, led_count: int,
) -> np.ndarray:
"""Per-LED color mapping for median/dominant modes."""
if edge_name in ("top", "bottom"):
edge_len = edge_pixels.shape[1]
else:
edge_len = edge_pixels.shape[0]
step = edge_len / led_count
result = np.empty((led_count, 3), dtype=np.uint8)
for i in range(led_count):
start = int(i * step)
end = max(start + 1, int((i + 1) * step))
end = min(end, edge_len)
if edge_name in ("top", "bottom"):
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
result[i] = self._calc_color(segment)
return result
def map_lines_to_leds(self, frames: Dict[str, np.ndarray]) -> np.ndarray:
"""Map multi-source frames to LED colors using calibration lines.
Args:
frames: Dict mapping picture_source_id to captured frame (H, W, 3) uint8
Returns:
numpy array of shape (total_leds, 3), dtype uint8
"""
led_array = self._led_buf
led_array[:] = 0
for i, line in enumerate(self.calibration.lines):
frame = frames.get(line.picture_source_id)
if frame is None:
continue
edge_pixels = self._extract_edge_strip(
frame, line.edge, line.border_width,
line.span_start, line.span_end,
)
if self._use_fast_avg:
colors = self._map_edge_average(
edge_pixels, line.edge, line.led_count, cache_key=i,
)
else:
colors = self._map_edge_fallback(
edge_pixels, line.edge, line.led_count,
)
led_array[self._line_indices[i]] = colors
# Phase 3: Physical skip (same as PixelMapper)
if self._skip_src is not None:
np.copyto(self._skip_float, led_array, casting='unsafe')
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start:end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
return led_array
def create_pixel_mapper(
calibration: CalibrationConfig,
interpolation_mode: str = "average",
):
"""Factory: create the right mapper for the calibration mode."""
if calibration.mode == "advanced":
return AdvancedPixelMapper(calibration, interpolation_mode)
return PixelMapper(calibration, interpolation_mode)
def create_default_calibration(led_count: int) -> CalibrationConfig:
"""Create a default calibration for a rectangular screen.
@@ -448,7 +724,35 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
ValueError: If data is invalid
"""
try:
mode = data.get("mode", "simple")
if mode == "advanced":
lines_data = data.get("lines", [])
lines = [
CalibrationLine(
picture_source_id=ld["picture_source_id"],
edge=ld["edge"],
led_count=ld["led_count"],
span_start=ld.get("span_start", 0.0),
span_end=ld.get("span_end", 1.0),
reverse=ld.get("reverse", False),
border_width=ld.get("border_width", 10),
)
for ld in lines_data
]
config = CalibrationConfig(
mode="advanced",
lines=lines,
offset=data.get("offset", 0),
skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0),
)
config.validate()
return config
# Simple mode (backward compat — missing "mode" key defaults here)
config = CalibrationConfig(
mode="simple",
layout=data["layout"],
start_position=data["start_position"],
offset=data.get("offset", 0),
@@ -489,7 +793,33 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
Returns:
Dictionary representation
"""
if config.mode == "advanced":
result: dict = {
"mode": "advanced",
"lines": [
{
"picture_source_id": line.picture_source_id,
"edge": line.edge,
"led_count": line.led_count,
"span_start": line.span_start,
"span_end": line.span_end,
"reverse": line.reverse,
"border_width": line.border_width,
}
for line in config.lines
],
}
if config.offset != 0:
result["offset"] = config.offset
if config.skip_leds_start > 0:
result["skip_leds_start"] = config.skip_leds_start
if config.skip_leds_end > 0:
result["skip_leds_end"] = config.skip_leds_end
return result
# Simple mode
result = {
"mode": "simple",
"layout": config.layout,
"start_position": config.start_position,
"offset": config.offset,

View File

@@ -1,68 +1,27 @@
"""Pixel processing utilities for color correction and manipulation."""
from typing import List, Tuple
from typing import List, Tuple, Union
import numpy as np
from wled_controller.utils import get_logger
logger = get_logger(__name__)
ColorList = Union[List[Tuple[int, int, int]], np.ndarray]
def apply_color_correction(
colors: List[Tuple[int, int, int]],
gamma: float = 2.2,
saturation: float = 1.0,
brightness: float = 1.0,
) -> List[Tuple[int, int, int]]:
"""Apply color correction to LED colors.
Args:
colors: List of (R, G, B) tuples
gamma: Gamma correction factor (default 2.2)
saturation: Saturation multiplier (0.0-2.0)
brightness: Brightness multiplier (0.0-1.0)
Returns:
Corrected list of (R, G, B) tuples
"""
if not colors:
def _as_array(colors: ColorList) -> np.ndarray:
"""Convert list-of-tuples to (N,3) uint8 array, or pass through if already ndarray."""
if isinstance(colors, np.ndarray):
return colors
# Convert to numpy array for efficient processing
colors_array = np.array(colors, dtype=np.float32) / 255.0
# Apply brightness
if brightness != 1.0:
colors_array *= brightness
# Apply saturation
if saturation != 1.0:
# Convert RGB to HSV-like saturation adjustment
# Calculate luminance (grayscale)
luminance = np.dot(colors_array, [0.299, 0.587, 0.114])
luminance = luminance[:, np.newaxis] # Reshape for broadcasting
# Blend between grayscale and color based on saturation
colors_array = luminance + (colors_array - luminance) * saturation
# Apply gamma correction
if gamma != 1.0:
colors_array = np.power(colors_array, 1.0 / gamma)
# Clamp to valid range and convert back to integers
colors_array = np.clip(colors_array * 255.0, 0, 255).astype(np.uint8)
# Convert back to list of tuples
corrected_colors = [tuple(color) for color in colors_array]
return corrected_colors
return np.array(colors, dtype=np.uint8)
def smooth_colors(
current_colors: List[Tuple[int, int, int]],
previous_colors: List[Tuple[int, int, int]],
current_colors: ColorList,
previous_colors: ColorList,
smoothing_factor: float = 0.5,
) -> List[Tuple[int, int, int]]:
) -> np.ndarray:
"""Smooth color transitions between frames.
Args:
@@ -71,96 +30,71 @@ def smooth_colors(
smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing)
Returns:
Smoothed colors
Smoothed colors as (N,3) uint8 ndarray
"""
if not current_colors or not previous_colors:
return current_colors
if not len(current_colors) or not len(previous_colors):
return _as_array(current_colors)
if len(current_colors) != len(previous_colors):
logger.warning(
f"Color count mismatch: current={len(current_colors)}, "
f"previous={len(previous_colors)}. Skipping smoothing."
)
return current_colors
return _as_array(current_colors)
if smoothing_factor <= 0:
return current_colors
return _as_array(current_colors)
if smoothing_factor >= 1:
return previous_colors
return _as_array(previous_colors)
# Convert to numpy arrays
current = np.array(current_colors, dtype=np.float32)
previous = np.array(previous_colors, dtype=np.float32)
# Blend between current and previous
current = np.asarray(current_colors, dtype=np.float32)
previous = np.asarray(previous_colors, dtype=np.float32)
smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor
# Convert back to integers
smoothed = np.clip(smoothed, 0, 255).astype(np.uint8)
return [tuple(color) for color in smoothed]
return np.clip(smoothed, 0, 255).astype(np.uint8)
def adjust_brightness_global(
colors: List[Tuple[int, int, int]],
colors: ColorList,
target_brightness: int,
) -> List[Tuple[int, int, int]]:
) -> np.ndarray:
"""Adjust colors to achieve target global brightness.
Args:
colors: List of (R, G, B) tuples
colors: List of (R, G, B) tuples or (N,3) ndarray
target_brightness: Target brightness (0-255)
Returns:
Adjusted colors
Adjusted colors as (N,3) uint8 ndarray
"""
if not colors or target_brightness == 255:
return colors
arr = _as_array(colors)
if not len(arr) or target_brightness == 255:
return arr
# Calculate scaling factor
scale = target_brightness / 255.0
# Scale all colors
scaled = [
(
int(r * scale),
int(g * scale),
int(b * scale),
)
for r, g, b in colors
]
return scaled
return (arr.astype(np.float32) * scale).astype(np.uint8)
def limit_brightness(
colors: List[Tuple[int, int, int]],
colors: ColorList,
max_brightness: int = 255,
) -> List[Tuple[int, int, int]]:
) -> np.ndarray:
"""Limit maximum brightness of any color channel.
Args:
colors: List of (R, G, B) tuples
colors: List of (R, G, B) tuples or (N,3) ndarray
max_brightness: Maximum allowed brightness (0-255)
Returns:
Limited colors
Limited colors as (N,3) uint8 ndarray
"""
if not colors or max_brightness == 255:
return colors
arr = _as_array(colors)
if not len(arr) or max_brightness == 255:
return arr
limited = []
for r, g, b in colors:
# Find max channel value
max_val = max(r, g, b)
if max_val > max_brightness:
# Scale down proportionally
scale = max_brightness / max_val
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
limited.append((r, g, b))
return limited
arr_f = arr.astype(np.float32)
max_vals = np.max(arr_f, axis=1)
need_scale = max_vals > max_brightness
if need_scale.any():
scales = np.where(need_scale, max_brightness / np.maximum(max_vals, 1.0), 1.0)
arr_f *= scales[:, np.newaxis]
return arr_f.astype(np.uint8)

View File

@@ -5,7 +5,6 @@ from typing import Dict, List
import mss
import numpy as np
from PIL import Image
from wled_controller.utils import get_logger, get_monitor_names, get_monitor_refresh_rates
@@ -122,9 +121,10 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
# Capture screenshot
screenshot = sct.grab(monitor)
# Convert to numpy array (RGB)
img = Image.frombytes("RGB", screenshot.size, screenshot.rgb)
img_array = np.array(img)
# Direct bytes→numpy (skips PIL intermediate object)
img_array = np.frombuffer(
screenshot.rgb, dtype=np.uint8,
).reshape(screenshot.height, screenshot.width, 3)
logger.debug(
f"Captured display {display_index}: {monitor['width']}x{monitor['height']}"

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