Compare commits

..

103 Commits

Author SHA1 Message Date
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
238 changed files with 33403 additions and 8092 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/

150
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,7 +96,13 @@
**Whenever server-side Python code is modified** (any file under `/server/src/` **excluding** `/server/src/wled_controller/static/`), **automatically restart the server** so the changes take effect immediately. Do NOT wait for the user to ask for a restart.
**No restart needed for frontend-only changes.** Files under `/server/src/wled_controller/static/` (HTML, JS, CSS, JSON locale files) are served directly by FastAPI's static file handler — changes take effect on the next browser page refresh without restarting the server.
**No restart needed for frontend-only changes** — but you **MUST rebuild the bundle**. The browser loads the esbuild bundle (`static/dist/app.bundle.js`, `static/dist/app.bundle.css`), NOT the source files. After ANY change to frontend files (JS, CSS under `/server/src/wled_controller/static/`), run:
```bash
cd server && npm run build
```
Without this step, changes will NOT take effect. No server restart is needed — just rebuild and refresh the browser.
### Restart procedure
@@ -97,118 +129,22 @@ This is a monorepo containing:
For detailed server-specific instructions (restart policy, testing, etc.), see:
- `server/CLAUDE.md`
## UI Conventions for Dialogs
## Frontend (HTML, CSS, JS, i18n)
### Hints
For all frontend conventions (CSS variables, UI patterns, modals, localization, tutorials), see [`contexts/frontend.md`](contexts/frontend.md).
Every form field in a modal should have a hint. Use the `.label-row` wrapper with a `?` toggle button:
## Task Tracking via TODO.md
```html
<div class="form-group">
<div class="label-row">
<label for="my-field" data-i18n="my.label">Label:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="my.label.hint">Hint text</small>
<input type="text" id="my-field">
</div>
```
Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the TodoWrite tool** — all progress tracking goes through `TODO.md`.
Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffix on the label key.
- **When starting a multi-step task**: add sub-steps as `- [ ]` items under the relevant section
- **When completing a step**: mark it `- [x]` immediately — don't batch updates
- **When a task is fully done**: mark it `- [x]` and leave it for the user to clean up
- **When the user requests a new feature/fix**: add it to the appropriate section with a priority tag
### Select dropdowns
## Documentation Lookup
Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.
### Enhanced selectors (IconSelect & EntitySelect)
Plain `<select>` dropdowns should be enhanced with visual selectors depending on the data type:
- **Predefined options** (source types, effect types, palettes, waveforms, viz modes) → use `IconSelect` from `js/core/icon-select.js`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.js` for examples.
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.js`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.js` or `_lineSourceEntitySelect` in `advanced-calibration.js` for examples.
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
### Modal dirty check (discard unsaved changes)
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.js`:
1. **Subclass Modal** with `snapshotValues()` returning an object of all tracked field values:
```javascript
class MyEditorModal extends Modal {
constructor() { super('my-modal-id'); }
snapshotValues() {
return {
name: document.getElementById('my-name').value,
// ... all form fields
};
}
onForceClose() {
// Optional: cleanup (reset flags, clear state, etc.)
}
}
const myModal = new MyEditorModal();
```
2. **Call `modal.snapshot()`** after the form is fully populated (after `modal.open()`).
3. **Close/cancel button** calls `await modal.close()` — triggers dirty check + confirmation.
4. **Save function** calls `modal.forceClose()` after successful save — skips dirty check.
5. For complex/dynamic state (filter lists, schedule rows, conditions), serialize to JSON string in `snapshotValues()`.
The base class handles: `isDirty()` comparison, confirmation dialog, backdrop click, ESC key, focus trapping, and body scroll lock.
### Card appearance
When creating or modifying entity cards (devices, targets, CSS sources, streams, audio/value sources, templates), **always reference existing cards** of the same or similar type for visual consistency. Cards should have:
- Clone (📋) and Edit (✏️) icon buttons in `.template-card-actions`
- Delete (✕) button as `.card-remove-btn`
- Property badges in `.stream-card-props` with emoji icons
- **Crosslinks**: When a card references another entity (audio source, picture source, capture template, PP template, etc.), make the property badge a clickable link using the `stream-card-link` CSS class and an `onclick` handler calling `navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue)`. Only add the link when the referenced entity is found (to avoid broken navigation). Example: `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-multi','data-id','${id}')">🎵 Name</span>`
### Modal footer buttons
Use **icon-only** buttons (✓ / ✕) matching the device settings modal pattern, **not** text buttons:
```html
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeMyModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#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`).
## Localization (i18n)
**Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.js` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`).
- In JS modules: `import { t } from '../core/i18n.js';` then `showToast(t('my.key'), 'error')`
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.t ? t('key') : 'fallback'`
- In HTML templates: use `data-i18n="key"` for text content, `data-i18n-title="key"` for title attributes, `data-i18n-aria-label="key"` for aria-labels
- Keys follow dotted namespace convention: `feature.context.description` (e.g. `device.error.brightness`, `calibration.saved`)
**Use context7 MCP tools for library/framework documentation lookups.** When you need to check API signatures, usage patterns, or current behavior of external libraries (e.g., FastAPI, OpenCV, Pydantic, yt-dlp), use `mcp__plugin_context7_context7__resolve-library-id` to find the library, then `mcp__plugin_context7_context7__query-docs` to fetch up-to-date docs. This avoids relying on potentially outdated training data.
## General Guidelines

164
TODO.md
View File

@@ -4,98 +4,126 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
## Processing Pipeline
- [x] `P1` **Noise gate** — Suppress small color changes below threshold, preventing shimmer on static content
- [x] `P1` **Color temperature filter** — Already covered by existing Color Correction filter (2000-10000K)
- [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color
- Complexity: medium — doesn't fit the PP filter model (operates on extracted LED colors, not images); needs a new param on calibration/color-strip-source config + PixelMapper changes
- Impact: high — smooths out single-LED noise, visually cleaner ambilight on sparse strips
- [x] `P2` **Palette quantization** — Force output to match a user-defined palette (preset or custom hex)
- [x] `P2` **Drag-and-drop filter ordering** — Reorder postprocessing filter chains visually
- [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut
- Complexity: large — requires a new transition layer concept in ProcessorManager; must blend two live streams simultaneously during switch, coordinating start/stop timing
- Impact: medium — polishes profile switching UX but ambient lighting rarely switches sources frequently
## Output Targets
- [x] `P1` **Rename `picture-targets` to `output-targets`** — Rename API endpoints and internal references for clarity
- [x] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
- [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers
- Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI
- Impact: medium — opens stage/theatrical use case, niche but differentiating
## Automation & Integration
- [ ] `P2` **Webhook/MQTT trigger** — Let external systems activate profiles without HA integration
- Complexity: low-medium — webhook: simple FastAPI endpoint calling SceneActivator; MQTT: add `asyncio-mqtt` dependency + subscription loop
- Impact: high — key integration point for home automation users without Home Assistant
- [ ] `P2` **WebSocket event bus** — Broadcast all state changes over a single WS channel
- Complexity: low-medium — ProcessorManager already emits events; add a WS endpoint that fans out JSON events to connected clients
- Impact: medium — enables real-time dashboards, mobile apps, and third-party integrations
- [x] `P3` **Notification reactive** — Flash/pulse on OS notifications (optional app filter)
- Complexity: large — OS-level notification listener (platform-specific: Win32 `WinToast`/`pystray`, macOS `pyobjc`); needs a new "effect source" type that triggers color pulses
- Impact: low-medium — fun but niche; platform-dependent maintenance burden
## Multi-Display
- [ ] `P2` **Investigate multimonitor support** — Research and plan support for multi-monitor setups
- Complexity: research — audit DXGI/MSS capture engine's display enumeration; test with 2+ monitors; identify gaps in calibration UI (per-display config)
- Impact: high — many users have multi-monitor setups; prerequisite for multi-display unification
- [ ] `P3` **Multi-display unification** — Treat 2-3 monitors as single virtual display for seamless ambilight
- Complexity: large — virtual display abstraction stitching multiple captures; edge-matching calibration between monitors; significant UI changes
- Impact: high — flagship feature for multi-monitor users, but depends on investigation results
## Capture Engines
- [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices
- Complexity: large — external dependency on scrcpy binary; need to manage subprocess lifecycle, parse video stream (ffmpeg/AV pipe), handle device connect/disconnect
- Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case
- [x] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting
## Code Health (from review 2026-03-09)
## Code Health
### Bugs
- [ ] `P1` **"Start All" targets button** — "Stop All" exists but "Start All" is missing
- [x] `P2` **Manual backup trigger endpoint**`POST /system/auto-backup/trigger` (~5 lines)
- [ ] `P2` **Scene snapshot should capture device brightness**`software_brightness` not saved/restored
- [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard**`metrics.last_error` is already populated
- [ ] `P3` **CSS source import/export** — share individual sources without full config backup
- [x] `P1` **Thread safety: dict mutation during iteration** — composite_stream.py / mapped_stream.py `_sub_streams.clear()` crashes processing loop
- [x] `P1` **Thread safety: SyncClockRuntime.get_time() race** — compound read without lock causes time double-counting
- [x] `P1` **Thread safety: SyncClockManager unprotected dicts**`_runtimes`/`_ref_counts` mutated from multiple threads without lock
- [x] `P1` **Clock ref-count corruption on hot-swap**`_release_clock` reads new clock_id from store instead of old one
- [x] `P1` **Path traversal guard**`auto_backup.py` uses string checks instead of `Path.resolve().is_relative_to()`
- [x] `P2` **Crash doesn't fire state_change event** — fatal exception path in `wled_target_processor.py` doesn't notify dashboard
- [x] `P2` **WS broadcast client mismatch**`kc_target_processor.py` `zip(clients, results)` can pair wrong clients after concurrent removal
## Backend Review Fixes (2026-03-14)
### Performance
- [x] `P1` **Triple FFT for mono audio**`analysis.py` runs 3 identical FFTs when audio is mono (2x wasted CPU)
- [x] `P2` **Per-frame np.array() from list**`ddp_client.py:195` allocates new numpy array every frame
- [x] `P2` **frame_time recomputed every loop iteration**`1.0/fps` in 8 stream files, should be cached
- [x] `P2` **Effect/composite/mapped streams hardcoded to 30 FPS** — ignores target FPS, bottlenecks 60 FPS targets
- [x] `P3` **Spectrum .copy() per audio chunk**`analysis.py` ~258 array allocations/sec for read-only consumers
- [x] **P1** PIL blocking in async handlers → `asyncio.to_thread`
- [x] **P2** `subprocess.run` blocking event loop → `asyncio.create_subprocess_exec`
- [x] **P3** Audio enum blocking async → `asyncio.to_thread`
- [x] **P4** Display enum blocking async → `asyncio.to_thread`
- [x] **P5** `colorsys` scalar loop in hot path → vectorize numpy
- [x] **P6** `MappedStream` per-frame allocation → double-buffer
- [x] **P7** Audio/effect per-frame temp allocs → pre-allocate
- [x] **P8** Blocking `httpx.get` in stream init → documented (callers use to_thread)
- [x] **P9** No-cache middleware runs on all requests → scope to static
- [x] **P10** Sync file I/O in async handlers (stores) → documented as accepted risk (< 5ms)
- [x] **P11** `frame_time` float division every loop iter → cache field
- [x] **P12** `_check_name_unique` O(N) + no lock → add threading.Lock
- [x] **P13** Imports inside 1-Hz metrics loop → move to module level
### Code Quality
- [x] `P2` **12 store classes with duplicated boilerplate** — no base class; `BaseJsonStore[T]` would eliminate ~60%
- [x] `P2` **DeviceStore.save() uses unsafe temp file** — fixed-path `.tmp` instead of `atomic_write_json`
- [x] `P2` **Route code directly mutates ProcessorManager internals**`devices.py` accesses `manager._devices` in 13+ places
- [x] `P2` **scene_activator.py accesses ProcessorManager._processors directly** — bypasses public API
- [x] `P3` **datetime.utcnow() deprecated** — 88 call sites in 42 files, should use `datetime.now(timezone.utc)`
- [x] `P3` **color-strips.js 1900+ lines** — should be split into separate modules
- [x] `P3` **No DataCache for color strip sources** — fetched with raw fetchWithAuth in 5+ places
### Features
- [x] `P1` **Auto-restart crashed processing loops** — add backoff-based restart when `_processing_loop` dies
- [ ] `P1` **"Start All" targets button** — "Stop All" exists but "Start All" is missing
- [ ] `P2` **Manual backup trigger endpoint**`POST /system/auto-backup/trigger` (~5 lines)
- [ ] `P2` **Scene snapshot should capture device brightness**`software_brightness` not saved/restored
- [ ] `P2` **Device health WebSocket events** — eliminate 5-30s poll latency for online/offline detection
- [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard**`metrics.last_error` is already populated
- [ ] `P3` **Home Assistant MQTT discovery** — publish auto-discovery payloads; MQTT infra already exists
- [ ] `P3` **CSS source import/export** — share individual sources without full config backup
- [ ] `P3` **Exponential backoff on events WS reconnect** — currently fixed 3s retry
- [x] **Q1** `DeviceStore` not using `BaseJsonStore`
- [x] **Q2** `ColorStripStore` 275-line god methods → factory dispatch
- [x] **Q3** Layer violation: core imports from routes → extract to utility
- [x] **Q4** 20+ field-by-field update in Device/routes → dataclass + generic update
- [x] **Q5** WebSocket auth copy-pasted 9x → extract helper
- [x] **Q6** `set_device_brightness` bypasses store → use update_device
- [x] **Q7** DI via 16+ module globals → registry pattern
- [x] **Q8** `_css_to_response` 30+ getattr → polymorphic to_response
- [x] **Q9** Private attribute access across modules → expose as properties
- [x] **Q10** `ColorStripSource.to_dict()` emits ~25 nulls → per-subclass override
- [x] **Q11** `DeviceStore.get_device` returns None vs raises → raise ValueError
- [x] **Q12** `list_all_tags` fragile method-name probing → use get_all()
- [x] **Q13** Route create/update pass 30 individual fields → **kwargs
## UX
- [x] `P2` **Tags / groups for cards** — Assign tags to devices, targets, and sources; filter and group cards by tag
- [x] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest
- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default
- [x] `P1` **Review protocol badge on LED target cards** — Review and improve the protocol badge display on LED target cards
- [x] `P1` **Daylight brightness value source** — New value source type that reports a 0255 brightness level based on daylight cycle time (real-time or simulated), reusing the daylight LUT logic
- [x] `P1` **Tags input: move under name, remove hint/title** — Move the tags chip input directly below the name field in all entity editor modals; remove the hint toggle and section title for a cleaner layout
## WebUI Review (2026-03-16)
### Critical (Safety & Correctness)
- [x] `P1` **"Stop All" buttons need confirmation** — dashboard, LED targets, KC targets
- [x] `P1` **`turnOffDevice()` needs confirmation**
- [x] `P1` **Confirm dialog i18n** — added data-i18n to title/buttons
- [x] `P1` **Duplicate `id="tutorial-overlay"`** — renamed to calibration-tutorial-overlay
- [x] `P1` **Define missing CSS variables** — --radius, --text-primary, --hover-bg, --input-bg
- [x] `P1` **Toast z-index conflict** — toast now 3000
### UX Consistency
- [x] `P1` **Test modals backdrop-close** — added setupBackdropClose
- [x] `P1` **Devices clone** — added cloneDevice with full field prefill
- [x] `P1` **Sync clocks in command palette** — added to _responseKeys + _buildItems
- [x] `P2` **Hardcoded accent colors** — 20+ replacements using color-mix() and CSS vars
- [x] `P2` **Duplicate `.badge` definition** — removed dead code from components.css
- [x] `P2` **Calibration elements keyboard-accessible** — changed div to button
- [x] `P2` **Color-picker swatch aria-labels** — added aria-label with hex value
- [x] `P2` **Pattern canvas mobile scroll** — added min-width: 0 override in mobile.css
- [x] `P2` **Graph editor mobile bottom clipping** — adjusted height in mobile.css
### Low Priority Polish
- [x] `P3` **Empty-state illustrations/onboarding** — CardSection emptyKey with per-entity messages
- [x] `P3` **api-key-modal submit title i18n**
- [x] `P3` **Settings modal close labeled "Cancel" → "Close"**
- [x] `P3` **Inconsistent px vs rem font sizes** — 21 conversions across streams/modal/cards CSS
- [x] `P3` **scroll-behavior: smooth** — added with reduced-motion override
- [x] `P3` **Reduce !important usage** — scoped .cs-filter selectors
- [x] `P3` **@media print styles** — theme reset + hide nav
- [x] `P3` **:focus-visible on interactive elements** — added 4 missing selectors
- [x] `P3` **iOS Safari modal scroll-position jump** — already implemented in ui.js lockBody/unlockBody
### New Features
- [x] `P1` **Command palette actions** — start/stop targets, activate scenes, enable/disable automations
- [x] `P1` **Bulk start/stop API** — POST /output-targets/bulk/start and /bulk/stop
- [x] `P1` **OS notification history viewer** — modal with app name, timestamp, fired/filtered badges
- [x] `P1` **Scene "used by" reference count** — badge on card with automation count
- [x] `P1` **Clock elapsed time on cards** — shows formatted elapsed time
- [x] `P1` **Device "last seen" timestamp** — relative time with full ISO in title
- [x] `P2` **Audio device refresh in modal** — refresh button next to device dropdown
- [x] `P2` **Composite layer reorder** — drag handles with pointer-based reorder
- [x] `P2` **MQTT settings panel** — config form with enabled/host/port/auth/topic, JSON persistence
- [x] `P2` **Log viewer** — WebSocket broadcaster with ring buffer, level-filtered UI in settings
- [x] `P2` **Animated value source waveform preview** — canvas drawing of sine/triangle/sawtooth/square
- [x] `P2` **Gradient custom preset save** — localStorage-backed custom presets with save/delete
- [x] `P2` **API key management UI** — read-only display of key labels with masked values
- [x] `P2` **Backup metadata** — file size, auto/manual badge
- [x] `P2` **Server restart button** — in settings with confirm dialog + restart overlay
- [x] `P2` **Partial config export/import** — per-store export/import with merge option
- [x] `P3` **Audio spectrum visualizer** — already fully implemented
- [ ] `P3` **Hue bridge pairing flow** — requires physical Hue bridge hardware
- [x] `P3` **Runtime log-level adjustment** — GET/PUT endpoints + settings dropdown
- [x] `P3` **Progressive disclosure in target editor** — advanced section collapsed by default
### CSS Architecture
- [x] `P1` **Define missing CSS variables** — --radius, --text-primary, --hover-bg, --input-bg
- [x] `P2` **Define radius scale** — --radius-sm/md/lg/pill tokens, migrated key selectors
- [x] `P2` **Scope generic input selector** — .cs-filter boosted specificity, 7 !important removed
- [x] `P2` **Consolidate duplicate toggle switch** — filter-list uses settings-toggle
- [x] `P2` **Replace hardcoded accent colors** — 20+ values → CSS vars with color-mix()

250
build-dist.ps1 Normal file
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
@@ -29,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH,
Platform.SENSOR,
Platform.NUMBER,
@@ -92,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
model="Scene Presets",
configuration_url=server_url,
)
current_identifiers.add(scenes_identifier)
current_identifiers.add(scenes_identifier)
# Remove devices for targets that no longer exist
for device_entry in dr.async_entries_for_config_entry(
@@ -111,15 +114,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
}
# Track target and scene IDs to detect changes
initial_target_ids = set(
known_target_ids = set(
coordinator.data.get("targets", {}).keys() if coordinator.data else []
)
initial_scene_ids = set(
known_scene_ids = set(
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
)
def _on_coordinator_update() -> None:
"""Manage WS connections and detect target list changes."""
nonlocal known_target_ids, known_scene_ids
if not coordinator.data:
return
@@ -131,16 +136,20 @@ 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 or scene list changed
current_ids = set(targets.keys())
current_scene_ids = set(
p["id"] for p in coordinator.data.get("scene_presets", [])
)
if current_ids != initial_target_ids or current_scene_ids != initial_scene_ids:
if current_ids != known_target_ids or current_scene_ids != known_scene_ids:
known_target_ids = current_ids
known_scene_ids = current_scene_ids
_LOGGER.info("Target or scene list changed, reloading integration")
hass.async_create_task(
hass.config_entries.async_reload(entry.entry_id)
@@ -148,6 +157,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator.async_add_listener(_on_coordinator_update)
# Register set_leds service (once across all entries)
async def handle_set_leds(call) -> None:
"""Handle the set_leds service call."""
source_id = call.data["source_id"]
segments = call.data["segments"]
# Route to the coordinator that owns this source
for entry_data in hass.data[DOMAIN].values():
coord = entry_data.get(DATA_COORDINATOR)
if not coord or not coord.data:
continue
source_ids = {
s["id"] for s in coord.data.get("css_sources", [])
}
if source_id in source_ids:
await coord.push_segments(source_id, segments)
return
_LOGGER.error("No server found with source_id %s", source_id)
if not hass.services.has_service(DOMAIN, "set_leds"):
hass.services.async_register(
DOMAIN,
"set_leds",
handle_set_leds,
schema=vol.Schema({
vol.Required("source_id"): str,
vol.Required("segments"): list,
}),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -163,5 +201,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
# Unregister service if no entries remain
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, "set_leds")
return unload_ok

View File

@@ -65,10 +65,9 @@ class SceneActivateButton(CoordinatorEntity, ButtonEntity):
"""Return if entity is available."""
if not self.coordinator.data:
return False
return any(
p["id"] == self._preset_id
for p in self.coordinator.data.get("scene_presets", [])
)
return self._preset_id in {
p["id"] for p in self.coordinator.data.get("scene_presets", [])
}
async def async_press(self) -> None:
"""Activate the scene preset."""

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:
@@ -136,7 +136,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
try:
async with self.session.get(
f"{self.server_url}/health",
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -150,7 +150,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/output-targets",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -161,7 +161,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
@@ -171,27 +171,22 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _get_rectangles(self, template_id: str) -> list[dict]:
"""Get rectangles for a pattern template, using cache."""
if template_id in self._pattern_cache:
return self._pattern_cache[template_id]
async def _fetch_rectangles(self, template_id: str) -> list[dict]:
"""Fetch rectangles for a pattern template (no cache — always fresh)."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/pattern-templates/{template_id}",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
rectangles = data.get("rectangles", [])
self._pattern_cache[template_id] = rectangles
return rectangles
return data.get("rectangles", [])
except Exception as err:
_LOGGER.warning(
"Failed to fetch pattern template %s: %s", template_id, err
@@ -204,7 +199,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/devices",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -213,18 +208,16 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
_LOGGER.warning("Failed to fetch devices: %s", err)
return {}
devices_data: dict[str, dict[str, Any]] = {}
for device in devices:
# Fetch brightness for all capable devices in parallel
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
device_id = device["id"]
entry: dict[str, Any] = {"info": device, "brightness": None}
if "brightness_control" in (device.get("capabilities") or []):
try:
async with self.session.get(
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status == 200:
bri_data = await resp.json()
@@ -234,7 +227,19 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"Failed to fetch brightness for device %s: %s",
device_id, err,
)
return device_id, entry
results = await asyncio.gather(
*(fetch_device_entry(d) for d in devices),
return_exceptions=True,
)
devices_data: dict[str, dict[str, Any]] = {}
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Device fetch failed: %s", r)
continue
device_id, entry = r
devices_data[device_id] = entry
return devices_data
@@ -245,7 +250,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"brightness": brightness},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -262,7 +267,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/devices/{device_id}/color",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"color": color},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -280,7 +285,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"key_colors_settings": {"brightness": brightness_float}},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -297,7 +302,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/color-strip-sources",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -312,7 +317,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/value-sources",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -327,7 +332,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/scene-presets",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -336,12 +341,44 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
_LOGGER.warning("Failed to fetch scene presets: %s", err)
return []
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
"""Push flat color array to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push colors to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
"""Push segment data to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push segments to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def activate_scene(self, preset_id: str) -> None:
"""Activate a scene preset."""
async with self.session.post(
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -352,13 +389,29 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
resp.raise_for_status()
await self.async_request_refresh()
async def update_source(self, source_id: str, **kwargs: Any) -> None:
"""Update a color strip source's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def update_target(self, target_id: str, **kwargs: Any) -> None:
"""Update a output target's fields."""
"""Update an output target's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -374,7 +427,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already processing", target_id)
@@ -392,7 +445,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already stopped", target_id)

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

@@ -96,7 +96,7 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None:
source_id = self._name_to_id(option)
source_id = self._name_to_id_map().get(option)
if source_id is None:
_LOGGER.error("CSS source not found: %s", option)
return
@@ -104,12 +104,9 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
self._target_id, color_strip_source_id=source_id
)
def _name_to_id(self, name: str) -> str | None:
def _name_to_id_map(self) -> dict[str, str]:
sources = (self.coordinator.data or {}).get("css_sources") or []
for s in sources:
if s["name"] == name:
return s["id"]
return None
return {s["name"]: s["id"] for s in sources}
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
@@ -167,17 +164,14 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
if option == NONE_OPTION:
source_id = ""
else:
source_id = self._name_to_id(option)
name_map = {
s["name"]: s["id"]
for s in (self.coordinator.data or {}).get("value_sources") or []
}
source_id = name_map.get(option)
if source_id is None:
_LOGGER.error("Value source not found: %s", option)
return
await self.coordinator.update_target(
self._target_id, brightness_value_source_id=source_id
)
def _name_to_id(self, name: str) -> str | None:
sources = (self.coordinator.data or {}).get("value_sources") or []
for s in sources:
if s["name"] == name:
return s["id"]
return None

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

@@ -31,6 +31,11 @@
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
@@ -66,5 +71,21 @@
"name": "Brightness Source"
}
}
},
"services": {
"set_leds": {
"name": "Set LEDs",
"description": "Push segment data to an api_input color strip source.",
"fields": {
"source_id": {
"name": "Source ID",
"description": "The api_input CSS source ID (e.g., css_abc12345)."
},
"segments": {
"name": "Segments",
"description": "List of segment objects with start, length, mode, and color/colors fields."
}
}
}
}
}

View File

@@ -31,6 +31,11 @@
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
@@ -58,9 +63,12 @@
"name": "Brightness"
}
},
"light": {
"light": {
"name": "Light"
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
}
}
}

View File

@@ -31,6 +31,11 @@
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Подсветка"
}
},
"switch": {
"processing": {
"name": "Обработка"
@@ -58,9 +63,12 @@
"name": "Яркость"
}
},
"light": {
"light": {
"name": "Подсветка"
"select": {
"color_strip_source": {
"name": "Источник цветовой полосы"
},
"brightness_source": {
"name": "Источник яркости"
}
}
}

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)

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

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

@@ -60,6 +60,11 @@ dev = [
camera = [
"opencv-python-headless>=4.8.0",
]
# OS notification capture
notifications = [
"winsdk>=1.0.0b10; sys_platform == 'win32'",
"dbus-next>=0.2.3; sys_platform == 'linux'",
]
# High-performance screen capture engines (Windows only)
perf = [
"dxcam>=0.0.5; sys_platform == 'win32'",

View File

@@ -18,6 +18,7 @@ from .routes.automations import router as automations_router
from .routes.scene_presets import router as scene_presets_router
from .routes.webhooks import router as webhooks_router
from .routes.sync_clocks import router as sync_clocks_router
from .routes.color_strip_processing import router as cspt_router
router = APIRouter()
router.include_router(system_router)
@@ -36,5 +37,6 @@ router.include_router(automations_router)
router.include_router(scene_presets_router)
router.include_router(webhooks_router)
router.include_router(sync_clocks_router)
router.include_router(cspt_router)
__all__ = ["router"]

View File

@@ -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
@@ -14,147 +20,122 @@ from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
# Global instances (initialized in main.py)
_auto_backup_engine: AutoBackupEngine | None = None
_device_store: DeviceStore | None = None
_template_store: TemplateStore | None = None
_pp_template_store: PostprocessingTemplateStore | None = None
_pattern_template_store: PatternTemplateStore | None = None
_picture_source_store: PictureSourceStore | None = None
_output_target_store: OutputTargetStore | None = None
_color_strip_store: ColorStripStore | None = None
_audio_source_store: AudioSourceStore | None = None
_audio_template_store: AudioTemplateStore | None = None
_value_source_store: ValueSourceStore | None = None
_processor_manager: ProcessorManager | None = None
_automation_store: AutomationStore | None = None
_scene_preset_store: ScenePresetStore | None = None
_automation_engine: AutomationEngine | None = None
_sync_clock_store: SyncClockStore | None = None
_sync_clock_manager: SyncClockManager | None = None
T = TypeVar("T")
# Central dependency registry — keyed by type or string label
_deps: Dict[str, Any] = {}
def _get(key: str, label: str) -> Any:
"""Get a dependency by key, raising RuntimeError if not initialized."""
dep = _deps.get(key)
if dep is None:
raise RuntimeError(f"{label} not initialized")
return dep
# ── Typed getters (unchanged signatures for FastAPI Depends()) ──────────
def get_device_store() -> DeviceStore:
"""Get device store dependency."""
if _device_store is None:
raise RuntimeError("Device store not initialized")
return _device_store
return _get("device_store", "Device store")
def get_template_store() -> TemplateStore:
"""Get template store dependency."""
if _template_store is None:
raise RuntimeError("Template store not initialized")
return _template_store
return _get("template_store", "Template store")
def get_pp_template_store() -> PostprocessingTemplateStore:
"""Get postprocessing template store dependency."""
if _pp_template_store is None:
raise RuntimeError("Postprocessing template store not initialized")
return _pp_template_store
return _get("pp_template_store", "Postprocessing template store")
def get_pattern_template_store() -> PatternTemplateStore:
"""Get pattern template store dependency."""
if _pattern_template_store is None:
raise RuntimeError("Pattern template store not initialized")
return _pattern_template_store
return _get("pattern_template_store", "Pattern template store")
def get_picture_source_store() -> PictureSourceStore:
"""Get picture source store dependency."""
if _picture_source_store is None:
raise RuntimeError("Picture source store not initialized")
return _picture_source_store
return _get("picture_source_store", "Picture source store")
def get_output_target_store() -> OutputTargetStore:
"""Get output target store dependency."""
if _output_target_store is None:
raise RuntimeError("Picture target store not initialized")
return _output_target_store
return _get("output_target_store", "Output target store")
def get_color_strip_store() -> ColorStripStore:
"""Get color strip store dependency."""
if _color_strip_store is None:
raise RuntimeError("Color strip store not initialized")
return _color_strip_store
return _get("color_strip_store", "Color strip store")
def get_audio_source_store() -> AudioSourceStore:
"""Get audio source store dependency."""
if _audio_source_store is None:
raise RuntimeError("Audio source store not initialized")
return _audio_source_store
return _get("audio_source_store", "Audio source store")
def get_audio_template_store() -> AudioTemplateStore:
"""Get audio template store dependency."""
if _audio_template_store is None:
raise RuntimeError("Audio template store not initialized")
return _audio_template_store
return _get("audio_template_store", "Audio template store")
def get_value_source_store() -> ValueSourceStore:
"""Get value source store dependency."""
if _value_source_store is None:
raise RuntimeError("Value source store not initialized")
return _value_source_store
return _get("value_source_store", "Value source store")
def get_processor_manager() -> ProcessorManager:
"""Get processor manager dependency."""
if _processor_manager is None:
raise RuntimeError("Processor manager not initialized")
return _processor_manager
return _get("processor_manager", "Processor manager")
def get_automation_store() -> AutomationStore:
"""Get automation store dependency."""
if _automation_store is None:
raise RuntimeError("Automation store not initialized")
return _automation_store
return _get("automation_store", "Automation store")
def get_scene_preset_store() -> ScenePresetStore:
"""Get scene preset store dependency."""
if _scene_preset_store is None:
raise RuntimeError("Scene preset store not initialized")
return _scene_preset_store
return _get("scene_preset_store", "Scene preset store")
def get_automation_engine() -> AutomationEngine:
"""Get automation engine dependency."""
if _automation_engine is None:
raise RuntimeError("Automation engine not initialized")
return _automation_engine
return _get("automation_engine", "Automation engine")
def get_auto_backup_engine() -> AutoBackupEngine:
"""Get auto-backup engine dependency."""
if _auto_backup_engine is None:
raise RuntimeError("Auto-backup engine not initialized")
return _auto_backup_engine
return _get("auto_backup_engine", "Auto-backup engine")
def get_sync_clock_store() -> SyncClockStore:
"""Get sync clock store dependency."""
if _sync_clock_store is None:
raise RuntimeError("Sync clock store not initialized")
return _sync_clock_store
return _get("sync_clock_store", "Sync clock store")
def get_sync_clock_manager() -> SyncClockManager:
"""Get sync clock manager dependency."""
if _sync_clock_manager is None:
raise RuntimeError("Sync clock manager not initialized")
return _sync_clock_manager
return _get("sync_clock_manager", "Sync clock manager")
def get_cspt_store() -> ColorStripProcessingTemplateStore:
return _get("cspt_store", "Color strip processing template store")
# ── Event helper ────────────────────────────────────────────────────────
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""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(
@@ -175,27 +156,26 @@ def init_dependencies(
auto_backup_engine: AutoBackupEngine | None = None,
sync_clock_store: SyncClockStore | None = None,
sync_clock_manager: SyncClockManager | None = None,
cspt_store: ColorStripProcessingTemplateStore | None = None,
):
"""Initialize global dependencies."""
global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _output_target_store
global _color_strip_store, _audio_source_store, _audio_template_store
global _value_source_store, _automation_store, _scene_preset_store, _automation_engine, _auto_backup_engine
global _sync_clock_store, _sync_clock_manager
_device_store = device_store
_template_store = template_store
_processor_manager = processor_manager
_pp_template_store = pp_template_store
_pattern_template_store = pattern_template_store
_picture_source_store = picture_source_store
_output_target_store = output_target_store
_color_strip_store = color_strip_store
_audio_source_store = audio_source_store
_audio_template_store = audio_template_store
_value_source_store = value_source_store
_automation_store = automation_store
_scene_preset_store = scene_preset_store
_automation_engine = automation_engine
_auto_backup_engine = auto_backup_engine
_sync_clock_store = sync_clock_store
_sync_clock_manager = sync_clock_manager
_deps.update({
"device_store": device_store,
"template_store": template_store,
"processor_manager": processor_manager,
"pp_template_store": pp_template_store,
"pattern_template_store": pattern_template_store,
"picture_source_store": picture_source_store,
"output_target_store": output_target_store,
"color_strip_store": color_strip_store,
"audio_source_store": audio_source_store,
"audio_template_store": audio_template_store,
"value_source_store": value_source_store,
"automation_store": automation_store,
"scene_preset_store": scene_preset_store,
"automation_engine": automation_engine,
"auto_backup_engine": auto_backup_engine,
"sync_clock_store": sync_clock_store,
"sync_clock_manager": sync_clock_manager,
"cspt_store": cspt_store,
})

View File

@@ -26,13 +26,12 @@ PREVIEW_JPEG_QUALITY = 70
def authenticate_ws_token(token: str) -> bool:
"""Check a WebSocket query-param token against configured API keys."""
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
return True
return False
"""Check a WebSocket query-param token against configured API keys.
Delegates to the canonical implementation in auth module.
"""
from wled_controller.api.auth import verify_ws_token
return verify_ws_token(token)
def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
@@ -44,6 +43,19 @@ def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
return f"data:image/jpeg;base64,{b64}"
def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int = 80) -> bytes:
"""Encode a numpy RGB image to JPEG bytes, optionally downscaling."""
import cv2
if max_width and image.shape[1] > max_width:
scale = max_width / image.shape[1]
new_h = int(image.shape[0] * scale)
image = cv2.resize(image, (max_width, new_h), interpolation=cv2.INTER_AREA)
# RGB → BGR for OpenCV JPEG encoding
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
_, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
return buf.tobytes()
def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image:
"""Create a thumbnail copy of the image, preserving aspect ratio."""
thumb = pil_image.copy()

View File

@@ -1,5 +1,7 @@
"""Audio device routes: enumerate available audio devices."""
import asyncio
from fastapi import APIRouter
from wled_controller.api.auth import AuthRequired
@@ -17,8 +19,12 @@ async def list_audio_devices(_auth: AuthRequired):
filter by the selected audio template's engine type.
"""
try:
devices = AudioCaptureManager.enumerate_devices()
by_engine = AudioCaptureManager.enumerate_devices_by_engine()
devices, by_engine = await asyncio.to_thread(
lambda: (
AudioCaptureManager.enumerate_devices(),
AudioCaptureManager.enumerate_devices_by_engine(),
)
)
return {
"devices": devices,
"count": len(devices),

View File

@@ -1,7 +1,6 @@
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
import asyncio
import secrets
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -9,6 +8,7 @@ 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,
@@ -20,11 +20,11 @@ from wled_controller.api.schemas.audio_sources import (
AudioSourceResponse,
AudioSourceUpdate,
)
from wled_controller.config import get_config
from wled_controller.storage.audio_source import AudioSource
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -43,7 +43,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
audio_source_id=getattr(source, "audio_source_id", None),
channel=getattr(source, "channel", None),
description=source.description,
tags=getattr(source, 'tags', []),
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
)
@@ -84,7 +84,11 @@ async def create_audio_source(
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))
@@ -123,7 +127,11 @@ async def update_audio_source(
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))
@@ -146,6 +154,10 @@ async def delete_audio_source(
)
store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -165,16 +177,8 @@ async def test_audio_source_ws(
(ref-counted — shares with running targets), and streams AudioAnalysis
snapshots as JSON at ~20 Hz.
"""
# Authenticate
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return

View File

@@ -2,13 +2,11 @@
import asyncio
import json
import secrets
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 get_audio_template_store, get_audio_source_store, get_processor_manager
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,
@@ -17,11 +15,11 @@ from wled_controller.api.schemas.audio_templates import (
AudioTemplateResponse,
AudioTemplateUpdate,
)
from wled_controller.config import get_config
from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -41,7 +39,7 @@ async def list_audio_templates(
responses = [
AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
@@ -66,12 +64,16 @@ async def create_audio_template(
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=getattr(template, 'tags', []),
engine_config=template.engine_config, tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at, description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -92,7 +94,7 @@ async def get_audio_template(
raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
@@ -112,12 +114,16 @@ async def update_audio_template(
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=getattr(t, 'tags', []),
engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -135,8 +141,12 @@ async def delete_audio_template(
"""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:
@@ -186,16 +196,8 @@ async def test_audio_template_ws(
Auth via ?token=<api_key>. Device specified via ?device_index=N&is_loopback=0|1.
Streams AudioAnalysis snapshots as JSON at ~20 Hz.
"""
# Authenticate
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return

View File

@@ -6,6 +6,7 @@ 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,
@@ -32,6 +33,7 @@ from wled_controller.storage.automation import (
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
@@ -88,7 +90,12 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
webhook_url = None
for c in automation.conditions:
if isinstance(c, WebhookCondition) and c.token:
if request:
# Prefer configured external URL, fall back to request base URL
from wled_controller.api.routes.system import load_external_url
ext = load_external_url()
if ext:
webhook_url = ext + f"/api/v1/webhooks/{c.token}"
elif request:
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
else:
webhook_url = f"/api/v1/webhooks/{c.token}"
@@ -107,7 +114,7 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
is_active=state["is_active"],
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
tags=getattr(automation, 'tags', []),
tags=automation.tags,
created_at=automation.created_at,
updated_at=automation.updated_at,
)
@@ -157,6 +164,9 @@ async def create_automation(
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -174,6 +184,7 @@ async def create_automation(
if automation.enabled:
await engine.trigger_evaluate()
fire_entity_event("automation", "created", automation.id)
return _automation_to_response(automation, engine, request)
@@ -242,6 +253,9 @@ async def update_automation(
if data.conditions is not None:
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -273,6 +287,7 @@ async def update_automation(
if automation.enabled:
await engine.trigger_evaluate()
fire_entity_event("automation", "updated", automation_id)
return _automation_to_response(automation, engine, request)
@@ -296,6 +311,8 @@ async def delete_automation(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("automation", "deleted", automation_id)
# ===== Enable/Disable =====

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}")

View File

@@ -1,12 +1,17 @@
"""Color strip source routes: CRUD, calibration test, and API input push."""
"""Color strip source routes: CRUD, calibration test, preview, and API input push."""
import secrets
import asyncio
import io as _io
import json as _json
import time as _time
import uuid as _uuid
import numpy as np
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_picture_source_store,
get_output_target_store,
@@ -31,28 +36,36 @@ from wled_controller.core.capture.calibration import (
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, NotificationColorStripSource, PictureColorStripSource
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource, ProcessedColorStripSource
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.config import get_config
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
"""Convert a ColorStripSource to a ColorStripSourceResponse."""
calibration = None
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) and source.calibration:
calibration = CalibrationSchema(**calibration_to_dict(source.calibration))
"""Convert a ColorStripSource to a ColorStripSourceResponse.
# Convert raw stop dicts to ColorStop schema objects for gradient sources
Uses the source's to_dict() for type-specific fields, then applies
schema conversions for calibration and gradient stops.
"""
from wled_controller.api.schemas.color_strip_sources import ColorStop as ColorStopSchema
raw_stops = getattr(source, "stops", None)
d = source.to_dict()
# Convert calibration dict → schema object
calibration = None
raw_cal = d.pop("calibration", None)
if raw_cal and isinstance(raw_cal, dict):
calibration = CalibrationSchema(**raw_cal)
# Convert stop dicts → schema objects
raw_stops = d.pop("stops", None)
stops = None
if raw_stops is not None:
try:
@@ -60,47 +73,20 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
except Exception:
stops = None
# Remove serialized timestamp strings — use actual datetime objects
d.pop("created_at", None)
d.pop("updated_at", None)
# Filter to only keys accepted by the schema (to_dict may include extra
# fields like 'fps' that aren't in the response model)
valid_fields = ColorStripSourceResponse.model_fields
filtered = {k: v for k, v in d.items() if k in valid_fields}
return ColorStripSourceResponse(
id=source.id,
name=source.name,
source_type=source.source_type,
picture_source_id=getattr(source, "picture_source_id", None),
brightness=getattr(source, "brightness", None),
saturation=getattr(source, "saturation", None),
gamma=getattr(source, "gamma", None),
smoothing=getattr(source, "smoothing", None),
interpolation_mode=getattr(source, "interpolation_mode", None),
led_count=getattr(source, "led_count", 0),
**filtered,
calibration=calibration,
color=getattr(source, "color", None),
stops=stops,
colors=getattr(source, "colors", None),
effect_type=getattr(source, "effect_type", None),
palette=getattr(source, "palette", None),
intensity=getattr(source, "intensity", None),
scale=getattr(source, "scale", None),
mirror=getattr(source, "mirror", None),
description=source.description,
clock_id=source.clock_id,
frame_interpolation=getattr(source, "frame_interpolation", None),
animation=getattr(source, "animation", None),
layers=getattr(source, "layers", None),
zones=getattr(source, "zones", None),
visualization_mode=getattr(source, "visualization_mode", None),
audio_source_id=getattr(source, "audio_source_id", None),
sensitivity=getattr(source, "sensitivity", None),
color_peak=getattr(source, "color_peak", None),
fallback_color=getattr(source, "fallback_color", None),
timeout=getattr(source, "timeout", None),
notification_effect=getattr(source, "notification_effect", None),
duration_ms=getattr(source, "duration_ms", None),
default_color=getattr(source, "default_color", None),
app_colors=getattr(source, "app_colors", None),
app_filter_mode=getattr(source, "app_filter_mode", None),
app_filter_list=getattr(source, "app_filter_list", None),
os_listener=getattr(source, "os_listener", None),
overlay_active=overlay_active,
tags=getattr(source, 'tags', []),
created_at=source.created_at,
updated_at=source.updated_at,
)
@@ -135,6 +121,27 @@ async def list_color_strip_sources(
return ColorStripSourceListResponse(sources=responses, count=len(responses))
def _extract_css_kwargs(data) -> dict:
"""Extract store-compatible kwargs from a Pydantic CSS create/update schema.
Converts nested Pydantic models (calibration, stops, layers, zones,
animation) to plain dicts/lists that the store expects.
"""
kwargs = data.model_dump(exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"})
# Remove fields that don't map to store kwargs
kwargs.pop("source_type", None)
if data.calibration is not None:
kwargs["calibration"] = calibration_from_dict(data.calibration.model_dump())
else:
kwargs["calibration"] = None
kwargs["stops"] = [s.model_dump() for s in data.stops] if data.stops is not None else None
kwargs["layers"] = [l.model_dump() for l in data.layers] if data.layers is not None else None
kwargs["zones"] = [z.model_dump() for z in data.zones] if data.zones is not None else None
kwargs["animation"] = data.animation.model_dump() if data.animation else None
return kwargs
@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201)
async def create_color_strip_source(
data: ColorStripSourceCreate,
@@ -143,58 +150,15 @@ async def create_color_strip_source(
):
"""Create a new color strip source."""
try:
calibration = None
if data.calibration is not None:
calibration = calibration_from_dict(data.calibration.model_dump())
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
source = store.create_source(
name=data.name,
source_type=data.source_type,
picture_source_id=data.picture_source_id,
brightness=data.brightness,
saturation=data.saturation,
gamma=data.gamma,
smoothing=data.smoothing,
interpolation_mode=data.interpolation_mode,
led_count=data.led_count,
calibration=calibration,
color=data.color,
stops=stops,
description=data.description,
frame_interpolation=data.frame_interpolation,
animation=data.animation.model_dump() if data.animation else None,
colors=data.colors,
effect_type=data.effect_type,
palette=data.palette,
intensity=data.intensity,
scale=data.scale,
mirror=data.mirror,
layers=layers,
zones=zones,
visualization_mode=data.visualization_mode,
audio_source_id=data.audio_source_id,
sensitivity=data.sensitivity,
color_peak=data.color_peak,
fallback_color=data.fallback_color,
timeout=data.timeout,
clock_id=data.clock_id,
notification_effect=data.notification_effect,
duration_ms=data.duration_ms,
default_color=data.default_color,
app_colors=data.app_colors,
app_filter_mode=data.app_filter_mode,
app_filter_list=data.app_filter_list,
os_listener=data.os_listener,
tags=data.tags,
)
kwargs = _extract_css_kwargs(data)
source = store.create_source(source_type=data.source_type, **kwargs)
fire_entity_event("color_strip_source", "created", source.id)
return _css_to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -227,56 +191,8 @@ async def update_color_strip_source(
):
"""Update a color strip source and hot-reload any running streams."""
try:
calibration = None
if data.calibration is not None:
calibration = calibration_from_dict(data.calibration.model_dump())
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
source = store.update_source(
source_id=source_id,
name=data.name,
picture_source_id=data.picture_source_id,
brightness=data.brightness,
saturation=data.saturation,
gamma=data.gamma,
smoothing=data.smoothing,
interpolation_mode=data.interpolation_mode,
led_count=data.led_count,
calibration=calibration,
color=data.color,
stops=stops,
description=data.description,
frame_interpolation=data.frame_interpolation,
animation=data.animation.model_dump() if data.animation else None,
colors=data.colors,
effect_type=data.effect_type,
palette=data.palette,
intensity=data.intensity,
scale=data.scale,
mirror=data.mirror,
layers=layers,
zones=zones,
visualization_mode=data.visualization_mode,
audio_source_id=data.audio_source_id,
sensitivity=data.sensitivity,
color_peak=data.color_peak,
fallback_color=data.fallback_color,
timeout=data.timeout,
clock_id=data.clock_id,
notification_effect=data.notification_effect,
duration_ms=data.duration_ms,
default_color=data.default_color,
app_colors=data.app_colors,
app_filter_mode=data.app_filter_mode,
app_filter_list=data.app_filter_list,
os_listener=data.os_listener,
tags=data.tags,
)
kwargs = _extract_css_kwargs(data)
source = store.update_source(source_id=source_id, **kwargs)
# Hot-reload running stream (no restart needed for in-place param changes)
try:
@@ -284,6 +200,7 @@ async def update_color_strip_source(
except Exception as e:
logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}")
fire_entity_event("color_strip_source", "updated", source_id)
return _css_to_response(source)
except ValueError as e:
@@ -326,7 +243,16 @@ async def delete_color_strip_source(
detail=f"Color strip source is used as a zone in mapped source(s): {names}. "
"Remove it from the mapped source(s) first.",
)
processed_names = store.get_processed_referencing(source_id)
if processed_names:
names = ", ".join(processed_names)
raise HTTPException(
status_code=409,
detail=f"Color strip source is used as input in processed source(s): {names}. "
"Delete or reassign the processed source(s) first.",
)
store.delete_source(source_id)
fire_entity_event("color_strip_source", "deleted", source_id)
except HTTPException:
raise
except ValueError as e:
@@ -489,7 +415,8 @@ async def push_colors(
):
"""Push raw LED colors to an api_input color strip source.
The colors are forwarded to all running stream instances for this source.
Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based).
The payload is forwarded to all running stream instances for this source.
"""
try:
source = store.get_source(source_id)
@@ -499,20 +426,32 @@ async def push_colors(
if not isinstance(source, ApiInputColorStripSource):
raise HTTPException(status_code=400, detail="Source is not an api_input type")
colors_array = np.array(body.colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams:
if hasattr(stream, "push_colors"):
stream.push_colors(colors_array)
return {
"status": "ok",
"streams_updated": len(streams),
"leds_received": len(body.colors),
}
if body.segments is not None:
# Segment-based path
seg_dicts = [s.model_dump() for s in body.segments]
for stream in streams:
if hasattr(stream, "push_segments"):
stream.push_segments(seg_dicts)
return {
"status": "ok",
"streams_updated": len(streams),
"segments_applied": len(body.segments),
}
else:
# Legacy flat colors path
colors_array = np.array(body.colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
for stream in streams:
if hasattr(stream, "push_colors"):
stream.push_colors(colors_array)
return {
"status": "ok",
"streams_updated": len(streams),
"leds_received": len(body.colors),
}
@router.post("/api/v1/color-strip-sources/{source_id}/notify", tags=["Color Strip Sources"])
@@ -554,6 +493,199 @@ async def notify_source(
}
@router.get("/api/v1/color-strip-sources/os-notifications/history", tags=["Color Strip Sources"])
async def os_notification_history(_auth: AuthRequired):
"""Return recent OS notification capture history (newest first)."""
from wled_controller.core.processing.os_notification_listener import get_os_notification_listener
listener = get_os_notification_listener()
if listener is None:
return {"available": False, "history": []}
return {
"available": listener.available,
"history": listener.recent_history,
}
# ── Transient Preview WebSocket ────────────────────────────────────────
_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight"}
@router.websocket("/api/v1/color-strip-sources/preview/ws")
async def preview_color_strip_ws(
websocket: WebSocket,
token: str = Query(""),
led_count: int = Query(100),
fps: int = Query(20),
):
"""Transient preview WebSocket — stream frames for an ad-hoc source config.
Auth via ``?token=<api_key>&led_count=100&fps=20``.
After accepting, waits for a text message containing the full source config
JSON (must include ``source_type``). Responds with a JSON metadata message,
then streams binary RGB frames at the requested FPS.
Subsequent text messages are treated as config updates: if the source_type
changed the old stream is replaced; otherwise ``update_source()`` is used.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
led_count = max(1, min(1000, led_count))
fps = max(1, min(60, fps))
frame_interval = 1.0 / fps
stream = None
clock_id = None
current_source_type = None
# Helpers ────────────────────────────────────────────────────────────
def _get_sync_clock_manager():
"""Return the SyncClockManager if available."""
try:
mgr = get_processor_manager()
return getattr(mgr, "_sync_clock_manager", None)
except Exception:
return None
def _build_source(config: dict):
"""Build a ColorStripSource from a raw config dict, injecting synthetic id/name."""
from wled_controller.storage.color_strip_source import ColorStripSource
config.setdefault("id", "__preview__")
config.setdefault("name", "__preview__")
return ColorStripSource.from_dict(config)
def _create_stream(source):
"""Instantiate and start the appropriate stream class for *source*."""
from wled_controller.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls:
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
s = stream_cls(source)
if hasattr(s, "configure"):
s.configure(led_count)
# Inject sync clock if requested
cid = getattr(source, "clock_id", None)
if cid and hasattr(s, "set_clock"):
scm = _get_sync_clock_manager()
if scm:
try:
clock_rt = scm.acquire(cid)
s.set_clock(clock_rt)
except Exception as e:
logger.warning(f"Preview: could not acquire clock {cid}: {e}")
cid = None
else:
cid = None
else:
cid = None
s.start()
return s, cid
def _stop_stream(s, cid):
"""Stop a stream and release its clock."""
try:
s.stop()
except Exception:
pass
if cid:
scm = _get_sync_clock_manager()
if scm:
try:
scm.release(cid)
except Exception:
pass
async def _send_meta(source_type: str):
meta = {"type": "meta", "led_count": led_count, "source_type": source_type}
await websocket.send_text(_json.dumps(meta))
# Wait for initial config ────────────────────────────────────────────
try:
initial_text = await websocket.receive_text()
except WebSocketDisconnect:
return
except Exception:
return
try:
config = _json.loads(initial_text)
source_type = config.get("source_type")
if source_type not in _PREVIEW_ALLOWED_TYPES:
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
await websocket.close(code=4003, reason="Invalid source_type")
return
source = _build_source(config)
stream, clock_id = _create_stream(source)
current_source_type = source_type
except Exception as e:
logger.error(f"Preview WS: bad initial config: {e}")
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
await websocket.close(code=4003, reason=str(e))
return
await _send_meta(current_source_type)
logger.info(f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}")
# Frame loop ─────────────────────────────────────────────────────────
try:
while True:
# Non-blocking check for incoming config updates
try:
msg = await asyncio.wait_for(websocket.receive_text(), timeout=frame_interval)
except asyncio.TimeoutError:
msg = None
except WebSocketDisconnect:
break
if msg is not None:
try:
new_config = _json.loads(msg)
new_type = new_config.get("source_type")
if new_type not in _PREVIEW_ALLOWED_TYPES:
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
continue
new_source = _build_source(new_config)
if new_type != current_source_type:
# Source type changed — recreate stream
_stop_stream(stream, clock_id)
stream, clock_id = _create_stream(new_source)
current_source_type = new_type
else:
stream.update_source(new_source)
if hasattr(stream, "configure"):
stream.configure(led_count)
await _send_meta(current_source_type)
except Exception as e:
logger.warning(f"Preview WS: bad config update: {e}")
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
# Send frame
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else:
# Stream hasn't produced a frame yet — send black
await websocket.send_bytes(b'\x00' * led_count * 3)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Preview WS error: {e}")
finally:
if stream is not None:
_stop_stream(stream, clock_id)
logger.info("Preview WS disconnected")
@router.websocket("/api/v1/color-strip-sources/{source_id}/ws")
async def css_api_input_ws(
websocket: WebSocket,
@@ -565,16 +697,8 @@ async def css_api_input_ws(
Auth via ?token=<api_key>. Accepts JSON frames ({"colors": [[R,G,B], ...]})
or binary frames (raw RGBRGB... bytes, 3 bytes per LED).
"""
# Authenticate
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -602,19 +726,42 @@ async def css_api_input_ws(
break
if "text" in message:
# JSON frame: {"colors": [[R,G,B], ...]}
# JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
import json
try:
data = json.loads(message["text"])
raw_colors = data.get("colors", [])
colors_array = np.array(raw_colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
continue
except (json.JSONDecodeError, ValueError, TypeError) as e:
except (json.JSONDecodeError, ValueError) as e:
await websocket.send_json({"error": str(e)})
continue
if "segments" in data:
# Segment-based path — validate and push
try:
from wled_controller.api.schemas.color_strip_sources import SegmentPayload
seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
except Exception as e:
await websocket.send_json({"error": f"Invalid segment: {e}"})
continue
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams:
if hasattr(stream, "push_segments"):
stream.push_segments(seg_dicts)
continue
elif "colors" in data:
try:
raw_colors = data["colors"]
colors_array = np.array(raw_colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
continue
except (ValueError, TypeError) as e:
await websocket.send_json({"error": str(e)})
continue
else:
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
continue
elif "bytes" in message:
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
raw_bytes = message["bytes"]
@@ -626,7 +773,7 @@ async def css_api_input_ws(
else:
continue
# Push to all running streams
# Push to all running streams (colors_array path only reaches here)
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams:
if hasattr(stream, "push_colors"):
@@ -638,3 +785,210 @@ async def css_api_input_ws(
logger.error(f"API input WebSocket error for source {source_id}: {e}")
finally:
logger.info(f"API input WebSocket disconnected for source {source_id}")
# ── Test / Preview WebSocket ──────────────────────────────────────────
@router.websocket("/api/v1/color-strip-sources/{source_id}/test/ws")
async def test_color_strip_ws(
websocket: WebSocket,
source_id: str,
token: str = Query(""),
led_count: int = Query(100),
fps: int = Query(20),
):
"""WebSocket for real-time CSS source preview. Auth via ``?token=<api_key>``.
First message is JSON metadata (source_type, led_count, calibration segments).
Subsequent messages are binary RGB frames (``led_count * 3`` bytes).
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Validate source exists
store: ColorStripStore = get_color_strip_store()
try:
source = store.get_source(source_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
# Acquire stream unique consumer ID per WS to avoid release races
manager: ProcessorManager = get_processor_manager()
csm = manager.color_strip_stream_manager
consumer_id = f"__test_{_uuid.uuid4().hex[:8]}__"
try:
stream = csm.acquire(source_id, consumer_id)
except Exception as e:
logger.error(f"CSS test: failed to acquire stream for {source_id}: {e}")
await websocket.close(code=4003, reason=str(e))
return
# Configure LED count for auto-sizing streams
if hasattr(stream, "configure"):
stream.configure(max(1, led_count))
# Clamp FPS to sane range
fps = max(1, min(60, fps))
_frame_interval = 1.0 / fps
await websocket.accept()
logger.info(f"CSS test WebSocket connected for {source_id} (fps={fps})")
try:
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
is_api_input = isinstance(stream, ApiInputColorStripStream)
_last_push_gen = 0 # track api_input push generation to skip unchanged frames
# Send metadata as first message
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
is_composite = isinstance(source, CompositeColorStripSource)
meta: dict = {
"type": "meta",
"source_type": source.source_type,
"source_name": source.name,
"led_count": stream.led_count,
}
if is_picture and stream.calibration:
cal = stream.calibration
total = cal.get_total_leds()
offset = cal.offset % total if total > 0 else 0
edges = []
for seg in cal.segments:
# Compute output indices matching PixelMapper logic
indices = list(range(seg.led_start, seg.led_start + seg.led_count))
if seg.reverse:
indices = indices[::-1]
if offset > 0:
indices = [(idx + offset) % total for idx in indices]
edges.append({"edge": seg.edge, "indices": indices})
meta["edges"] = edges
meta["border_width"] = cal.border_width
if is_composite and hasattr(source, "layers"):
# Send layer info for composite preview
enabled_layers = [l for l in source.layers if l.get("enabled", True)]
layer_infos = [] # [{name, id, is_notification, has_brightness, ...}, ...]
for layer in enabled_layers:
info = {"id": layer["source_id"], "name": layer.get("source_id", "?"),
"is_notification": False, "has_brightness": bool(layer.get("brightness_source_id"))}
try:
layer_src = store.get_source(layer["source_id"])
info["name"] = layer_src.name
info["is_notification"] = isinstance(layer_src, NotificationColorStripSource)
if isinstance(layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource)):
info["is_picture"] = True
if hasattr(layer_src, "calibration") and layer_src.calibration:
info["calibration_led_count"] = layer_src.calibration.get_total_leds()
except (ValueError, KeyError):
pass
layer_infos.append(info)
meta["layers"] = [li["name"] for li in layer_infos]
meta["layer_infos"] = layer_infos
await websocket.send_text(_json.dumps(meta))
# For picture sources, grab the live stream for frame preview
_frame_live = None
if is_picture and hasattr(stream, 'live_stream'):
_frame_live = stream.live_stream
_last_aux_time = 0.0
_AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS
_frame_dims_sent = False # send frame dimensions once with first JPEG
# Stream binary RGB frames at ~20 Hz
while True:
# For composite sources, send per-layer data like target preview does
if is_composite and isinstance(stream, CompositeColorStripStream):
layer_colors = stream.get_layer_colors()
composite_colors = stream.get_latest_colors()
if composite_colors is not None and layer_colors and len(layer_colors) > 1:
led_count = composite_colors.shape[0]
rgb_size = led_count * 3
# Wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layer0_rgb...] ... [composite_rgb]
header = bytes([0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF])
parts = [header]
for lc in layer_colors:
if lc is not None and lc.shape[0] == led_count:
parts.append(lc.tobytes())
else:
parts.append(b'\x00' * rgb_size)
parts.append(composite_colors.tobytes())
await websocket.send_bytes(b''.join(parts))
elif composite_colors is not None:
await websocket.send_bytes(composite_colors.tobytes())
else:
# For api_input: only send when new data was pushed
if is_api_input:
gen = stream.push_generation
if gen != _last_push_gen:
_last_push_gen = gen
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else:
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
# Periodically send auxiliary data (frame preview, brightness)
now = _time.monotonic()
if now - _last_aux_time >= _AUX_INTERVAL:
_last_aux_time = now
# Send brightness values for composite layers
if is_composite and isinstance(stream, CompositeColorStripStream):
try:
bri_values = stream.get_layer_brightness()
if any(v is not None for v in bri_values):
bri_msg = {"type": "brightness", "values": [
round(v * 100) if v is not None else None for v in bri_values
]}
await websocket.send_text(_json.dumps(bri_msg))
except Exception:
pass
# Send JPEG frame preview for picture sources
if _frame_live:
try:
frame = _frame_live.get_latest_frame()
if frame is not None and frame.image is not None:
from PIL import Image as _PIL_Image
img = frame.image
# Ensure 3-channel RGB (some engines may produce BGRA)
if img.ndim == 3 and img.shape[2] == 4:
img = img[:, :, :3]
h, w = img.shape[:2]
# Send frame dimensions once so client can compute border overlay
if not _frame_dims_sent:
_frame_dims_sent = True
await websocket.send_text(_json.dumps({
"type": "frame_dims",
"width": w,
"height": h,
}))
# Downscale for bandwidth
scale = min(960 / w, 540 / h, 1.0)
if scale < 1.0:
new_w = max(1, int(w * scale))
new_h = max(1, int(h * scale))
pil = _PIL_Image.fromarray(img).resize((new_w, new_h), _PIL_Image.LANCZOS)
else:
pil = _PIL_Image.fromarray(img)
buf = _io.BytesIO()
pil.save(buf, format='JPEG', quality=70)
# Wire format: [0xFD] [jpeg_bytes]
await websocket.send_bytes(b'\xfd' + buf.getvalue())
except Exception as e:
logger.warning(f"JPEG frame preview error: {e}")
await asyncio.sleep(_frame_interval)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"CSS test WebSocket error for {source_id}: {e}")
finally:
csm.release(source_id, consumer_id)
logger.info(f"CSS test WebSocket disconnected for {source_id}")

View File

@@ -1,7 +1,5 @@
"""Device routes: CRUD, health state, brightness, power, calibration, WS stream."""
import secrets
import httpx
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
@@ -12,6 +10,7 @@ from wled_controller.core.devices.led_client import (
get_provider,
)
from wled_controller.api.dependencies import (
fire_entity_event,
get_device_store,
get_output_target_store,
get_processor_manager,
@@ -31,6 +30,7 @@ from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -52,7 +52,19 @@ def _device_to_response(device) -> DeviceResponse:
rgbw=device.rgbw,
zone_mode=device.zone_mode,
capabilities=sorted(get_device_capabilities(device.device_type)),
tags=getattr(device, 'tags', []),
tags=device.tags,
dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'),
dmx_start_universe=getattr(device, 'dmx_start_universe', 0),
dmx_start_channel=getattr(device, 'dmx_start_channel', 1),
espnow_peer_mac=getattr(device, 'espnow_peer_mac', ''),
espnow_channel=getattr(device, 'espnow_channel', 1),
hue_username=getattr(device, 'hue_username', ''),
hue_client_key=getattr(device, 'hue_client_key', ''),
hue_entertainment_group_id=getattr(device, 'hue_entertainment_group_id', ''),
spi_speed_hz=getattr(device, 'spi_speed_hz', 800000),
spi_led_type=getattr(device, 'spi_led_type', 'WS2812B'),
chroma_device_type=getattr(device, 'chroma_device_type', 'chromalink'),
gamesense_device_type=getattr(device, 'gamesense_device_type', 'keyboard'),
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -128,12 +140,23 @@ async def create_device(
rgbw=device_data.rgbw or False,
zone_mode=device_data.zone_mode or "combined",
tags=device_data.tags,
dmx_protocol=device_data.dmx_protocol or "artnet",
dmx_start_universe=device_data.dmx_start_universe or 0,
dmx_start_channel=device_data.dmx_start_channel or 1,
espnow_peer_mac=device_data.espnow_peer_mac or "",
espnow_channel=device_data.espnow_channel or 1,
hue_username=device_data.hue_username or "",
hue_client_key=device_data.hue_client_key or "",
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
spi_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink",
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
)
# WS devices: auto-set URL to ws://{device_id}
if device_type == "ws":
store.update_device(device_id=device.id, url=f"ws://{device.id}")
device = store.get_device(device.id)
device = store.update_device(device.id, url=f"ws://{device.id}")
# Register in processor manager for health monitoring
manager.add_device(
@@ -146,6 +169,7 @@ async def create_device(
zone_mode=device.zone_mode,
)
fire_entity_event("device", "created", device.id)
return _device_to_response(device)
except HTTPException:
@@ -283,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)
@@ -311,6 +336,18 @@ async def update_device(
rgbw=update_data.rgbw,
zone_mode=update_data.zone_mode,
tags=update_data.tags,
dmx_protocol=update_data.dmx_protocol,
dmx_start_universe=update_data.dmx_start_universe,
dmx_start_channel=update_data.dmx_start_channel,
espnow_peer_mac=update_data.espnow_peer_mac,
espnow_channel=update_data.espnow_channel,
hue_username=update_data.hue_username,
hue_client_key=update_data.hue_client_key,
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
spi_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type,
gamesense_device_type=update_data.gamesense_device_type,
)
# Sync connection info in processor manager
@@ -332,6 +369,7 @@ async def update_device(
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:
@@ -369,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:
@@ -390,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)
@@ -402,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"])
@@ -417,9 +478,10 @@ async def get_device_brightness(
frontend request — hitting the ESP32 over WiFi in the async event loop
causes ~150 ms jitter in the processing loop.
"""
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")
@@ -452,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")
@@ -468,10 +531,7 @@ async def set_device_brightness(
await provider.set_brightness(device.url, bri)
except NotImplementedError:
# Provider has no hardware brightness; use software brightness
device.software_brightness = bri
from datetime import datetime, timezone
device.updated_at = datetime.now(timezone.utc)
store.save()
store.update_device(device_id=device_id, software_brightness=bri)
ds = manager.find_device_state(device_id)
if ds:
ds.software_brightness = bri
@@ -497,9 +557,10 @@ 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")
@@ -526,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")
@@ -568,23 +630,15 @@ async def device_ws_stream(
Wire format: [brightness_byte][R G B R G B ...]
Auth via ?token=<api_key>.
"""
from wled_controller.config import get_config
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
store = get_device_store()
device = store.get_device(device_id)
if not device:
try:
device = store.get_device(device_id)
except ValueError:
await websocket.close(code=4004, reason="Device not found")
return
if device.device_type != "ws":

View File

@@ -3,7 +3,6 @@
import asyncio
import base64
import io
import secrets
import time
import numpy as np
@@ -12,6 +11,7 @@ from PIL import Image
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_device_store,
get_pattern_template_store,
@@ -22,6 +22,8 @@ from wled_controller.api.dependencies import (
get_template_store,
)
from wled_controller.api.schemas.output_targets import (
BulkTargetRequest,
BulkTargetResponse,
ExtractedColorResponse,
KCTestRectangleResponse,
KCTestResponse,
@@ -34,7 +36,6 @@ from wled_controller.api.schemas.output_targets import (
TargetMetricsResponse,
TargetProcessingState,
)
from wled_controller.config import get_config
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processing.processor_manager import ProcessorManager
@@ -58,6 +59,7 @@ from wled_controller.storage.key_colors_output_target import (
)
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -97,7 +99,7 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type,
device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id,
brightness_value_source_id=target.brightness_value_source_id,
brightness_value_source_id=target.brightness_value_source_id or "",
fps=target.fps,
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
@@ -105,7 +107,7 @@ def _target_to_response(target) -> OutputTargetResponse:
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
description=target.description,
tags=getattr(target, 'tags', []),
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
@@ -118,7 +120,7 @@ def _target_to_response(target) -> OutputTargetResponse:
picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description,
tags=getattr(target, 'tags', []),
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
@@ -129,7 +131,7 @@ def _target_to_response(target) -> OutputTargetResponse:
name=target.name,
target_type=target.target_type,
description=target.description,
tags=getattr(target, 'tags', []),
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
@@ -150,8 +152,9 @@ async def create_target(
try:
# Validate device exists if provided
if data.device_id:
device = device_store.get_device(data.device_id)
if not device:
try:
device_store.get_device(data.device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
@@ -181,10 +184,14 @@ async def create_target(
except ValueError as e:
logger.warning(f"Could not register target {target.id} in processor manager: {e}")
fire_entity_event("output_target", "created", target.id)
return _target_to_response(target)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -248,8 +255,9 @@ async def update_target(
try:
# Validate device exists if changing
if data.device_id is not None and data.device_id:
device = device_store.get_device(data.device_id)
if not device:
try:
device_store.get_device(data.device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
# Build KC settings with partial-update support: only apply fields that were
@@ -313,12 +321,19 @@ async def update_target(
data.adaptive_fps is not None or
data.key_colors_settings is not None),
css_changed=data.color_strip_source_id is not None,
device_changed=data.device_id is not None,
brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed),
)
except ValueError:
pass
# Device change requires async stop → swap → start cycle
if data.device_id is not None:
try:
await manager.update_target_device(target_id, target.device_id)
except ValueError:
pass
fire_entity_event("output_target", "updated", target_id)
return _target_to_response(target)
except HTTPException:
@@ -354,6 +369,7 @@ async def delete_target(
# Delete from store
target_store.delete_target(target_id)
fire_entity_event("output_target", "deleted", target_id)
logger.info(f"Deleted target {target_id}")
except ValueError as e:
@@ -363,6 +379,64 @@ async def delete_target(
raise HTTPException(status_code=500, detail=str(e))
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_start_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for multiple output targets. Returns lists of started IDs and per-ID errors."""
started: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
target_store.get_target(target_id)
await manager.start_processing(target_id)
started.append(target_id)
logger.info(f"Bulk start: started processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except RuntimeError as e:
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
errors[target_id] = msg
except Exception as e:
logger.error(f"Bulk start: failed to start target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(started=started, errors=errors)
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_stop_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
stopped: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
await manager.stop_processing(target_id)
stopped.append(target_id)
logger.info(f"Bulk stop: stopped processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except Exception as e:
logger.error(f"Bulk stop: failed to stop target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(stopped=stopped, errors=errors)
# ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
@@ -385,7 +459,12 @@ async def start_processing(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
# Resolve target IDs to human-readable names in error messages
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
raise HTTPException(status_code=409, detail=msg)
except Exception as e:
logger.error(f"Failed to start processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@@ -523,6 +602,9 @@ async def test_kc_target(
try:
chain = source_store.resolve_stream_chain(target.picture_source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -660,6 +742,9 @@ async def test_kc_target(
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
@@ -675,6 +760,231 @@ async def test_kc_target(
logger.error(f"Error cleaning up test stream: {e}")
@router.websocket("/api/v1/output-targets/{target_id}/test/ws")
async def test_kc_target_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
fps: int = Query(3),
preview_width: int = Query(480),
):
"""WebSocket for real-time KC target test preview. Auth via ?token=<api_key>.
Streams JSON frames: {"type": "frame", "image": "data:image/jpeg;base64,...",
"rectangles": [...], "pattern_template_name": "...", "interpolation_mode": "..."}
"""
import json as _json
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Load stores
target_store_inst: OutputTargetStore = get_output_target_store()
source_store_inst: PictureSourceStore = get_picture_source_store()
template_store_inst: TemplateStore = get_template_store()
pattern_store_inst: PatternTemplateStore = get_pattern_template_store()
processor_manager_inst: ProcessorManager = get_processor_manager()
device_store_inst: DeviceStore = get_device_store()
pp_template_store_inst = get_pp_template_store()
# Validate target
try:
target = target_store_inst.get_target(target_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
if not isinstance(target, KeyColorsOutputTarget):
await websocket.close(code=4003, reason="Target is not a key_colors target")
return
settings = target.settings
if not settings.pattern_template_id:
await websocket.close(code=4003, reason="No pattern template configured")
return
try:
pattern_tmpl = pattern_store_inst.get_template(settings.pattern_template_id)
except ValueError:
await websocket.close(code=4003, reason=f"Pattern template not found: {settings.pattern_template_id}")
return
rectangles = pattern_tmpl.rectangles
if not rectangles:
await websocket.close(code=4003, reason="Pattern template has no rectangles")
return
if not target.picture_source_id:
await websocket.close(code=4003, reason="No picture source configured")
return
try:
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
except ValueError as e:
await websocket.close(code=4003, reason=str(e))
return
raw_stream = chain["raw_stream"]
# For screen capture sources, check display lock
if isinstance(raw_stream, ScreenCapturePictureSource):
display_index = raw_stream.display_index
locked_device_id = processor_manager_inst.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store_inst.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
await websocket.close(
code=4003,
reason=f"Display {display_index} is captured by '{device_name}'. Stop processing first.",
)
return
fps = max(1, min(30, fps))
preview_width = max(120, min(1920, preview_width))
frame_interval = 1.0 / fps
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
await websocket.accept()
logger.info(f"KC test WS connected for {target_id} (fps={fps})")
# Use the shared LiveStreamManager so we share the capture stream with
# running LED targets instead of creating a competing DXGI duplicator.
live_stream_mgr = processor_manager_inst._live_stream_manager
live_stream = None
try:
live_stream = await asyncio.to_thread(
live_stream_mgr.acquire, target.picture_source_id
)
logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}")
prev_frame_ref = None
while True:
loop_start = time.monotonic()
try:
capture = await asyncio.to_thread(live_stream.get_latest_frame)
if capture is None or capture.image is None:
await asyncio.sleep(frame_interval)
continue
# Skip if same frame object (no new capture yet)
if capture is prev_frame_ref:
await asyncio.sleep(frame_interval * 0.5)
continue
prev_frame_ref = capture
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None
if pil_image is None:
await asyncio.sleep(frame_interval)
continue
# Apply postprocessing (if the source chain has PP templates)
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store_inst:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store_inst.get_template(pp_id)
except ValueError:
continue
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
pass
pil_image = Image.fromarray(img_array)
# Extract colors
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append({
"name": rect.name,
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"color": {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"},
})
# Encode frame as JPEG
if preview_width and pil_image.width > preview_width:
ratio = preview_width / pil_image.width
thumb = pil_image.resize((preview_width, int(pil_image.height * ratio)), Image.LANCZOS)
else:
thumb = pil_image
buf = io.BytesIO()
thumb.save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode()
await websocket.send_text(_json.dumps({
"type": "frame",
"image": f"data:image/jpeg;base64,{b64}",
"rectangles": result_rects,
"pattern_template_name": pattern_tmpl.name,
"interpolation_mode": settings.interpolation_mode,
}))
except (WebSocketDisconnect, Exception) as inner_e:
if isinstance(inner_e, WebSocketDisconnect):
raise
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
elapsed = time.monotonic() - loop_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except WebSocketDisconnect:
logger.info(f"KC test WS disconnected for {target_id}")
except Exception as e:
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
finally:
if live_stream is not None:
try:
await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id
)
except Exception:
pass
logger.info(f"KC test WS closed for {target_id}")
@router.websocket("/api/v1/output-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,
@@ -682,16 +992,8 @@ async def target_colors_ws(
token: str = Query(""),
):
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
# Authenticate
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -722,15 +1024,8 @@ async def led_preview_ws(
token: str = Query(""),
):
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -762,15 +1057,8 @@ async def events_ws(
token: str = Query(""),
):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return

View File

@@ -4,6 +4,7 @@ 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_output_target_store,
)
@@ -18,6 +19,7 @@ from wled_controller.storage.key_colors_output_target import KeyColorRectangle
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -36,7 +38,7 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
tags=getattr(t, 'tags', []),
tags=t.tags,
)
@@ -73,7 +75,11 @@ async def create_pattern_template(
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:
@@ -117,7 +123,11 @@ async def update_pattern_template(
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:
@@ -143,8 +153,12 @@ async def delete_pattern_template(
"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
@@ -12,6 +13,7 @@ from fastapi.responses import Response
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_picture_source_store,
get_output_target_store,
get_pp_template_store,
@@ -37,8 +39,9 @@ from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -60,7 +63,15 @@ def _stream_to_response(s) -> PictureSourceResponse:
created_at=s.created_at,
updated_at=s.updated_at,
description=s.description,
tags=getattr(s, 'tags', []),
tags=s.tags,
# Video fields
url=getattr(s, "url", None),
loop=getattr(s, "loop", None),
playback_speed=getattr(s, "playback_speed", None),
start_time=getattr(s, "start_time", None),
end_time=getattr(s, "end_time", None),
resolution_limit=getattr(s, "resolution_limit", None),
clock_id=getattr(s, "clock_id", None),
)
@@ -96,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
@@ -139,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
@@ -198,10 +216,22 @@ async def create_picture_source(
image_source=data.image_source,
description=data.description,
tags=data.tags,
# Video fields
url=data.url,
loop=data.loop,
playback_speed=data.playback_speed,
start_time=data.start_time,
end_time=data.end_time,
resolution_limit=data.resolution_limit,
clock_id=data.clock_id,
)
fire_entity_event("picture_source", "created", stream.id)
return _stream_to_response(stream)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -243,8 +273,20 @@ async def update_picture_source(
image_source=data.image_source,
description=data.description,
tags=data.tags,
# Video fields
url=data.url,
loop=data.loop,
playback_speed=data.playback_speed,
start_time=data.start_time,
end_time=data.end_time,
resolution_limit=data.resolution_limit,
clock_id=data.clock_id,
)
fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -271,8 +313,12 @@ async def delete_picture_source(
"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:
@@ -280,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,
@@ -301,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))
@@ -322,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
@@ -389,48 +484,50 @@ async def test_picture_source(
else:
raise ValueError("Unexpected image format from engine")
# Create thumbnail
thumbnail_width = 640
aspect_ratio = pil_image.height / pil_image.width
thumbnail_height = int(thumbnail_width * aspect_ratio)
thumbnail = pil_image.copy()
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
# Apply postprocessing filters if this is a processed stream
# Create thumbnail + encode (CPU-bound — run in thread)
pp_template_ids = chain["postprocessing_template_ids"]
flat_filters = None
if pp_template_ids:
try:
pp_template = pp_store.get_template(pp_template_ids[0])
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
if flat_filters:
pool = ImagePool()
def apply_filters(img):
arr = np.array(img)
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)
thumbnail = apply_filters(thumbnail)
pil_image = apply_filters(pil_image)
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")
# Encode thumbnail
img_buffer = io.BytesIO()
thumbnail.save(img_buffer, format='JPEG', quality=85)
img_buffer.seek(0)
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
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)
# 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')
if filters:
pool = ImagePool()
def apply_filters(img):
arr = np.array(img)
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)
img_buffer = io.BytesIO()
thumb.save(img_buffer, format='JPEG', quality=85)
thumb_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}"
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
@@ -457,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:
@@ -516,6 +616,86 @@ async def test_picture_source_ws(
await websocket.close(code=4003, reason="Static image streams don't support live test")
return
# Video sources: use VideoCaptureLiveStream for test preview
if isinstance(raw_stream, VideoCaptureSource):
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
await websocket.accept()
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
video_stream = VideoCaptureLiveStream(
url=raw_stream.url,
loop=raw_stream.loop,
playback_speed=raw_stream.playback_speed,
start_time=raw_stream.start_time,
end_time=raw_stream.end_time,
resolution_limit=raw_stream.resolution_limit,
target_fps=raw_stream.target_fps,
)
def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI."""
from PIL import Image as PILImage
pil = PILImage.fromarray(image)
if pw and pil.width > pw:
ratio = pw / pil.width
pil = pil.resize((pw, int(pil.height * ratio)), PILImage.LANCZOS)
buf = io.BytesIO()
pil.save(buf, format="JPEG", quality=80)
b64 = base64.b64encode(buf.getvalue()).decode()
return f"data:image/jpeg;base64,{b64}", pil.width, pil.height
try:
await asyncio.get_event_loop().run_in_executor(None, video_stream.start)
import time as _time
fps = min(raw_stream.target_fps or 30, 30)
frame_time = 1.0 / fps
end_at = _time.monotonic() + duration
frame_count = 0
last_frame = None
while _time.monotonic() < end_at:
frame = video_stream.get_latest_frame()
if frame is not None and frame.image is not None and frame is not last_frame:
last_frame = frame
frame_count += 1
thumb, w, h = await asyncio.get_event_loop().run_in_executor(
None, _encode_video_frame, frame.image, preview_width or None,
)
elapsed = duration - (end_at - _time.monotonic())
await websocket.send_json({
"type": "frame",
"thumbnail": thumb,
"width": w, "height": h,
"elapsed": round(elapsed, 1),
"frame_count": frame_count,
})
await asyncio.sleep(frame_time)
# Send final result
if last_frame is not None:
full_img, fw, fh = await asyncio.get_event_loop().run_in_executor(
None, _encode_video_frame, last_frame.image, None,
)
await websocket.send_json({
"type": "result",
"full_image": full_img,
"width": fw, "height": fh,
"total_frames": frame_count,
"duration": duration,
"avg_fps": round(frame_count / max(duration, 0.001), 1),
})
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Video source test WS error for {stream_id}: {e}")
try:
await websocket.send_json({"type": "error", "detail": str(e)})
except Exception:
pass
finally:
video_stream.stop()
logger.info(f"Video source test WS disconnected for {stream_id}")
return
if not isinstance(raw_stream, ScreenCapturePictureSource):
await websocket.close(code=4003, reason="Unsupported stream type for live test")
return

View File

@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_picture_source_store,
get_pp_template_store,
get_template_store,
@@ -35,6 +36,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -50,7 +52,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
tags=getattr(t, 'tags', []),
tags=t.tags,
)
@@ -60,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)
@@ -84,7 +82,11 @@ async def create_pp_template(
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,7 +125,11 @@ async def update_pp_template(
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:
@@ -150,8 +156,12 @@ async def delete_pp_template(
"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:
@@ -180,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))
@@ -323,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:

View File

@@ -7,6 +7,7 @@ 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,
@@ -27,6 +28,7 @@ from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
@@ -45,7 +47,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
"fps": t.fps,
} for t in preset.targets],
order=preset.order,
tags=getattr(preset, 'tags', []),
tags=preset.tags,
created_at=preset.created_at,
updated_at=preset.updated_at,
)
@@ -84,9 +86,13 @@ async def create_scene_preset(
try:
preset = store.create_preset(preset)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_preset", "created", preset.id)
return _preset_to_response(preset)
@@ -175,6 +181,7 @@ async def update_scene_preset(
)
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)
@@ -194,6 +201,7 @@ async def delete_scene_preset(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("scene_preset", "deleted", preset_id)
# ===== Recapture =====
@@ -259,4 +267,5 @@ async def activate_scene_preset(
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

@@ -4,6 +4,7 @@ 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,
@@ -19,6 +20,7 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -33,7 +35,7 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
name=clock.name,
speed=rt.speed if rt else clock.speed,
description=clock.description,
tags=getattr(clock, 'tags', []),
tags=clock.tags,
is_running=rt.is_running if rt else True,
elapsed_time=rt.get_time() if rt else 0.0,
created_at=clock.created_at,
@@ -70,7 +72,11 @@ async def create_sync_clock(
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))
@@ -110,7 +116,11 @@ async def update_sync_clock(
# 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))
@@ -133,6 +143,10 @@ async def delete_sync_clock(
)
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -152,6 +166,7 @@ async def pause_sync_clock(
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)
@@ -168,6 +183,7 @@ async def resume_sync_clock(
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)
@@ -184,4 +200,5 @@ async def reset_sync_clock(
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

@@ -3,6 +3,7 @@
import asyncio
import io
import json
import logging
import platform
import subprocess
import sys
@@ -12,7 +13,7 @@ from pathlib import Path
from typing import Optional
import psutil
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
@@ -42,8 +43,14 @@ from wled_controller.api.schemas.system import (
BackupListResponse,
DisplayInfo,
DisplayListResponse,
ExternalUrlRequest,
ExternalUrlResponse,
GpuInfo,
HealthResponse,
LogLevelRequest,
LogLevelResponse,
MQTTSettingsRequest,
MQTTSettingsResponse,
PerformanceResponse,
ProcessListResponse,
RestoreResponse,
@@ -59,19 +66,9 @@ logger = get_logger(__name__)
# Prime psutil CPU counter (first call always returns 0.0)
psutil.cpu_percent(interval=None)
# Try to initialize NVIDIA GPU monitoring
_nvml_available = False
try:
import pynvml as _pynvml_mod # nvidia-ml-py (the pynvml wrapper is deprecated)
_pynvml_mod.nvmlInit()
_nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0)
_nvml_available = True
_nvml = _pynvml_mod
logger.info(f"NVIDIA GPU monitoring enabled: {_nvml.nvmlDeviceGetName(_nvml_handle)}")
except Exception:
_nvml = None
logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)")
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
from wled_controller.storage.base_store import EntityNotFoundError
def _get_cpu_name() -> str | None:
@@ -100,8 +97,8 @@ def _get_cpu_name() -> str | None:
.decode()
.strip()
)
except Exception:
pass
except Exception as e:
logger.warning("CPU name detection failed: %s", e)
return platform.processor() or None
@@ -156,20 +153,12 @@ async def list_all_tags(_: AuthRequired):
store = getter()
except RuntimeError:
continue
# Each store has a different "get all" method name
items = None
for method_name in (
"get_all_devices", "get_all_targets", "get_all_sources",
"get_all_streams", "get_all_clocks", "get_all_automations",
"get_all_presets", "get_all_templates",
):
fn = getattr(store, method_name, None)
if fn is not None:
items = fn()
break
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
items = fn() if fn else None
if items:
for item in items:
all_tags.update(getattr(item, 'tags', []))
all_tags.update(item.tags)
return {"tags": sorted(all_tags)}
@@ -191,9 +180,9 @@ async def get_displays(
from wled_controller.core.capture_engines import EngineRegistry
engine_cls = EngineRegistry.get_engine(engine_type)
display_dataclasses = engine_cls.get_available_displays()
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
else:
display_dataclasses = get_available_displays()
display_dataclasses = await asyncio.to_thread(get_available_displays)
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
displays = [
@@ -217,6 +206,10 @@ async def get_displays(
count=len(displays),
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -277,8 +270,8 @@ def get_system_performance(_: AuthRequired):
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
temperature_c=float(temp),
)
except Exception:
pass
except Exception as e:
logger.debug("NVML query failed: %s", e)
return PerformanceResponse(
cpu_name=_cpu_name,
@@ -321,6 +314,7 @@ STORE_MAP = {
"audio_templates": "audio_templates_file",
"value_sources": "value_sources_file",
"sync_clocks": "sync_clocks_file",
"color_strip_processing_templates": "color_strip_processing_templates_file",
"automations": "automations_file",
"scene_presets": "scene_presets_file",
}
@@ -349,6 +343,125 @@ def _schedule_restart() -> None:
threading.Thread(target=_restart, daemon=True).start()
@router.get("/api/v1/system/api-keys", tags=["System"])
def list_api_keys(_: AuthRequired):
"""List API key labels (read-only; keys are defined in the YAML config file)."""
config = get_config()
keys = [
{"label": label, "masked": key[:4] + "****" + key[-4:] if len(key) >= 8 else "****"}
for label, key in config.auth.api_keys.items()
]
return {"keys": keys, "count": len(keys)}
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
def export_store(store_key: str, _: AuthRequired):
"""Download a single entity store as a JSON file."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
export = {
"meta": {
"format": "ledgrab-partial-export",
"format_version": 1,
"store_key": store_key,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
},
"store": data,
}
content = json.dumps(export, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-{store_key}-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
async def import_store(
store_key: str,
_: AuthRequired,
file: UploadFile = File(...),
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
):
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
payload = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
# Support both full-backup format and partial-export format
if "stores" in payload and isinstance(payload.get("meta"), dict):
# Full backup: extract the specific store
if payload["meta"].get("format") not in ("ledgrab-backup",):
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
stores = payload.get("stores", {})
if store_key not in stores:
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
incoming = stores[store_key]
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
# Partial export format
if payload["meta"].get("store_key") != store_key:
raise HTTPException(
status_code=400,
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
)
incoming = payload.get("store", {})
else:
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
if not isinstance(incoming, dict):
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
def _write():
if merge and file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
existing = json.load(f)
if isinstance(existing, dict):
existing.update(incoming)
atomic_write_json(file_path, existing)
return len(existing)
atomic_write_json(file_path, incoming)
return len(incoming)
count = await asyncio.to_thread(_write)
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
_schedule_restart()
return {
"status": "imported",
"store_key": store_key,
"entries": count,
"merge": merge,
"restart_scheduled": True,
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
}
@router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired):
"""Download all configuration as a single JSON backup file."""
@@ -384,6 +497,13 @@ def backup_config(_: AuthRequired):
)
@router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
_schedule_restart()
return {"status": "restarting"}
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
async def restore_config(
_: AuthRequired,
@@ -489,6 +609,16 @@ async def update_auto_backup_settings(
)
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
async def trigger_backup(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Manually trigger a backup now."""
backup = await engine.trigger_backup()
return {"status": "ok", "backup": backup}
@router.get(
"/api/v1/system/backups",
response_model=BackupListResponse,
@@ -540,6 +670,217 @@ async def delete_saved_backup(
return {"status": "deleted", "filename": filename}
# ---------------------------------------------------------------------------
# MQTT settings
# ---------------------------------------------------------------------------
_MQTT_SETTINGS_FILE: Path | None = None
def _get_mqtt_settings_path() -> Path:
global _MQTT_SETTINGS_FILE
if _MQTT_SETTINGS_FILE is None:
cfg = get_config()
# Derive the data directory from any known storage file path
data_dir = Path(cfg.storage.devices_file).parent
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
return _MQTT_SETTINGS_FILE
def _load_mqtt_settings() -> dict:
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
cfg = get_config()
defaults = {
"enabled": cfg.mqtt.enabled,
"broker_host": cfg.mqtt.broker_host,
"broker_port": cfg.mqtt.broker_port,
"username": cfg.mqtt.username,
"password": cfg.mqtt.password,
"client_id": cfg.mqtt.client_id,
"base_topic": cfg.mqtt.base_topic,
}
path = _get_mqtt_settings_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
overrides = json.load(f)
defaults.update(overrides)
except Exception as e:
logger.warning(f"Failed to load MQTT settings override file: {e}")
return defaults
def _save_mqtt_settings(settings: dict) -> None:
"""Persist MQTT settings to the JSON override file."""
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_mqtt_settings_path(), settings)
@router.get(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def get_mqtt_settings(_: AuthRequired):
"""Get current MQTT broker settings. Password is masked."""
s = _load_mqtt_settings()
return MQTTSettingsResponse(
enabled=s["enabled"],
broker_host=s["broker_host"],
broker_port=s["broker_port"],
username=s["username"],
password_set=bool(s.get("password")),
client_id=s["client_id"],
base_topic=s["base_topic"],
)
@router.put(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings()
# If caller sends an empty password, keep the existing one
password = body.password if body.password else current.get("password", "")
new_settings = {
"enabled": body.enabled,
"broker_host": body.broker_host,
"broker_port": body.broker_port,
"username": body.username,
"password": password,
"client_id": body.client_id,
"base_topic": body.base_topic,
}
_save_mqtt_settings(new_settings)
logger.info("MQTT settings updated")
return MQTTSettingsResponse(
enabled=new_settings["enabled"],
broker_host=new_settings["broker_host"],
broker_port=new_settings["broker_port"],
username=new_settings["username"],
password_set=bool(new_settings["password"]),
client_id=new_settings["client_id"],
base_topic=new_settings["base_topic"],
)
# ---------------------------------------------------------------------------
# External URL setting
# ---------------------------------------------------------------------------
_EXTERNAL_URL_FILE: Path | None = None
def _get_external_url_path() -> Path:
global _EXTERNAL_URL_FILE
if _EXTERNAL_URL_FILE is None:
cfg = get_config()
data_dir = Path(cfg.storage.devices_file).parent
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
return _EXTERNAL_URL_FILE
def load_external_url() -> str:
"""Load the external URL setting. Returns empty string if not set."""
path = _get_external_url_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("external_url", "")
except Exception:
pass
return ""
def _save_external_url(url: str) -> None:
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_external_url_path(), {"external_url": url})
@router.get(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def get_external_url(_: AuthRequired):
"""Get the configured external base URL."""
return ExternalUrlResponse(external_url=load_external_url())
@router.put(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/")
_save_external_url(url)
logger.info("External URL updated: %s", url or "(cleared)")
return ExternalUrlResponse(external_url=url)
# ---------------------------------------------------------------------------
# Live log viewer WebSocket
# ---------------------------------------------------------------------------
@router.websocket("/api/v1/system/logs/ws")
async def logs_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket that streams server log lines in real time.
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
lines as individual text messages, then pushes new lines as they appear.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.utils import log_broadcaster
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
# Ensure the broadcaster knows the event loop (may be first connection)
log_broadcaster.ensure_loop()
# Subscribe *before* reading the backlog so no lines slip through
queue = log_broadcaster.subscribe()
try:
# Send backlog first
for line in log_broadcaster.get_backlog():
await websocket.send_text(line)
# Stream new lines
while True:
try:
line = await asyncio.wait_for(queue.get(), timeout=30.0)
await websocket.send_text(line)
except asyncio.TimeoutError:
# Send a keepalive ping so the connection stays alive
try:
await websocket.send_text("")
except Exception:
break
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
log_broadcaster.unsubscribe(queue)
# ---------------------------------------------------------------------------
# ADB helpers (for Android / scrcpy engine)
# ---------------------------------------------------------------------------
@@ -569,11 +910,13 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
adb = _get_adb_path()
logger.info(f"Connecting ADB device: {address}")
try:
result = subprocess.run(
[adb, "connect", address],
capture_output=True, text=True, timeout=10,
proc = await asyncio.create_subprocess_exec(
adb, "connect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
output = (result.stdout + result.stderr).strip()
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = (stdout.decode() + stderr.decode()).strip()
if "connected" in output.lower():
return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed")
@@ -582,7 +925,7 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
status_code=500,
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
)
except subprocess.TimeoutExpired:
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB connect timed out")
@@ -596,12 +939,45 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
adb = _get_adb_path()
logger.info(f"Disconnecting ADB device: {address}")
try:
result = subprocess.run(
[adb, "disconnect", address],
capture_output=True, text=True, timeout=10,
proc = await asyncio.create_subprocess_exec(
adb, "disconnect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
return {"status": "disconnected", "message": result.stdout.strip()}
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
return {"status": "disconnected", "message": stdout.decode().strip()}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH")
except subprocess.TimeoutExpired:
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
# ─── Log level ─────────────────────────────────────────────────
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
@router.get("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def get_log_level(_: AuthRequired):
"""Get the current root logger log level."""
level_int = logging.getLogger().getEffectiveLevel()
return LogLevelResponse(level=logging.getLevelName(level_int))
@router.put("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def set_log_level(_: AuthRequired, body: LogLevelRequest):
"""Change the root logger log level at runtime (no server restart required)."""
level_name = body.level.upper()
if level_name not in _VALID_LOG_LEVELS:
raise HTTPException(
status_code=400,
detail=f"Invalid log level '{body.level}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}",
)
level_int = getattr(logging, level_name)
root = logging.getLogger()
root.setLevel(level_int)
# Also update all handlers so they actually emit at the new level
for handler in root.handlers:
handler.setLevel(level_int)
logger.info("Log level changed to %s", level_name)
return LogLevelResponse(level=level_name)

View File

@@ -10,6 +10,8 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_cspt_store,
get_picture_source_store,
get_pp_template_store,
get_template_store,
@@ -39,6 +41,7 @@ from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -62,7 +65,7 @@ async def list_templates(
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=getattr(t, 'tags', []),
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
@@ -96,17 +99,22 @@ async def create_template(
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=getattr(template, 'tags', []),
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -131,7 +139,7 @@ async def get_template(
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=getattr(template, 'tags', []),
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
@@ -156,17 +164,22 @@ async def update_template(
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=getattr(template, 'tags', []),
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -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:
@@ -354,6 +371,10 @@ def test_template(
),
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
@@ -475,3 +496,48 @@ async def list_filter_types(
options_schema=opt_schemas,
))
return FilterTypeListResponse(filters=responses, count=len(responses))
@router.get("/api/v1/strip-filters", response_model=FilterTypeListResponse, tags=["Filters"])
async def list_strip_filter_types(
_auth: AuthRequired,
cspt_store=Depends(get_cspt_store),
):
"""List filter types that support 1D LED strip processing."""
all_filters = FilterRegistry.get_all()
# Pre-build template choices for the css_filter_template filter
cspt_choices = None
if cspt_store:
try:
templates = cspt_store.get_all_templates()
cspt_choices = [{"value": t.id, "label": t.name} for t in templates]
except Exception:
cspt_choices = []
responses = []
for filter_id, filter_cls in all_filters.items():
if not getattr(filter_cls, "supports_strip", True):
continue
schema = filter_cls.get_options_schema()
opt_schemas = []
for opt in schema:
choices = opt.choices
if filter_id == "css_filter_template" and opt.key == "template_id" and cspt_choices is not None:
choices = cspt_choices
opt_schemas.append(FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
choices=choices,
))
responses.append(FilterTypeResponse(
filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name,
options_schema=opt_schemas,
))
return FilterTypeListResponse(filters=responses, count=len(responses))

View File

@@ -1,18 +1,17 @@
"""Value source routes: CRUD for value sources."""
import asyncio
import secrets
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.config import get_config
from wled_controller.api.schemas.value_sources import (
ValueSourceCreate,
ValueSourceListResponse,
@@ -24,6 +23,7 @@ from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -50,6 +50,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
schedule=d.get("schedule"),
picture_source_id=d.get("picture_source_id"),
scene_behavior=d.get("scene_behavior"),
use_real_time=d.get("use_real_time"),
latitude=d.get("latitude"),
description=d.get("description"),
tags=d.get("tags", []),
created_at=source.created_at,
@@ -98,9 +100,15 @@ async def create_value_source(
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
)
fire_entity_event("value_source", "created", source.id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -146,11 +154,17 @@ async def update_value_source(
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
)
# Hot-reload running value streams
pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -174,6 +188,10 @@ async def delete_value_source(
)
store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -192,16 +210,8 @@ async def test_value_source_ws(
Acquires a ValueStream for the given source, polls get_value() at ~20 Hz,
and streams {value: float} JSON to the client.
"""
# Authenticate
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return

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

@@ -3,7 +3,7 @@
from datetime import datetime
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from wled_controller.api.schemas.devices import Calibration
@@ -31,9 +31,11 @@ class CompositeLayer(BaseModel):
"""A single layer in a composite color strip source."""
source_id: str = Field(description="ID of the layer's color strip source")
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen")
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen|override")
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
enabled: bool = Field(default=True, description="Whether this layer is active")
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID")
class MappedZone(BaseModel):
@@ -49,12 +51,9 @@ class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification"] = Field(default="picture", description="Source type")
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0)
gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0)
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
@@ -82,7 +81,6 @@ class ColorStripSourceCreate(BaseModel):
# shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
frame_interpolation: bool = Field(default=False, description="Blend between consecutive captured frames for smoother output")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
@@ -95,6 +93,15 @@ class ColorStripSourceCreate(BaseModel):
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")
@@ -106,9 +113,6 @@ class ColorStripSourceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
# picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0)
gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
@@ -136,7 +140,6 @@ class ColorStripSourceUpdate(BaseModel):
# shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
@@ -149,6 +152,15 @@ class ColorStripSourceUpdate(BaseModel):
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
@@ -162,9 +174,6 @@ class ColorStripSourceResponse(BaseModel):
source_type: str = Field(description="Source type")
# picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
brightness: Optional[float] = Field(None, description="Brightness multiplier")
saturation: Optional[float] = Field(None, description="Saturation")
gamma: Optional[float] = Field(None, description="Gamma correction")
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
@@ -192,7 +201,6 @@ class ColorStripSourceResponse(BaseModel):
# shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description")
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
@@ -205,6 +213,15 @@ class ColorStripSourceResponse(BaseModel):
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")
@@ -220,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
count: int = Field(description="Number of sources")
class ColorPushRequest(BaseModel):
"""Request to push raw LED colors to an api_input source."""
class SegmentPayload(BaseModel):
"""A single segment for segment-based LED color updates."""
colors: List[List[int]] = Field(description="LED color array [[R,G,B], ...] (0-255 each)")
start: int = Field(ge=0, description="Starting LED index")
length: int = Field(ge=1, description="Number of LEDs in segment")
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field(None, description="Colors for per_pixel/gradient [[R,G,B],...]")
@model_validator(mode="after")
def _validate_mode_fields(self) -> "SegmentPayload":
if self.mode == "solid":
if self.color is None or len(self.color) != 3:
raise ValueError("solid mode requires 'color' as a list of 3 ints [R,G,B]")
if not all(0 <= c <= 255 for c in self.color):
raise ValueError("solid color values must be 0-255")
elif self.mode == "per_pixel":
if not self.colors:
raise ValueError("per_pixel mode requires non-empty 'colors' list")
for c in self.colors:
if len(c) != 3:
raise ValueError("each color in per_pixel must be [R,G,B]")
elif self.mode == "gradient":
if not self.colors or len(self.colors) < 2:
raise ValueError("gradient mode requires 'colors' with at least 2 stops")
for c in self.colors:
if len(c) != 3:
raise ValueError("each color stop in gradient must be [R,G,B]")
return self
class ColorPushRequest(BaseModel):
"""Request to push raw LED colors to an api_input source.
Accepts either 'colors' (legacy flat array) or 'segments' (new segment-based).
At least one must be provided.
"""
colors: Optional[List[List[int]]] = Field(None, description="LED color array [[R,G,B], ...] (0-255 each)")
segments: Optional[List[SegmentPayload]] = Field(None, description="Segment-based color updates")
@model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest":
if self.colors is None and self.segments is None:
raise ValueError("Either 'colors' or 'segments' must be provided")
return self
class NotifyRequest(BaseModel):

View File

@@ -19,6 +19,25 @@ class DeviceCreate(BaseModel):
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
# DMX (Art-Net / sACN) fields
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
# ESP-NOW fields
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)")
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)")
# Philips Hue fields
hue_username: Optional[str] = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: Optional[str] = Field(None, description="Hue entertainment group/zone ID")
# SPI Direct fields
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed in Hz")
spi_led_type: Optional[str] = Field(None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW")
# Razer Chroma fields
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad")
# SteelSeries GameSense fields
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator")
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
class DeviceUpdate(BaseModel):
@@ -34,6 +53,19 @@ class DeviceUpdate(BaseModel):
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
tags: Optional[List[str]] = None
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address")
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
hue_username: Optional[str] = Field(None, description="Hue bridge username")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: Optional[str] = Field(None, description="Hue entertainment group ID")
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
class CalibrationLineSchema(BaseModel):
@@ -128,6 +160,19 @@ class DeviceResponse(BaseModel):
zone_mode: str = Field(default="combined", description="OpenRGB zone mode: combined or separate")
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
dmx_start_universe: int = Field(default=0, description="DMX start universe")
dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)")
espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address")
espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel")
hue_username: str = Field(default="", description="Hue bridge username")
hue_client_key: str = Field(default="", description="Hue entertainment client key")
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
gamesense_device_type: str = Field(default="keyboard", description="GameSense device type")
default_css_processing_template_id: str = Field(default="", description="Default color strip processing template ID")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")

View File

@@ -177,6 +177,20 @@ class TargetMetricsResponse(BaseModel):
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
class BulkTargetRequest(BaseModel):
"""Request body for bulk start/stop operations."""
ids: List[str] = Field(description="List of target IDs to operate on")
class BulkTargetResponse(BaseModel):
"""Response for bulk start/stop operations."""
started: List[str] = Field(default_factory=list, description="IDs that were successfully started")
stopped: List[str] = Field(default_factory=list, description="IDs that were successfully stopped")
errors: Dict[str, str] = Field(default_factory=dict, description="Map of target ID to error message for failures")
class KCTestRectangleResponse(BaseModel):
"""A rectangle with its extracted color from a KC test."""

View File

@@ -10,15 +10,23 @@ class PictureSourceCreate(BaseModel):
"""Request to create a picture source."""
name: str = Field(description="Stream name", min_length=1, max_length=100)
stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type")
stream_type: Literal["raw", "processed", "static_image", "video"] = Field(description="Stream type")
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=1, le=90)
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
# Video fields
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
class PictureSourceUpdate(BaseModel):
@@ -27,12 +35,20 @@ class PictureSourceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=1, le=90)
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
# Video fields
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
class PictureSourceResponse(BaseModel):
@@ -40,7 +56,7 @@ class PictureSourceResponse(BaseModel):
id: str = Field(description="Stream ID")
name: str = Field(description="Stream name")
stream_type: str = Field(description="Stream type (raw, processed, or static_image)")
stream_type: str = Field(description="Stream type (raw, processed, static_image, or video)")
display_index: Optional[int] = Field(None, description="Display index")
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS")
@@ -51,6 +67,14 @@ class PictureSourceResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Stream description")
# Video fields
url: Optional[str] = Field(None, description="Video URL")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
end_time: Optional[float] = Field(None, description="Trim end time in seconds")
resolution_limit: Optional[int] = Field(None, description="Max width for decode")
clock_id: Optional[str] = Field(None, description="Sync clock ID")
class PictureSourceListResponse(BaseModel):

View File

@@ -115,3 +115,57 @@ class BackupListResponse(BaseModel):
backups: List[BackupFileInfo]
count: int
# ─── MQTT schemas ──────────────────────────────────────────────
class MQTTSettingsResponse(BaseModel):
"""MQTT broker settings response (password is masked)."""
enabled: bool = Field(description="Whether MQTT is enabled")
broker_host: str = Field(description="MQTT broker hostname or IP")
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
username: str = Field(description="MQTT username (empty = anonymous)")
password_set: bool = Field(description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
class MQTTSettingsRequest(BaseModel):
"""MQTT broker settings update request."""
enabled: bool = Field(description="Whether MQTT is enabled")
broker_host: str = Field(description="MQTT broker hostname or IP")
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
username: str = Field(default="", description="MQTT username (empty = anonymous)")
password: str = Field(default="", description="MQTT password (empty = keep existing if omitted)")
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
# ─── External URL schema ───────────────────────────────────────
class ExternalUrlResponse(BaseModel):
"""External URL setting response."""
external_url: str = Field(description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL.")
class ExternalUrlRequest(BaseModel):
"""External URL setting update request."""
external_url: str = Field(default="", description="External base URL. Empty string to clear.")
# ─── Log level schemas ─────────────────────────────────────────
class LogLevelResponse(BaseModel):
"""Current log level response."""
level: str = Field(description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)")
class LogLevelRequest(BaseModel):
"""Request to change the log level."""
level: str = Field(description="New log level name (DEBUG, INFO, WARNING, ERROR, CRITICAL)")

View File

@@ -10,12 +10,12 @@ class ValueSourceCreate(BaseModel):
"""Request to create a value source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene"] = Field(description="Source type")
source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"] = Field(description="Source type")
# static fields
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Cycles per minute (1.0-120.0)", ge=1.0, le=120.0)
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields
@@ -28,6 +28,9 @@ class ValueSourceCreate(BaseModel):
schedule: Optional[list] = Field(None, description="Time-of-day schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
# daylight fields
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -40,7 +43,7 @@ class ValueSourceUpdate(BaseModel):
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Cycles per minute (1.0-120.0)", ge=1.0, le=120.0)
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields
@@ -53,6 +56,9 @@ class ValueSourceUpdate(BaseModel):
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
# daylight fields
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
@@ -76,6 +82,8 @@ class ValueSourceResponse(BaseModel):
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")

View File

@@ -39,6 +39,7 @@ class StorageConfig(BaseSettings):
value_sources_file: str = "data/value_sources.json"
automations_file: str = "data/automations.json"
scene_presets_file: str = "data/scene_presets.json"
color_strip_processing_templates_file: str = "data/color_strip_processing_templates.json"
sync_clocks_file: str = "data/sync_clocks.json"
@@ -92,7 +93,7 @@ class Config(BaseSettings):
if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with open(config_path, "r") as f:
with open(config_path, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f)
return cls(**config_data)

View File

@@ -393,13 +393,13 @@ class AutomationEngine:
def _fire_event(self, automation_id: str, action: str) -> None:
try:
self._manager._fire_event({
self._manager.fire_event({
"type": "automation_state_changed",
"automation_id": automation_id,
"action": action,
})
except Exception:
pass
except Exception as e:
logger.error("Automation action failed: %s", e, exc_info=True)
# ===== Public query methods (used by API) =====

View File

@@ -211,6 +211,14 @@ class AutoBackupEngine:
raise ValueError("Invalid filename")
return target
async def trigger_backup(self) -> dict:
"""Manually trigger a backup and prune old ones. Returns the created backup info."""
await self._perform_backup()
self._prune_old_backups()
# Return the most recent backup entry
backups = self.list_backups()
return backups[0] if backups else {}
def delete_backup(self, filename: str) -> None:
target = self._safe_backup_path(filename)
if not target.exists():

View File

@@ -10,8 +10,9 @@ Prerequisites (optional dependency):
import platform
import sys
import threading
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Set
import numpy as np
@@ -26,6 +27,13 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
# Process-wide registry of cv2 camera indices currently held open.
# Prevents _enumerate_cameras from probing an in-use camera (which can
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
# from opening the same physical camera concurrently.
_active_cv2_indices: Set[int] = set()
_camera_lock = threading.Lock()
_CV2_BACKENDS = {
"auto": None,
"dshow": 700, # cv2.CAP_DSHOW
@@ -103,7 +111,29 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
cameras: List[Dict[str, Any]] = []
sequential_idx = 0
with _camera_lock:
active = set(_active_cv2_indices)
for i in range(max_probe):
if i in active:
# Camera already held open — use cached metadata if available,
# otherwise add a placeholder so display_index mapping stays stable.
if _camera_cache is not None:
prev = [c for c in _camera_cache if c["cv2_index"] == i]
if prev:
cameras.append(prev[0])
sequential_idx += 1
continue
cameras.append({
"cv2_index": i,
"name": friendly_names.get(sequential_idx, f"Camera {sequential_idx}"),
"width": 0,
"height": 0,
"fps": 30.0,
})
sequential_idx += 1
continue
if backend_id is not None:
cap = cv2.VideoCapture(i, backend_id)
else:
@@ -149,6 +179,7 @@ class CameraCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._cap = None
self._cv2_index: Optional[int] = None
def initialize(self) -> None:
if self._initialized:
@@ -173,18 +204,34 @@ class CameraCaptureStream(CaptureStream):
camera = cameras[self.display_index]
cv2_index = camera["cv2_index"]
# Open the camera
backend_id = _cv2_backend_id(backend_name)
if backend_id is not None:
self._cap = cv2.VideoCapture(cv2_index, backend_id)
else:
self._cap = cv2.VideoCapture(cv2_index)
# Prevent concurrent opens of the same physical camera (crashes DSHOW)
with _camera_lock:
if cv2_index in _active_cv2_indices:
raise RuntimeError(
f"Camera {self.display_index} (cv2 index {cv2_index}) "
f"is already in use by another stream"
)
_active_cv2_indices.add(cv2_index)
if not self._cap.isOpened():
raise RuntimeError(
f"Failed to open camera {self.display_index} "
f"(cv2 index {cv2_index})"
)
try:
# Open the camera
backend_id = _cv2_backend_id(backend_name)
if backend_id is not None:
self._cap = cv2.VideoCapture(cv2_index, backend_id)
else:
self._cap = cv2.VideoCapture(cv2_index)
if not self._cap.isOpened():
raise RuntimeError(
f"Failed to open camera {self.display_index} "
f"(cv2 index {cv2_index})"
)
except Exception:
with _camera_lock:
_active_cv2_indices.discard(cv2_index)
raise
self._cv2_index = cv2_index
# Apply optional resolution override
res_w = self.config.get("resolution_width", 0)
@@ -198,6 +245,9 @@ class CameraCaptureStream(CaptureStream):
if not ret or frame is None:
self._cap.release()
self._cap = None
with _camera_lock:
_active_cv2_indices.discard(cv2_index)
self._cv2_index = None
raise RuntimeError(
f"Camera {self.display_index} opened but test read failed"
)
@@ -234,6 +284,10 @@ class CameraCaptureStream(CaptureStream):
if self._cap is not None:
self._cap.release()
self._cap = None
if self._cv2_index is not None:
with _camera_lock:
_active_cv2_indices.discard(self._cv2_index)
self._cv2_index = None
self._initialized = False
logger.info(f"Camera capture stream cleaned up (display={self.display_index})")

View File

@@ -168,9 +168,7 @@ class AdalightClient(LEDClient):
else:
arr = np.array(pixels, dtype=np.uint16)
if brightness < 255:
arr = arr * brightness // 255
# Note: brightness already applied by processor loop (_cached_brightness)
np.clip(arr, 0, 255, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes()
return self._header + rgb_bytes

View File

@@ -13,10 +13,8 @@ class AdalightDeviceProvider(SerialDeviceProvider):
def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.devices.adalight_client import AdalightClient
led_count = kwargs.pop("led_count", 0)
baud_rate = kwargs.pop("baud_rate", None)
kwargs.pop("use_ddp", None) # Not applicable for serial
kwargs.pop("send_latency_ms", None)
kwargs.pop("rgbw", None)
return AdalightClient(url, led_count=led_count, baud_rate=baud_rate)
return AdalightClient(
url,
led_count=kwargs.get("led_count", 0),
baud_rate=kwargs.get("baud_rate"),
)

View File

@@ -40,9 +40,7 @@ class AmbiLEDClient(AdalightClient):
else:
arr = np.array(pixels, dtype=np.uint16)
if brightness < 255:
arr = arr * brightness // 255
# Note: brightness already applied by processor loop (_cached_brightness)
# Clamp to 0250: values >250 are command bytes in AmbiLED protocol
np.clip(arr, 0, 250, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes()

View File

@@ -13,10 +13,8 @@ class AmbiLEDDeviceProvider(SerialDeviceProvider):
def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.devices.ambiled_client import AmbiLEDClient
led_count = kwargs.pop("led_count", 0)
baud_rate = kwargs.pop("baud_rate", None)
kwargs.pop("use_ddp", None)
kwargs.pop("send_latency_ms", None)
kwargs.pop("rgbw", None)
return AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate)
return AmbiLEDClient(
url,
led_count=kwargs.get("led_count", 0),
baud_rate=kwargs.get("baud_rate"),
)

View File

@@ -0,0 +1,226 @@
"""Razer Chroma SDK LED client — controls Razer RGB peripherals via REST API."""
import asyncio
import json
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
import numpy as np
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Chroma SDK REST API base
CHROMA_SDK_URL = "http://localhost:54235/razer/rest"
# Device type → (endpoint suffix, max LEDs)
CHROMA_DEVICES = {
"keyboard": ("keyboard", 132), # 22 columns × 6 rows
"mouse": ("mouse", 30),
"mousepad": ("mousepad", 15),
"headset": ("headset", 5), # left + right + 3 zones
"chromalink": ("chromalink", 5),
"keypad": ("keypad", 20), # 5×4 grid
}
class ChromaClient(LEDClient):
"""LED client that controls Razer peripherals via the Chroma SDK REST API.
The Chroma SDK exposes a local REST API. Workflow:
1. POST /razer/rest to init a session → get session URL
2. PUT effects to {session_url}/{device_type}
3. DELETE session on close
Uses CUSTOM effect type for per-LED RGB control.
"""
HEARTBEAT_INTERVAL = 10 # seconds — SDK kills session after 15s idle
def __init__(
self,
url: str = "",
led_count: int = 0,
chroma_device_type: str = "chromalink",
**kwargs,
):
self._base_url = url or CHROMA_SDK_URL
self._led_count = led_count
self._chroma_device_type = chroma_device_type
self._session_url: Optional[str] = None
self._connected = False
self._heartbeat_task: Optional[asyncio.Task] = None
self._http_client = None
async def connect(self) -> bool:
import httpx
self._http_client = httpx.AsyncClient(timeout=5.0)
# Initialize Chroma SDK session
init_payload = {
"title": "WLED Screen Controller",
"description": "LED pixel streaming from WLED Screen Controller",
"author": {"name": "WLED-SC", "contact": "https://github.com"},
"device_supported": [self._chroma_device_type],
"category": "application",
}
try:
resp = await self._http_client.post(self._base_url, json=init_payload)
resp.raise_for_status()
data = resp.json()
self._session_url = data.get("uri") or data.get("sessionid")
if not self._session_url:
raise RuntimeError(f"Chroma SDK init returned no session URL: {data}")
except Exception as e:
logger.error("Chroma SDK init failed: %s", e)
await self._http_client.aclose()
self._http_client = None
raise
self._connected = True
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
logger.info(
"Chroma client connected: device=%s session=%s leds=%d",
self._chroma_device_type, self._session_url, self._led_count,
)
return True
async def _heartbeat_loop(self):
"""Keep the Chroma SDK session alive."""
while self._connected and self._session_url:
try:
await asyncio.sleep(self.HEARTBEAT_INTERVAL)
if self._http_client and self._session_url:
await self._http_client.put(
f"{self._session_url}/heartbeat",
json={},
)
except asyncio.CancelledError:
break
except Exception as e:
logger.debug("Chroma heartbeat error: %s", e)
async def close(self) -> None:
self._connected = False
if self._heartbeat_task:
self._heartbeat_task.cancel()
try:
await self._heartbeat_task
except asyncio.CancelledError:
pass
self._heartbeat_task = None
if self._http_client and self._session_url:
try:
await self._http_client.delete(self._session_url)
except Exception:
pass
if self._http_client:
await self._http_client.aclose()
self._http_client = None
self._session_url = None
logger.info("Chroma client closed")
@property
def is_connected(self) -> bool:
return self._connected and self._session_url is not None
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self.is_connected or not self._http_client:
return False
if isinstance(pixels, np.ndarray):
pixel_arr = pixels
else:
pixel_arr = np.array(pixels, dtype=np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
device_info = CHROMA_DEVICES.get(self._chroma_device_type)
if not device_info:
return False
endpoint, max_leds = device_info
n = min(len(pixel_arr), max_leds, self._led_count or max_leds)
# Chroma uses BGR packed as 0x00BBGGRR integers
colors = []
for i in range(n):
r, g, b = int(pixel_arr[i][0]), int(pixel_arr[i][1]), int(pixel_arr[i][2])
colors.append(r | (g << 8) | (b << 16))
# Pad to max_leds if needed
while len(colors) < max_leds:
colors.append(0)
# Build effect payload based on device type
if self._chroma_device_type == "keyboard":
# Keyboard uses 2D array: 6 rows × 22 columns
grid = []
idx = 0
for row in range(6):
row_colors = []
for col in range(22):
row_colors.append(colors[idx] if idx < len(colors) else 0)
idx += 1
grid.append(row_colors)
effect = {"effect": "CHROMA_CUSTOM", "param": grid}
elif self._chroma_device_type == "keypad":
# Keypad uses 2D array: 4 rows × 5 columns
grid = []
idx = 0
for row in range(4):
row_colors = []
for col in range(5):
row_colors.append(colors[idx] if idx < len(colors) else 0)
idx += 1
grid.append(row_colors)
effect = {"effect": "CHROMA_CUSTOM", "param": grid}
else:
# 1D devices: mouse, mousepad, headset, chromalink
effect = {"effect": "CHROMA_CUSTOM", "param": colors[:max_leds]}
try:
url = f"{self._session_url}/{endpoint}"
resp = await self._http_client.put(url, json=effect)
return resp.status_code == 200
except Exception as e:
logger.error("Chroma send failed: %s", e)
return False
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Check if Chroma SDK is running."""
base = url or CHROMA_SDK_URL
try:
resp = await http_client.get(base, timeout=3.0)
if resp.status_code < 500:
return DeviceHealth(
online=True,
latency_ms=0.0,
device_name="Razer Chroma SDK",
last_checked=datetime.now(timezone.utc),
)
except Exception:
pass
return DeviceHealth(
online=False,
error="Chroma SDK not responding (is Razer Synapse running?)",
last_checked=datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,104 @@
"""Razer Chroma SDK device provider — control Razer RGB peripherals."""
from datetime import datetime, timezone
from typing import List
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.core.devices.chroma_client import (
ChromaClient,
CHROMA_DEVICES,
CHROMA_SDK_URL,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class ChromaDeviceProvider(LEDDeviceProvider):
"""Provider for Razer Chroma SDK RGB peripherals.
URL format: chroma://device_type (e.g. chroma://keyboard, chroma://chromalink)
Requires Razer Synapse with Chroma SDK enabled.
"""
@property
def device_type(self) -> str:
return "chroma"
@property
def capabilities(self) -> set:
return {"manual_led_count", "health_check"}
def create_client(self, url: str, **kwargs) -> LEDClient:
chroma_device_type = _parse_chroma_url(url)
return ChromaClient(
url=CHROMA_SDK_URL,
led_count=kwargs.get("led_count", 0),
chroma_device_type=chroma_device_type,
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await ChromaClient.check_health(CHROMA_SDK_URL, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate Chroma SDK is reachable."""
chroma_type = _parse_chroma_url(url)
if chroma_type not in CHROMA_DEVICES:
raise ValueError(
f"Unknown Chroma device type '{chroma_type}'. "
f"Supported: {', '.join(CHROMA_DEVICES.keys())}"
)
import httpx
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(CHROMA_SDK_URL)
if resp.status_code >= 500:
raise ValueError("Chroma SDK returned server error")
except httpx.ConnectError:
raise ValueError(
"Cannot connect to Chroma SDK. "
"Ensure Razer Synapse is running with Chroma SDK enabled."
)
_, max_leds = CHROMA_DEVICES[chroma_type]
return {"led_count": max_leds}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Discover available Chroma device types if SDK is running."""
import httpx
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(CHROMA_SDK_URL)
if resp.status_code >= 500:
return []
except Exception:
return []
# SDK is running — offer all device types
results = []
for dev_type, (_, max_leds) in CHROMA_DEVICES.items():
results.append(
DiscoveredDevice(
name=f"Razer {dev_type.title()}",
url=f"chroma://{dev_type}",
device_type="chroma",
ip="127.0.0.1",
mac="",
led_count=max_leds,
version=None,
)
)
return results
def _parse_chroma_url(url: str) -> str:
"""Parse 'chroma://device_type' → device_type string."""
return url.replace("chroma://", "").strip().lower()

View File

@@ -0,0 +1,245 @@
"""Art-Net / sACN (E1.31) DMX client for stage lighting and LED controllers."""
import asyncio
import struct
import uuid
from typing import List, Optional, Tuple, Union
import numpy as np
from wled_controller.core.devices.led_client import LEDClient, DeviceHealth
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Art-Net constants
ARTNET_PORT = 6454
ARTNET_HEADER = b"Art-Net\x00"
ARTNET_OPCODE_DMX = 0x5000
ARTNET_PROTOCOL_VERSION = 14
# sACN / E1.31 constants
SACN_PORT = 5568
ACN_PACKET_IDENTIFIER = b"\x00\x10\x00\x00\x41\x53\x43\x2d\x45\x31\x2e\x31\x37\x00\x00\x00"
SACN_VECTOR_ROOT = 0x00000004
SACN_VECTOR_FRAMING = 0x00000002
SACN_VECTOR_DMP = 0x02
# DMX512 limits
DMX_CHANNELS_PER_UNIVERSE = 512
DMX_PIXELS_PER_UNIVERSE = 170 # floor(512 / 3)
class DMXClient(LEDClient):
"""UDP client for Art-Net and sACN (E1.31) DMX protocols.
Supports sending RGB pixel data across multiple DMX universes.
Both protocols are UDP fire-and-forget, similar to DDP.
"""
def __init__(
self,
host: str,
port: Optional[int] = None,
led_count: int = 1,
protocol: str = "artnet",
start_universe: int = 0,
start_channel: int = 1,
**kwargs,
):
self.host = host
self.protocol = protocol.lower()
self.port = port or (ARTNET_PORT if self.protocol == "artnet" else SACN_PORT)
self.led_count = led_count
self.start_universe = start_universe
self.start_channel = max(1, min(512, start_channel)) # clamp 1-512
self._transport = None
self._protocol_obj = None
self._sequence = 0
# sACN requires a stable 16-byte CID (Component Identifier)
self._sacn_cid = uuid.uuid4().bytes
self._sacn_source_name = b"WLED Controller\x00" + b"\x00" * 48 # 64 bytes padded
# Pre-compute universe mapping
self._universe_map = self._compute_universe_map()
def _compute_universe_map(self) -> List[Tuple[int, int, int]]:
"""Pre-compute which channels go to which universe.
Returns list of (universe, channel_offset, num_channels) tuples.
channel_offset is 0-based index into the flat RGB byte array.
"""
total_channels = self.led_count * 3
start_ch_0 = self.start_channel - 1 # convert to 0-based
mapping = []
byte_offset = 0
universe = self.start_universe
ch_in_universe = start_ch_0
while byte_offset < total_channels:
available = DMX_CHANNELS_PER_UNIVERSE - ch_in_universe
needed = total_channels - byte_offset
count = min(available, needed)
mapping.append((universe, ch_in_universe, count, byte_offset))
byte_offset += count
universe += 1
ch_in_universe = 0 # subsequent universes start at channel 0
return mapping
@property
def is_connected(self) -> bool:
return self._transport is not None
@property
def supports_fast_send(self) -> bool:
return True
async def connect(self) -> bool:
try:
loop = asyncio.get_event_loop()
self._transport, self._protocol_obj = await loop.create_datagram_endpoint(
asyncio.DatagramProtocol,
remote_addr=(self.host, self.port),
)
num_universes = len(self._universe_map)
logger.info(
f"DMX/{self.protocol} client connected to {self.host}:{self.port} "
f"({self.led_count} LEDs across {num_universes} universe(s), "
f"starting at universe {self.start_universe} channel {self.start_channel})"
)
return True
except Exception as e:
logger.error(f"Failed to connect DMX client: {e}")
raise
async def close(self) -> None:
if self._transport:
self._transport.close()
self._transport = None
self._protocol_obj = None
logger.debug(f"Closed DMX/{self.protocol} connection to {self.host}:{self.port}")
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self._transport:
raise RuntimeError("DMX client not connected")
self.send_pixels_fast(pixels, brightness)
return True
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> None:
if not self._transport:
raise RuntimeError("DMX client not connected")
if isinstance(pixels, np.ndarray):
pixel_bytes = pixels.tobytes()
else:
pixel_bytes = np.array(pixels, dtype=np.uint8).tobytes()
self._sequence = (self._sequence + 1) % 256
for universe, ch_offset, num_channels, byte_offset in self._universe_map:
# Build a full 512-channel DMX frame (zero-padded)
dmx_data = bytearray(DMX_CHANNELS_PER_UNIVERSE)
chunk = pixel_bytes[byte_offset:byte_offset + num_channels]
dmx_data[ch_offset:ch_offset + len(chunk)] = chunk
if self.protocol == "sacn":
packet = self._build_sacn_packet(universe, bytes(dmx_data), self._sequence)
else:
packet = self._build_artnet_packet(universe, bytes(dmx_data), self._sequence)
self._transport.sendto(packet)
def _build_artnet_packet(self, universe: int, dmx_data: bytes, sequence: int) -> bytes:
"""Build an Art-Net DMX (OpDmx / 0x5000) packet.
Art-Net packet structure:
- 8 bytes: "Art-Net\\0" header
- 2 bytes: OpCode (0x5000 little-endian)
- 2 bytes: Protocol version (14, big-endian)
- 1 byte: Sequence number
- 1 byte: Physical port (0)
- 2 bytes: Universe (little-endian, 15-bit: subnet+universe)
- 2 bytes: Data length (big-endian, must be even, 2-512)
- N bytes: DMX channel data
"""
data_len = len(dmx_data)
# Art-Net requires even data length
if data_len % 2 != 0:
dmx_data = dmx_data + b"\x00"
data_len += 1
packet = bytearray()
packet.extend(ARTNET_HEADER) # 8 bytes: "Art-Net\0"
packet.extend(struct.pack("<H", ARTNET_OPCODE_DMX)) # 2 bytes: OpCode LE
packet.extend(struct.pack(">H", ARTNET_PROTOCOL_VERSION)) # 2 bytes: version BE
packet.append(sequence & 0xFF) # 1 byte: sequence
packet.append(0) # 1 byte: physical
packet.extend(struct.pack("<H", universe & 0x7FFF)) # 2 bytes: universe LE
packet.extend(struct.pack(">H", data_len)) # 2 bytes: length BE
packet.extend(dmx_data) # N bytes: data
return bytes(packet)
def _build_sacn_packet(
self, universe: int, dmx_data: bytes, sequence: int, priority: int = 100,
) -> bytes:
"""Build an sACN / E1.31 data packet.
Structure:
- Root layer (38 bytes)
- Framing layer (77 bytes)
- DMP layer (10 bytes + 1 start code + DMX data)
"""
slot_count = len(dmx_data) + 1 # +1 for DMX start code (0x00)
dmp_len = 10 + slot_count # DMP layer
framing_len = 77 + dmp_len # framing layer
root_len = 22 + framing_len # root layer (after preamble)
packet = bytearray()
# ── Root Layer ──
packet.extend(struct.pack(">H", 0x0010)) # preamble size
packet.extend(struct.pack(">H", 0x0000)) # post-amble size
packet.extend(ACN_PACKET_IDENTIFIER) # 12 bytes ACN packet ID
# Flags + length (high 4 bits = 0x7, low 12 = root_len)
packet.extend(struct.pack(">H", 0x7000 | (root_len & 0x0FFF)))
packet.extend(struct.pack(">I", SACN_VECTOR_ROOT)) # vector
packet.extend(self._sacn_cid) # 16 bytes CID
# ── Framing Layer ──
packet.extend(struct.pack(">H", 0x7000 | (framing_len & 0x0FFF)))
packet.extend(struct.pack(">I", SACN_VECTOR_FRAMING)) # vector
packet.extend(self._sacn_source_name[:64]) # 64 bytes source name
packet.append(priority & 0xFF) # priority
packet.extend(struct.pack(">H", 0)) # sync address (0 = none)
packet.append(sequence & 0xFF) # sequence
packet.append(0) # options (0 = normal)
packet.extend(struct.pack(">H", universe)) # universe
# ── DMP Layer ──
packet.extend(struct.pack(">H", 0x7000 | (dmp_len & 0x0FFF)))
packet.append(SACN_VECTOR_DMP) # vector
packet.append(0xA1) # address type & data type
packet.extend(struct.pack(">H", 0)) # first property address
packet.extend(struct.pack(">H", 1)) # address increment
packet.extend(struct.pack(">H", slot_count)) # property value count
packet.append(0x00) # DMX start code
packet.extend(dmx_data) # DMX channel data
return bytes(packet)
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

View File

@@ -0,0 +1,82 @@
"""DMX device provider — Art-Net / sACN (E1.31) factory, validation, health."""
from datetime import datetime, timezone
from typing import List
from urllib.parse import urlparse
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.core.devices.dmx_client import DMXClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
def parse_dmx_url(url: str) -> dict:
"""Parse a DMX URL like 'artnet://192.168.1.50' or 'sacn://192.168.1.50'.
Returns dict with 'host', 'port', 'protocol'.
Also accepts plain IP addresses (defaults to artnet).
"""
url = url.strip()
if "://" not in url:
url = f"artnet://{url}"
parsed = urlparse(url)
protocol = parsed.scheme.lower()
if protocol not in ("artnet", "sacn"):
protocol = "artnet"
host = parsed.hostname or "127.0.0.1"
port = parsed.port # None = use protocol default
return {"host": host, "port": port, "protocol": protocol}
class DMXDeviceProvider(LEDDeviceProvider):
"""Provider for Art-Net and sACN (E1.31) DMX devices."""
@property
def device_type(self) -> str:
return "dmx"
@property
def capabilities(self) -> set:
return {"manual_led_count"}
def create_client(self, url: str, **kwargs) -> LEDClient:
parsed = parse_dmx_url(url)
return DMXClient(
host=parsed["host"],
port=parsed["port"],
led_count=kwargs.get("led_count", 1),
protocol=kwargs.get("dmx_protocol", parsed["protocol"]),
start_universe=kwargs.get("dmx_start_universe", 0),
start_channel=kwargs.get("dmx_start_channel", 1),
)
async def check_health(
self, url: str, http_client, prev_health=None,
) -> DeviceHealth:
# DMX is UDP fire-and-forget — no reliable health probe.
# Report as always online (same pattern as WS/Mock providers).
return DeviceHealth(
online=True,
latency_ms=0.0,
last_checked=datetime.now(timezone.utc),
)
async def validate_device(self, url: str) -> dict:
"""Validate DMX device URL."""
parsed = parse_dmx_url(url)
if not parsed["host"]:
raise ValueError("DMX device requires a valid IP address or hostname")
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
# No auto-discovery for DMX devices
return []

View File

@@ -0,0 +1,163 @@
"""ESP-NOW LED client — sends pixel data via serial to an ESP32 gateway which forwards over ESP-NOW."""
import asyncio
import struct
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
import numpy as np
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Gateway serial protocol constants
FRAME_START = 0xEE
FRAME_END = 0xEF
def _mac_str_to_bytes(mac: str) -> bytes:
"""Convert 'AA:BB:CC:DD:EE:FF' to 6 raw bytes."""
parts = mac.replace("-", ":").split(":")
if len(parts) != 6:
raise ValueError(f"Invalid MAC address: {mac}")
return bytes(int(p, 16) for p in parts)
def _build_frame(peer_mac: bytes, pixels: bytes, brightness: int) -> bytes:
"""Build a serial frame for the ESP-NOW gateway.
Wire format:
[0xEE][MAC 6B][LED_COUNT 2B LE][BRIGHTNESS 1B][RGB...][CHECKSUM 1B][0xEF]
Checksum is XOR of all bytes between START and END (exclusive).
"""
led_count = len(pixels) // 3
header = struct.pack("<B6sHB", FRAME_START, peer_mac, led_count, brightness)
payload = header[1:] + pixels # everything after START for checksum
checksum = 0
for b in payload:
checksum ^= b
return header + pixels + bytes([checksum, FRAME_END])
class ESPNowClient(LEDClient):
"""LED client that sends pixel data to an ESP32 ESP-NOW gateway over serial.
The gateway ESP32 receives serial frames and forwards LED data via
ESP-NOW to a peer ESP32 which drives the LED strip.
"""
def __init__(
self,
url: str = "",
led_count: int = 0,
baud_rate: int = 921600,
espnow_peer_mac: str = "FF:FF:FF:FF:FF:FF",
espnow_channel: int = 1,
**kwargs,
):
self._port = url
self._led_count = led_count
self._baud_rate = baud_rate
self._peer_mac = _mac_str_to_bytes(espnow_peer_mac)
self._channel = espnow_channel
self._serial = None
self._connected = False
self._lock = asyncio.Lock()
async def connect(self) -> bool:
try:
import serial as pyserial
except ImportError:
raise RuntimeError("pyserial is required for ESP-NOW devices: pip install pyserial")
loop = asyncio.get_event_loop()
self._serial = await loop.run_in_executor(
None,
lambda: pyserial.Serial(self._port, self._baud_rate, timeout=1),
)
self._connected = True
logger.info(
"ESP-NOW client connected: port=%s baud=%d peer=%s channel=%d leds=%d",
self._port, self._baud_rate,
":".join(f"{b:02X}" for b in self._peer_mac),
self._channel, self._led_count,
)
return True
async def close(self) -> None:
if self._serial and self._serial.is_open:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._serial.close)
self._serial = None
self._connected = False
logger.info("ESP-NOW client closed: port=%s", self._port)
@property
def is_connected(self) -> bool:
return self._connected and self._serial is not None and self._serial.is_open
@property
def supports_fast_send(self) -> bool:
return True
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> None:
if not self.is_connected:
return
if isinstance(pixels, np.ndarray):
pixel_bytes = pixels.astype(np.uint8).tobytes()
else:
pixel_bytes = bytes(c for rgb in pixels for c in rgb)
# Note: brightness already applied by processor loop; pass 255 to firmware
frame = _build_frame(self._peer_mac, pixel_bytes, 255)
try:
self._serial.write(frame)
except Exception as e:
logger.warning("ESP-NOW send failed: %s", e)
self._connected = False
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self.is_connected:
return False
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(
None,
lambda: self.send_pixels_fast(pixels, brightness),
)
return True
except Exception as e:
logger.error("ESP-NOW async send failed: %s", e)
return False
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Check if the serial port is available."""
try:
import serial as pyserial
s = pyserial.Serial(url, timeout=0.1)
s.close()
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
except Exception as e:
return DeviceHealth(
online=False,
error=str(e),
last_checked=datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,87 @@
"""ESP-NOW device provider — ultra-low-latency LED control via ESP32 gateway."""
from datetime import datetime, timezone
from typing import List
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.core.devices.espnow_client import ESPNowClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class ESPNowDeviceProvider(LEDDeviceProvider):
"""Provider for ESP-NOW LED devices via serial ESP32 gateway.
URL = serial port of the gateway ESP32 (e.g. COM3, /dev/ttyUSB0).
Each device represents one remote ESP32 peer identified by MAC address.
Multiple devices can share the same gateway (serial port).
"""
@property
def device_type(self) -> str:
return "espnow"
@property
def capabilities(self) -> set:
return {"manual_led_count", "health_check"}
def create_client(self, url: str, **kwargs) -> LEDClient:
return ESPNowClient(
url,
led_count=kwargs.get("led_count", 0),
baud_rate=kwargs.get("baud_rate", 921600),
espnow_peer_mac=kwargs.get("espnow_peer_mac", "FF:FF:FF:FF:FF:FF"),
espnow_channel=kwargs.get("espnow_channel", 1),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await ESPNowClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate serial port is accessible. LED count is manual."""
try:
import serial as pyserial
s = pyserial.Serial(url, timeout=0.5)
s.close()
except ImportError:
raise ValueError("pyserial is required for ESP-NOW devices: pip install pyserial")
except Exception as e:
raise ValueError(f"Cannot open serial port {url}: {e}")
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Discover available serial ports that could be ESP32 gateways."""
try:
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
results = []
for port in ports:
# Look for ESP32 USB descriptors
desc = (port.description or "").lower()
vid = port.vid or 0
# Common ESP32 USB VIDs: Espressif (0x303A), Silicon Labs CP210x (0x10C4),
# FTDI (0x0403), WCH CH340 (0x1A86)
esp_vids = {0x303A, 0x10C4, 0x0403, 0x1A86}
if vid in esp_vids or "cp210" in desc or "ch340" in desc or "esp" in desc:
results.append(
DiscoveredDevice(
name=f"ESP-NOW Gateway ({port.description})",
url=port.device,
device_type="espnow",
ip="",
mac="",
led_count=None,
version=None,
)
)
return results
except ImportError:
return []

View File

@@ -0,0 +1,262 @@
"""SteelSeries GameSense LED client — controls SteelSeries RGB peripherals via REST API."""
import asyncio
import json
import os
import platform
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
import numpy as np
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Game registration constants
GAME_NAME = "WLED_SCREEN_CTRL"
GAME_DISPLAY_NAME = "WLED Screen Controller"
EVENT_NAME = "PIXEL_DATA"
def _get_gamesense_address() -> Optional[str]:
"""Discover the SteelSeries GameSense address from coreProps.json."""
if platform.system() == "Windows":
props_path = os.path.join(
os.environ.get("PROGRAMDATA", r"C:\ProgramData"),
"SteelSeries", "SteelSeries Engine 3", "coreProps.json",
)
elif platform.system() == "Darwin":
props_path = os.path.expanduser(
"~/Library/Application Support/SteelSeries Engine 3/coreProps.json"
)
else:
# Linux — SteelSeries Engine not officially supported
props_path = os.path.expanduser(
"~/.config/SteelSeries Engine 3/coreProps.json"
)
try:
with open(props_path, "r") as f:
data = json.load(f)
return data.get("address")
except (FileNotFoundError, json.JSONDecodeError, KeyError):
return None
# GameSense device-zone mapping
GAMESENSE_ZONES = {
"keyboard": ("rgb-per-key-zones", "all"),
"mouse": ("mouse", "wheel"),
"headset": ("headset", "earcups"),
"mousepad": ("mousepad", "all"),
"indicator": ("indicator", "one"),
}
class GameSenseClient(LEDClient):
"""LED client that controls SteelSeries peripherals via GameSense REST API.
GameSense uses a register-bind-send pattern:
1. Register game + event
2. Bind event to device zone with context-color handler
3. Send color events with RGB data in frame payload
The API address is discovered from coreProps.json at runtime.
"""
def __init__(
self,
url: str = "",
led_count: int = 0,
gamesense_device_type: str = "keyboard",
**kwargs,
):
self._address = url.replace("gamesense://", "").strip() if url else ""
self._led_count = led_count
self._gs_device_type = gamesense_device_type
self._connected = False
self._http_client = None
self._base_url: Optional[str] = None
async def connect(self) -> bool:
import httpx
# Discover GameSense address
address = self._address or _get_gamesense_address()
if not address:
raise RuntimeError(
"Cannot find SteelSeries Engine. "
"Ensure SteelSeries GG/Engine is running."
)
self._base_url = f"http://{address}"
self._http_client = httpx.AsyncClient(timeout=5.0)
try:
# Register game
await self._http_client.post(
f"{self._base_url}/game_metadata",
json={
"game": GAME_NAME,
"game_display_name": GAME_DISPLAY_NAME,
"developer": "WLED-SC",
},
)
# Register event
await self._http_client.post(
f"{self._base_url}/register_game_event",
json={
"game": GAME_NAME,
"event": EVENT_NAME,
"min_value": 0,
"max_value": 100,
"value_optional": True,
},
)
# Bind event to device zone with context-color mode
zone_info = GAMESENSE_ZONES.get(
self._gs_device_type, ("rgb-per-key-zones", "all")
)
device_type, zone = zone_info
await self._http_client.post(
f"{self._base_url}/bind_game_event",
json={
"game": GAME_NAME,
"event": EVENT_NAME,
"handlers": [
{
"device-type": device_type,
"zone": zone,
"mode": "context-color",
"context-frame-key": "zone-color",
}
],
},
)
except Exception as e:
logger.error("GameSense setup failed: %s", e)
await self._http_client.aclose()
self._http_client = None
raise
self._connected = True
logger.info(
"GameSense client connected: device=%s address=%s leds=%d",
self._gs_device_type, address, self._led_count,
)
return True
async def close(self) -> None:
if self._http_client and self._base_url:
try:
# Remove game registration
await self._http_client.post(
f"{self._base_url}/remove_game",
json={"game": GAME_NAME},
)
except Exception:
pass
if self._http_client:
await self._http_client.aclose()
self._http_client = None
self._connected = False
self._base_url = None
logger.info("GameSense client closed")
@property
def is_connected(self) -> bool:
return self._connected and self._http_client is not None
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self.is_connected or not self._http_client:
return False
if isinstance(pixels, np.ndarray):
pixel_arr = pixels
else:
pixel_arr = np.array(pixels, dtype=np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
# Use average color for single-zone devices, or first N for multi-zone
if len(pixel_arr) == 0:
return True
# Compute average color for the zone
avg = pixel_arr.mean(axis=0)
r = int(avg[0])
g = int(avg[1])
b = int(avg[2])
event_data = {
"game": GAME_NAME,
"event": EVENT_NAME,
"data": {
"value": 100,
"frame": {
"zone-color": {
"red": r,
"green": g,
"blue": b,
}
},
},
}
try:
resp = await self._http_client.post(
f"{self._base_url}/game_event",
json=event_data,
)
return resp.status_code == 200
except Exception as e:
logger.error("GameSense send failed: %s", e)
return False
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Check if SteelSeries Engine is running."""
address = url.replace("gamesense://", "").strip() if url else None
if not address:
address = _get_gamesense_address()
if not address:
return DeviceHealth(
online=False,
error="SteelSeries Engine not found (coreProps.json missing)",
last_checked=datetime.now(timezone.utc),
)
try:
resp = await http_client.get(f"http://{address}", timeout=3.0)
if resp.status_code < 500:
return DeviceHealth(
online=True,
latency_ms=0.0,
device_name="SteelSeries GameSense",
last_checked=datetime.now(timezone.utc),
)
except Exception:
pass
return DeviceHealth(
online=False,
error="SteelSeries Engine not responding",
last_checked=datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,104 @@
"""SteelSeries GameSense device provider — control SteelSeries RGB peripherals."""
from datetime import datetime, timezone
from typing import List
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.core.devices.gamesense_client import (
GameSenseClient,
GAMESENSE_ZONES,
_get_gamesense_address,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class GameSenseDeviceProvider(LEDDeviceProvider):
"""Provider for SteelSeries GameSense RGB peripherals.
URL format: gamesense://address or gamesense://auto (auto-discover from coreProps.json)
Requires SteelSeries GG / SteelSeries Engine 3.
"""
@property
def device_type(self) -> str:
return "gamesense"
@property
def capabilities(self) -> set:
return {"manual_led_count", "health_check"}
def create_client(self, url: str, **kwargs) -> LEDClient:
return GameSenseClient(
url=url,
led_count=kwargs.get("led_count", 0),
gamesense_device_type=kwargs.get("gamesense_device_type", "keyboard"),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await GameSenseClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate GameSense is reachable."""
address = url.replace("gamesense://", "").strip()
if not address or address == "auto":
address = _get_gamesense_address()
if not address:
raise ValueError(
"Cannot find SteelSeries Engine. "
"Ensure SteelSeries GG is running."
)
import httpx
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(f"http://{address}")
if resp.status_code >= 500:
raise ValueError("SteelSeries Engine returned server error")
except httpx.ConnectError:
raise ValueError(
"Cannot connect to SteelSeries Engine at "
f"{address}. Ensure it is running."
)
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Discover SteelSeries Engine if running."""
address = _get_gamesense_address()
if not address:
return []
import httpx
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"http://{address}")
if resp.status_code >= 500:
return []
except Exception:
return []
# Engine is running — offer device types
results = []
for dev_type in GAMESENSE_ZONES:
results.append(
DiscoveredDevice(
name=f"SteelSeries {dev_type.title()}",
url=f"gamesense://{address}",
device_type="gamesense",
ip=address.split(":")[0],
mac="",
led_count=None,
version=None,
)
)
return results

View File

@@ -0,0 +1,240 @@
"""Philips Hue LED client — streams color data via the Hue Entertainment API."""
import asyncio
import socket
import struct
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
import numpy as np
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Hue Entertainment API constants
HUE_ENT_PORT = 2100
PROTOCOL_NAME = b"HueStream"
VERSION_MAJOR = 2
VERSION_MINOR = 0
COLOR_SPACE_RGB = 0x00
# API version 2 header: "HueStream" + version(2B) + sequence(1B) + reserved(2B)
# + color_space(1B) + reserved(1B) = 16 bytes
HEADER_SIZE = 16
def _build_entertainment_frame(
lights: List[Tuple[int, int, int]],
brightness: int = 255,
sequence: int = 0,
) -> bytes:
"""Build a Hue Entertainment API v2 UDP frame.
Each light gets 7 bytes: [light_id(2B)][R(2B)][G(2B)][B(2B)]
Colors are 16-bit (0-65535). We scale 8-bit RGB + brightness.
"""
# Header
header = bytearray(HEADER_SIZE)
header[0:9] = PROTOCOL_NAME
header[9] = VERSION_MAJOR
header[10] = VERSION_MINOR
header[11] = sequence & 0xFF
header[12] = 0x00 # reserved
header[13] = 0x00 # reserved
header[14] = COLOR_SPACE_RGB
header[15] = 0x00 # reserved
# Light data
# Note: brightness already applied by processor loop (_cached_brightness)
data = bytearray()
for idx, (r, g, b) in enumerate(lights):
light_id = idx # 0-based light index in entertainment group
r16 = int(r * 257) # scale 0-255 to 0-65535
g16 = int(g * 257)
b16 = int(b * 257)
data += struct.pack(">BHHH", light_id, r16, g16, b16)
return bytes(header) + bytes(data)
class HueClient(LEDClient):
"""LED client for Philips Hue Entertainment API streaming.
Uses UDP (optionally DTLS) to stream color data at ~25 fps to a Hue
entertainment group. Each light in the group is treated as one "LED".
"""
def __init__(
self,
url: str = "",
led_count: int = 0,
hue_username: str = "",
hue_client_key: str = "",
hue_entertainment_group_id: str = "",
**kwargs,
):
self._bridge_ip = url.replace("hue://", "").rstrip("/")
self._led_count = led_count
self._username = hue_username
self._client_key = hue_client_key
self._group_id = hue_entertainment_group_id
self._sock: Optional[socket.socket] = None
self._connected = False
self._sequence = 0
self._dtls_sock = None
async def connect(self) -> bool:
loop = asyncio.get_event_loop()
# Activate entertainment streaming via REST API
await self._activate_streaming(True)
# Open UDP socket for entertainment streaming
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setblocking(False)
# Try DTLS if dtls library is available
try:
from dtls import do_patch
do_patch()
import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_ciphers("PSK-AES128-GCM-SHA256")
# PSK identity = username, key = client_key (hex decoded)
psk = bytes.fromhex(self._client_key)
ctx.set_psk_client_callback(lambda hint: (self._username.encode(), psk))
self._dtls_sock = ctx.wrap_socket(self._sock, server_hostname=self._bridge_ip)
await loop.run_in_executor(
None,
lambda: self._dtls_sock.connect((self._bridge_ip, HUE_ENT_PORT)),
)
logger.info("Hue DTLS connection established to %s", self._bridge_ip)
except (ImportError, Exception) as e:
# Fall back to plain UDP (works for local testing / older bridges)
logger.warning(
"Hue DTLS not available (%s), falling back to plain UDP. "
"Install 'dtls' package for encrypted streaming.",
e,
)
self._dtls_sock = None
self._connected = True
logger.info(
"Hue client connected: bridge=%s group=%s lights=%d",
self._bridge_ip, self._group_id, self._led_count,
)
return True
async def _activate_streaming(self, active: bool) -> None:
"""Activate/deactivate entertainment streaming via Hue REST API."""
import httpx
url = f"https://{self._bridge_ip}/clip/v2/resource/entertainment_configuration/{self._group_id}"
payload = {"action": "start" if active else "stop"}
headers = {"hue-application-key": self._username}
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
resp = await client.put(url, json=payload, headers=headers)
if resp.status_code not in (200, 207):
logger.warning("Hue streaming %s failed: %s", "start" if active else "stop", resp.text)
async def close(self) -> None:
if self._connected:
try:
await self._activate_streaming(False)
except Exception:
pass
if self._dtls_sock:
try:
self._dtls_sock.close()
except Exception:
pass
if self._sock:
try:
self._sock.close()
except Exception:
pass
self._sock = None
self._dtls_sock = None
self._connected = False
logger.info("Hue client closed: bridge=%s", self._bridge_ip)
@property
def is_connected(self) -> bool:
return self._connected
@property
def supports_fast_send(self) -> bool:
return True
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> None:
if not self._connected:
return
if isinstance(pixels, np.ndarray):
light_colors = [tuple(pixels[i]) for i in range(min(len(pixels), self._led_count))]
else:
light_colors = pixels[: self._led_count]
frame = _build_entertainment_frame(light_colors, brightness, self._sequence)
self._sequence = (self._sequence + 1) & 0xFF
try:
if self._dtls_sock:
self._dtls_sock.send(frame)
elif self._sock:
self._sock.sendto(frame, (self._bridge_ip, HUE_ENT_PORT))
except Exception as e:
logger.warning("Hue send failed: %s", e)
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self._connected:
return False
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: self.send_pixels_fast(pixels, brightness),
)
return True
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Check if the Hue bridge is reachable."""
bridge_ip = url.replace("hue://", "").rstrip("/")
try:
import httpx
import time
start = time.time()
async with httpx.AsyncClient(verify=False, timeout=3.0) as client:
resp = await client.get(f"https://{bridge_ip}/api/0/config")
latency = (time.time() - start) * 1000
if resp.status_code == 200:
data = resp.json()
return DeviceHealth(
online=True,
latency_ms=round(latency, 1),
device_name=data.get("name", "Hue Bridge"),
device_version=data.get("swversion"),
last_checked=datetime.now(timezone.utc),
)
return DeviceHealth(online=False, error="Unexpected response", last_checked=datetime.now(timezone.utc))
except Exception as e:
return DeviceHealth(online=False, error=str(e), last_checked=datetime.now(timezone.utc))

View File

@@ -0,0 +1,195 @@
"""Philips Hue device provider — entertainment streaming to Hue lights."""
import asyncio
from datetime import datetime, timezone
from typing import List, Tuple
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.core.devices.hue_client import HueClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class HueDeviceProvider(LEDDeviceProvider):
"""Provider for Philips Hue Entertainment API streaming.
URL format: hue://<bridge-ip>
Each device = one entertainment group on the bridge.
LED count = number of lights in the entertainment group.
"""
@property
def device_type(self) -> str:
return "hue"
@property
def capabilities(self) -> set:
return {
"manual_led_count",
"brightness_control",
"power_control",
"health_check",
"static_color",
}
def create_client(self, url: str, **kwargs) -> LEDClient:
return HueClient(
url,
led_count=kwargs.get("led_count", 0),
hue_username=kwargs.get("hue_username", ""),
hue_client_key=kwargs.get("hue_client_key", ""),
hue_entertainment_group_id=kwargs.get("hue_entertainment_group_id", ""),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await HueClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate Hue bridge is reachable. LED count is manual (depends on entertainment group)."""
bridge_ip = url.replace("hue://", "").rstrip("/")
try:
import httpx
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
resp = await client.get(f"https://{bridge_ip}/api/0/config")
if resp.status_code == 200:
data = resp.json()
if "bridgeid" in data or "name" in data:
return {}
raise ValueError(f"Device at {bridge_ip} does not appear to be a Hue bridge")
except ValueError:
raise
except Exception as e:
raise ValueError(f"Cannot reach Hue bridge at {bridge_ip}: {e}")
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Discover Hue bridges via mDNS."""
results = []
try:
from zeroconf import ServiceBrowser, Zeroconf
found = []
class Listener:
def add_service(self, zc, type_, name):
info = zc.get_service_info(type_, name)
if info:
found.append(info)
def remove_service(self, zc, type_, name):
pass
def update_service(self, zc, type_, name):
pass
zc = Zeroconf()
try:
ServiceBrowser(zc, "_hue._tcp.local.", Listener())
await asyncio.sleep(min(timeout, 3.0))
for info in found:
addresses = info.parsed_addresses()
if not addresses:
continue
ip = addresses[0]
name = info.server.rstrip(".")
results.append(
DiscoveredDevice(
name=f"Hue Bridge ({name})",
url=f"hue://{ip}",
device_type="hue",
ip=ip,
mac="",
led_count=None,
version=None,
)
)
finally:
zc.close()
except ImportError:
logger.debug("zeroconf not available for Hue discovery")
return results
async def get_brightness(self, url: str) -> int:
"""Get bridge group brightness (not per-light)."""
raise NotImplementedError
async def set_brightness(self, url: str, brightness: int) -> None:
"""Software brightness — handled at send time."""
raise NotImplementedError
async def get_power(self, url: str, **kwargs) -> bool:
return True
async def set_power(self, url: str, on: bool, **kwargs) -> None:
"""Turn all lights in group on/off via REST API."""
bridge_ip = url.replace("hue://", "").rstrip("/")
hue_username = kwargs.get("hue_username", "")
group_id = kwargs.get("hue_entertainment_group_id", "")
if not hue_username or not group_id:
return
try:
import httpx
api_url = f"https://{bridge_ip}/clip/v2/resource/grouped_light"
headers = {"hue-application-key": hue_username}
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
resp = await client.get(api_url, headers=headers)
if resp.status_code == 200:
# Find the grouped_light for our entertainment config
for item in resp.json().get("data", []):
await client.put(
f"{api_url}/{item['id']}",
json={"on": {"on": on}},
headers=headers,
)
except Exception as e:
logger.error("Failed to set Hue power: %s", e)
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
"""Set all lights to a solid color via REST API."""
bridge_ip = url.replace("hue://", "").rstrip("/")
hue_username = kwargs.get("hue_username", "")
if not hue_username:
return
try:
import httpx
headers = {"hue-application-key": hue_username}
# Convert RGB to CIE xy for Hue API
r, g, b = [c / 255.0 for c in color]
# sRGB to linear
r = r / 12.92 if r <= 0.04045 else ((r + 0.055) / 1.055) ** 2.4
g = g / 12.92 if g <= 0.04045 else ((g + 0.055) / 1.055) ** 2.4
b = b / 12.92 if b <= 0.04045 else ((b + 0.055) / 1.055) ** 2.4
X = r * 0.4124 + g * 0.3576 + b * 0.1805
Y = r * 0.2126 + g * 0.7152 + b * 0.0722
Z = r * 0.0193 + g * 0.1192 + b * 0.9505
total = X + Y + Z
if total > 0:
x = X / total
y = Y / total
else:
x, y = 0.3127, 0.3290 # D65 white point
api_url = f"https://{bridge_ip}/clip/v2/resource/light"
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
resp = await client.get(api_url, headers=headers)
if resp.status_code == 200:
for light in resp.json().get("data", []):
await client.put(
f"{api_url}/{light['id']}",
json={
"on": {"on": True},
"color": {"xy": {"x": round(x, 4), "y": round(y, 4)}},
},
headers=headers,
)
except Exception as e:
logger.error("Failed to set Hue color: %s", e)

View File

@@ -296,5 +296,26 @@ def _register_builtin_providers():
from wled_controller.core.devices.openrgb_provider import OpenRGBDeviceProvider
register_provider(OpenRGBDeviceProvider())
from wled_controller.core.devices.dmx_provider import DMXDeviceProvider
register_provider(DMXDeviceProvider())
from wled_controller.core.devices.espnow_provider import ESPNowDeviceProvider
register_provider(ESPNowDeviceProvider())
from wled_controller.core.devices.hue_provider import HueDeviceProvider
register_provider(HueDeviceProvider())
from wled_controller.core.devices.usbhid_provider import USBHIDDeviceProvider
register_provider(USBHIDDeviceProvider())
from wled_controller.core.devices.spi_provider import SPIDeviceProvider
register_provider(SPIDeviceProvider())
from wled_controller.core.devices.chroma_provider import ChromaDeviceProvider
register_provider(ChromaDeviceProvider())
from wled_controller.core.devices.gamesense_provider import GameSenseDeviceProvider
register_provider(GameSenseDeviceProvider())
_register_builtin_providers()

View File

@@ -24,8 +24,11 @@ class MockDeviceProvider(LEDDeviceProvider):
return {"manual_led_count", "power_control", "brightness_control"}
def create_client(self, url: str, **kwargs) -> LEDClient:
kwargs.pop("use_ddp", None)
return MockClient(url, **kwargs)
return MockClient(
url,
led_count=kwargs.get("led_count", 0),
send_latency_ms=kwargs.get("send_latency_ms", 0),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))

View File

@@ -31,7 +31,10 @@ class MQTTDeviceProvider(LEDDeviceProvider):
return {"manual_led_count"}
def create_client(self, url: str, **kwargs) -> LEDClient:
return MQTTLEDClient(url, **kwargs)
return MQTTLEDClient(
url,
led_count=kwargs.get("led_count", 0),
)
async def check_health(
self, url: str, http_client, prev_health=None,

View File

@@ -96,7 +96,7 @@ class OpenRGBLEDClient(LEDClient):
self._send_pending: Optional[Tuple[np.ndarray, int]] = None # (pixels, brightness)
self._send_thread: Optional[threading.Thread] = None
self._send_stop = threading.Event()
self._last_sent_pixels: Optional[np.ndarray] = None # for change-threshold dedup
self._last_sent_pixels: Optional[np.ndarray] = None
async def connect(self) -> bool:
"""Connect to OpenRGB server and access the target device."""
@@ -288,23 +288,22 @@ class OpenRGBLEDClient(LEDClient):
Builds raw OpenRGB UpdateZoneLeds packets directly with struct.pack,
bypassing RGBColor object creation to avoid GC pressure.
"""
# Apply brightness scaling
if brightness < 255:
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
# Truncate to match target LED count
n_target = self._device_led_count
if len(pixel_array) > n_target:
pixel_array = pixel_array[:n_target]
# Change-threshold dedup — skip if average per-LED color change < 2
# GPU I2C/SMBus writes cause system-wide stalls; minimizing writes is critical.
# Change-threshold dedup — compare RAW pixels before brightness scaling
# so low brightness doesn't crush differences below the threshold.
# Exact-match dedup — skip only if pixels are identical to last sent frame.
# Threshold-based dedup caused stutter at low brightness.
if self._last_sent_pixels is not None and self._last_sent_pixels.shape == pixel_array.shape:
diff = np.mean(np.abs(pixel_array.astype(np.int16) - self._last_sent_pixels.astype(np.int16)))
if diff < 2.0:
if np.array_equal(pixel_array, self._last_sent_pixels):
return
self._last_sent_pixels = pixel_array.copy()
# Note: brightness already applied by processor loop (_cached_brightness)
# Separate mode: resample full pixel array independently per zone
if self._zone_mode == "separate" and len(self._target_zones) > 1:
n_src = len(pixel_array)

View File

@@ -27,15 +27,13 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
@property
def capabilities(self) -> set:
return {"health_check", "auto_restore", "static_color"}
return {"health_check", "auto_restore", "static_color", "brightness_control"}
def create_client(self, url: str, **kwargs) -> LEDClient:
zone_mode = kwargs.pop("zone_mode", "combined")
kwargs.pop("led_count", None)
kwargs.pop("baud_rate", None)
kwargs.pop("send_latency_ms", None)
kwargs.pop("rgbw", None)
return OpenRGBLEDClient(url, zone_mode=zone_mode, **kwargs)
return OpenRGBLEDClient(
url,
zone_mode=kwargs.get("zone_mode", "combined"),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await OpenRGBLEDClient.check_health(url, http_client, prev_health)

View File

@@ -0,0 +1,264 @@
"""SPI Direct LED client — drives WS2812/SK6812 strips via Raspberry Pi GPIO/SPI."""
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
import numpy as np
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Supported LED chipsets
LED_TYPES = {
"WS2812": {"freq_hz": 800000, "invert": False, "strip_type": None},
"WS2812B": {"freq_hz": 800000, "invert": False, "strip_type": None},
"WS2811": {"freq_hz": 800000, "invert": False, "strip_type": None},
"SK6812": {"freq_hz": 800000, "invert": False, "strip_type": None},
"SK6812_RGBW": {"freq_hz": 800000, "invert": False, "strip_type": None},
}
# SPI speed for bitbang protocol
DEFAULT_SPI_SPEED = 800000
# GPIO pin for rpi_ws281x (GPIO 18 = PWM0, GPIO 10 = SPI0 MOSI)
DEFAULT_GPIO_PIN = 18
def _parse_spi_url(url: str) -> dict:
"""Parse SPI URL format.
Formats:
spi://gpio:18 — rpi_ws281x via GPIO pin 18
spi://spidev:0.0 — SPI device /dev/spidev0.0
spi://0 — shorthand for GPIO pin 0 (actually pin 18 default)
"""
path = url.replace("spi://", "")
if path.startswith("gpio:"):
pin = int(path.split(":")[1])
return {"method": "gpio", "gpio_pin": pin}
elif path.startswith("spidev:"):
dev = path.split(":")[1]
bus, device = dev.split(".")
return {"method": "spidev", "bus": int(bus), "device": int(device)}
else:
# Default: gpio pin
try:
pin = int(path) if path else DEFAULT_GPIO_PIN
except ValueError:
pin = DEFAULT_GPIO_PIN
return {"method": "gpio", "gpio_pin": pin}
class SPIClient(LEDClient):
"""LED client that drives addressable LED strips directly via Raspberry Pi GPIO/SPI.
Uses rpi_ws281x library for GPIO-based control, or spidev for raw SPI.
Requires root privileges or SPI group membership.
"""
def __init__(
self,
url: str = "",
led_count: int = 0,
spi_speed_hz: int = DEFAULT_SPI_SPEED,
spi_led_type: str = "WS2812B",
**kwargs,
):
self._config = _parse_spi_url(url)
self._led_count = led_count
self._speed_hz = spi_speed_hz
self._led_type = spi_led_type
self._strip = None
self._spi = None
self._connected = False
async def connect(self) -> bool:
loop = asyncio.get_event_loop()
if self._config["method"] == "gpio":
await loop.run_in_executor(None, self._connect_rpi_ws281x)
else:
await loop.run_in_executor(None, self._connect_spidev)
self._connected = True
logger.info(
"SPI client connected: method=%s leds=%d type=%s",
self._config["method"], self._led_count, self._led_type,
)
return True
def _connect_rpi_ws281x(self):
"""Connect via rpi_ws281x library (GPIO PWM/DMA)."""
try:
from rpi_ws281x import PixelStrip, Color
except ImportError:
raise RuntimeError(
"rpi_ws281x is required for GPIO LED control: "
"pip install rpi_ws281x (Linux/RPi only)"
)
led_info = LED_TYPES.get(self._led_type, LED_TYPES["WS2812B"])
gpio_pin = self._config["gpio_pin"]
self._strip = PixelStrip(
self._led_count,
gpio_pin,
led_info["freq_hz"],
10, # DMA channel
led_info["invert"],
255, # max brightness
0, # channel (0 for GPIO 18, 1 for GPIO 13)
)
self._strip.begin()
def _connect_spidev(self):
"""Connect via spidev (raw SPI bus)."""
try:
import spidev
except ImportError:
raise RuntimeError("spidev is required for SPI LED control: pip install spidev")
bus = self._config["bus"]
device = self._config["device"]
self._spi = spidev.SpiDev()
self._spi.open(bus, device)
self._spi.max_speed_hz = self._speed_hz
self._spi.mode = 0
async def close(self) -> None:
loop = asyncio.get_event_loop()
if self._strip:
# Turn off all LEDs
def _clear():
for i in range(self._led_count):
self._strip.setPixelColor(i, 0)
self._strip.show()
await loop.run_in_executor(None, _clear)
self._strip = None
if self._spi:
await loop.run_in_executor(None, self._spi.close)
self._spi = None
self._connected = False
logger.info("SPI client closed")
@property
def is_connected(self) -> bool:
return self._connected
@property
def supports_fast_send(self) -> bool:
return True
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> None:
if not self._connected:
return
# Note: brightness already applied by processor loop (_cached_brightness)
if isinstance(pixels, np.ndarray):
pixel_arr = pixels
else:
pixel_arr = np.array(pixels, dtype=np.uint8)
if self._strip:
# rpi_ws281x path
try:
from rpi_ws281x import Color
except ImportError:
return
self._strip.setBrightness(255)
for i in range(min(len(pixel_arr), self._led_count)):
r, g, b = pixel_arr[i]
self._strip.setPixelColor(i, Color(int(r), int(g), int(b)))
self._strip.show()
elif self._spi:
# SPI bitbang path: convert RGB to WS2812 wire format
# Each bit is encoded as 3 SPI bits: 1=110, 0=100
scaled = pixel_arr[:self._led_count]
# GRB order for WS2812
grb = scaled[:, [1, 0, 2]]
raw_bytes = grb.tobytes()
# Encode each byte as 3 SPI bytes (8 bits → 24 SPI bits)
spi_data = bytearray()
for byte in raw_bytes:
for bit in range(7, -1, -1):
if byte & (1 << bit):
spi_data.append(0b110)
else:
spi_data.append(0b100)
# Reset pulse (>50us low)
spi_data.extend(b'\x00' * 20)
self._spi.writebytes2(list(spi_data))
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self._connected:
return False
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(
None,
lambda: self.send_pixels_fast(pixels, brightness),
)
return True
except Exception as e:
logger.error("SPI send failed: %s", e)
return False
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Check if the SPI/GPIO device is accessible."""
import platform
if platform.system() != "Linux":
return DeviceHealth(
online=False,
error="SPI direct is only available on Linux (Raspberry Pi)",
last_checked=datetime.now(timezone.utc),
)
config = _parse_spi_url(url)
if config["method"] == "spidev":
import os
dev_path = f"/dev/spidev{config['bus']}.{config['device']}"
if os.path.exists(dev_path):
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
return DeviceHealth(
online=False,
error=f"SPI device {dev_path} not found",
last_checked=datetime.now(timezone.utc),
)
else:
# GPIO — check if we can import rpi_ws281x
try:
import rpi_ws281x
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
except ImportError:
return DeviceHealth(
online=False,
error="rpi_ws281x not installed",
last_checked=datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,133 @@
"""SPI Direct device provider — Raspberry Pi GPIO/SPI direct LED strip control."""
import platform
from datetime import datetime, timezone
from typing import List
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.core.devices.spi_client import SPIClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class SPIDeviceProvider(LEDDeviceProvider):
"""Provider for direct SPI/GPIO LED strip control on Raspberry Pi.
URL formats:
spi://gpio:18 — rpi_ws281x via GPIO pin 18 (PWM)
spi://gpio:10 — rpi_ws281x via GPIO pin 10 (SPI MOSI)
spi://spidev:0.0 — Raw SPI device /dev/spidev0.0
Requires Linux (Raspberry Pi) and root privileges or appropriate permissions.
"""
@property
def device_type(self) -> str:
return "spi"
@property
def capabilities(self) -> set:
return {"manual_led_count", "health_check", "power_control", "brightness_control"}
def create_client(self, url: str, **kwargs) -> LEDClient:
return SPIClient(
url,
led_count=kwargs.get("led_count", 0),
spi_speed_hz=kwargs.get("spi_speed_hz", 800000),
spi_led_type=kwargs.get("spi_led_type", "WS2812B"),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await SPIClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate SPI/GPIO is accessible. LED count is manual."""
if platform.system() != "Linux":
raise ValueError("SPI direct control is only available on Linux (Raspberry Pi)")
from wled_controller.core.devices.spi_client import _parse_spi_url
config = _parse_spi_url(url)
if config["method"] == "spidev":
import os
dev_path = f"/dev/spidev{config['bus']}.{config['device']}"
if not os.path.exists(dev_path):
raise ValueError(f"SPI device {dev_path} not found. Enable SPI in raspi-config.")
else:
try:
import rpi_ws281x # noqa: F401
except ImportError:
raise ValueError("rpi_ws281x library required: pip install rpi_ws281x")
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Discover available SPI devices on Raspberry Pi."""
if platform.system() != "Linux":
return []
results = []
# Check for SPI devices
import os
for bus in range(2):
for device in range(2):
path = f"/dev/spidev{bus}.{device}"
if os.path.exists(path):
results.append(
DiscoveredDevice(
name=f"SPI Bus {bus} Device {device}",
url=f"spi://spidev:{bus}.{device}",
device_type="spi",
ip="",
mac="",
led_count=None,
version=None,
)
)
# Check for GPIO availability
try:
import rpi_ws281x # noqa: F401
results.append(
DiscoveredDevice(
name="GPIO 18 (PWM0)",
url="spi://gpio:18",
device_type="spi",
ip="",
mac="",
led_count=None,
version=None,
)
)
results.append(
DiscoveredDevice(
name="GPIO 10 (SPI MOSI)",
url="spi://gpio:10",
device_type="spi",
ip="",
mac="",
led_count=None,
version=None,
)
)
except ImportError:
pass
return results
async def get_power(self, url: str, **kwargs) -> bool:
return True
async def set_power(self, url: str, on: bool, **kwargs) -> None:
"""Power off = send all-black frame."""
pass # Handled at target processor level

View File

@@ -0,0 +1,178 @@
"""USB HID LED client — controls RGB peripherals via HID protocol using hidapi."""
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
import numpy as np
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
def _parse_hid_url(url: str) -> Tuple[int, int]:
"""Parse 'hid://vendor_id:product_id' to (vid, pid) ints."""
path = url.replace("hid://", "")
parts = path.split(":")
if len(parts) != 2:
raise ValueError(f"Invalid HID URL: {url}. Expected format: hid://VID:PID (hex)")
return int(parts[0], 16), int(parts[1], 16)
class USBHIDClient(LEDClient):
"""LED client that controls RGB peripherals via USB HID reports.
Uses the hidapi library to send raw HID reports containing LED color data.
The report format is device-specific but follows a common pattern:
[REPORT_ID][MODE][LED_INDEX][R][G][B]...
For generic HID RGB devices, a bulk-set report is used:
[REPORT_ID=0x00][CMD=0x0E][ZONE_COUNT][R G B R G B ...]
"""
# Common HID report IDs and commands
REPORT_ID_LED = 0x00
CMD_SET_LEDS = 0x0E
MAX_REPORT_SIZE = 64 # typical HID report size
def __init__(
self,
url: str = "",
led_count: int = 0,
hid_usage_page: int = 0,
**kwargs,
):
self._vid, self._pid = _parse_hid_url(url)
self._led_count = led_count
self._usage_page = hid_usage_page
self._device = None
self._connected = False
async def connect(self) -> bool:
try:
import hid
except ImportError:
raise RuntimeError("hidapi is required for USB HID devices: pip install hidapi")
loop = asyncio.get_event_loop()
def _open():
device = hid.device()
device.open(self._vid, self._pid)
device.set_nonblocking(True)
return device
self._device = await loop.run_in_executor(None, _open)
self._connected = True
manufacturer = self._device.get_manufacturer_string() or "Unknown"
product = self._device.get_product_string() or "Unknown"
logger.info(
"USB HID client connected: %04X:%04X (%s %s) leds=%d",
self._vid, self._pid, manufacturer, product, self._led_count,
)
return True
async def close(self) -> None:
if self._device:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._device.close)
self._device = None
self._connected = False
logger.info("USB HID client closed: %04X:%04X", self._vid, self._pid)
@property
def is_connected(self) -> bool:
return self._connected and self._device is not None
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self.is_connected:
return False
if isinstance(pixels, np.ndarray):
pixel_list = pixels.tolist()
else:
pixel_list = list(pixels)
# Note: brightness already applied by processor loop (_cached_brightness)
# Build HID reports — split across multiple reports if needed
# Each report: [REPORT_ID][CMD][OFFSET_LO][OFFSET_HI][COUNT][R G B R G B ...]
max_leds_per_report = (self.MAX_REPORT_SIZE - 5) // 3
offset = 0
reports = []
while offset < len(pixel_list):
chunk = pixel_list[offset: offset + max_leds_per_report]
report = bytearray(self.MAX_REPORT_SIZE)
report[0] = self.REPORT_ID_LED
report[1] = self.CMD_SET_LEDS
report[2] = offset & 0xFF
report[3] = (offset >> 8) & 0xFF
report[4] = len(chunk)
for i, (r, g, b) in enumerate(chunk):
base = 5 + i * 3
report[base] = int(r)
report[base + 1] = int(g)
report[base + 2] = int(b)
reports.append(bytes(report))
offset += len(chunk)
loop = asyncio.get_event_loop()
def _send_all():
for report in reports:
self._device.write(report)
try:
await loop.run_in_executor(None, _send_all)
return True
except Exception as e:
logger.error("USB HID send failed: %s", e)
self._connected = False
return False
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Check if the HID device is present."""
try:
import hid
vid, pid = _parse_hid_url(url)
devices = hid.enumerate(vid, pid)
if devices:
info = devices[0]
return DeviceHealth(
online=True,
latency_ms=0.0,
device_name=info.get("product_string", "USB HID Device"),
last_checked=datetime.now(timezone.utc),
)
return DeviceHealth(
online=False,
error=f"HID device {vid:04X}:{pid:04X} not found",
last_checked=datetime.now(timezone.utc),
)
except ImportError:
return DeviceHealth(
online=False, error="hidapi not installed",
last_checked=datetime.now(timezone.utc),
)
except Exception as e:
return DeviceHealth(
online=False, error=str(e),
last_checked=datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,103 @@
"""USB HID LED device provider — control RGB peripherals via USB HID."""
from datetime import datetime, timezone
from typing import List
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.core.devices.usbhid_client import USBHIDClient, _parse_hid_url
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Known RGB peripheral vendor IDs and names
KNOWN_RGB_VENDORS = {
0x1532: "Razer",
0x1B1C: "Corsair",
0x1038: "SteelSeries",
0x046D: "Logitech",
0x2516: "Cooler Master",
0x0951: "HyperX",
0x3633: "Glorious",
0x320F: "NZXT",
}
class USBHIDDeviceProvider(LEDDeviceProvider):
"""Provider for USB HID RGB peripheral devices.
URL format: hid://VID:PID (hex, e.g. hid://1532:0084)
LED count = number of addressable zones/keys on the device.
"""
@property
def device_type(self) -> str:
return "usbhid"
@property
def capabilities(self) -> set:
return {"manual_led_count", "health_check"}
def create_client(self, url: str, **kwargs) -> LEDClient:
return USBHIDClient(
url,
led_count=kwargs.get("led_count", 0),
hid_usage_page=kwargs.get("hid_usage_page", 0),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await USBHIDClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate HID device exists. LED count is manual."""
try:
import hid
vid, pid = _parse_hid_url(url)
devices = hid.enumerate(vid, pid)
if not devices:
raise ValueError(f"No HID device found with VID:PID {vid:04X}:{pid:04X}")
return {}
except ImportError:
raise ValueError("hidapi is required for USB HID devices: pip install hidapi")
except ValueError:
raise
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Discover connected USB HID devices with known RGB vendor IDs."""
try:
import hid
results = []
seen = set()
for info in hid.enumerate():
vid = info.get("vendor_id", 0)
pid = info.get("product_id", 0)
key = (vid, pid)
if key in seen or vid == 0:
continue
seen.add(key)
vendor = KNOWN_RGB_VENDORS.get(vid)
if not vendor:
continue
product = info.get("product_string", "Unknown Device")
results.append(
DiscoveredDevice(
name=f"{vendor} {product}",
url=f"hid://{vid:04x}:{pid:04x}",
device_type="usbhid",
ip="",
mac="",
led_count=None,
version=None,
)
)
return results
except ImportError:
return []

View File

@@ -378,9 +378,7 @@ class WLEDClient(LEDClient):
True if successful
"""
try:
if brightness < 255:
pixels = (pixels.astype(np.uint16) * brightness >> 8).astype(np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
logger.debug(f"Sending {len(pixels)} LEDs via DDP")
self._ddp_client.send_pixels_numpy(pixels)
logger.debug(f"Successfully sent pixel colors via DDP")
@@ -419,7 +417,7 @@ class WLEDClient(LEDClient):
# Build WLED JSON state
payload = {
"on": True,
"bri": int(brightness),
"bri": 255, # brightness already applied by processor loop
"seg": [
{
"id": segment_id,
@@ -461,9 +459,7 @@ class WLEDClient(LEDClient):
else:
pixel_array = np.array(pixels, dtype=np.uint8)
if brightness < 255:
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
self._ddp_client.send_pixels_numpy(pixel_array)
# ===== LEDClient abstraction methods =====

View File

@@ -53,11 +53,10 @@ class WLEDDeviceProvider(LEDDeviceProvider):
def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.devices.wled_client import WLEDClient
kwargs.pop("led_count", None)
kwargs.pop("baud_rate", None)
kwargs.pop("send_latency_ms", None)
kwargs.pop("rgbw", None)
return WLEDClient(url, **kwargs)
return WLEDClient(
url,
use_ddp=kwargs.get("use_ddp", False),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
from wled_controller.core.devices.wled_client import WLEDClient

View File

@@ -27,7 +27,10 @@ class WSDeviceProvider(LEDDeviceProvider):
return {"manual_led_count"}
def create_client(self, url: str, **kwargs) -> LEDClient:
return WSLEDClient(url, **kwargs)
return WSLEDClient(
url,
led_count=kwargs.get("led_count", 0),
)
async def check_health(
self, url: str, http_client, prev_health=None,

View File

@@ -20,8 +20,10 @@ import wled_controller.core.filters.flip # noqa: F401
import wled_controller.core.filters.color_correction # noqa: F401
import wled_controller.core.filters.frame_interpolation # noqa: F401
import wled_controller.core.filters.filter_template # noqa: F401
import wled_controller.core.filters.css_filter_template # noqa: F401
import wled_controller.core.filters.noise_gate # noqa: F401
import wled_controller.core.filters.palette_quantization # noqa: F401
import wled_controller.core.filters.reverse # noqa: F401
__all__ = [
"FilterOptionDef",

View File

@@ -18,6 +18,7 @@ class AutoCropFilter(PostprocessingFilter):
filter_id = "auto_crop"
filter_name = "Auto Crop"
supports_strip = False
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:

View File

@@ -44,11 +44,21 @@ class PostprocessingFilter(ABC):
Each filter operates on a full image (np.ndarray H×W×3 uint8).
Filters that preserve dimensions modify in-place and return None.
Filters that change dimensions return a new array from the image pool.
Filters that also support 1D LED strip arrays (N×3 uint8) should
leave ``supports_strip = True`` (the default). The base class
provides a generic ``process_strip`` that reshapes (N,3) → (1,N,3),
delegates to ``process_image``, and reshapes back. Subclasses may
override for a more efficient implementation.
Filters that are purely spatial (auto-crop, downscaler, flip) should
set ``supports_strip = False``.
"""
filter_id: str = ""
filter_name: str = ""
supports_idle_frames: bool = False
supports_strip: bool = True
def __init__(self, options: Dict[str, Any]):
"""Initialize filter with validated options."""
@@ -74,6 +84,31 @@ class PostprocessingFilter(ABC):
"""
...
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
"""Process a 1D LED strip array (N, 3) uint8.
Default implementation reshapes to (1, N, 3), calls process_image
with a no-op pool, and reshapes back. Override for filters that
need strip-specific behaviour or use ImagePool.
Returns:
None if modified in-place.
New np.ndarray if a new array was created.
"""
from wled_controller.core.filters.image_pool import ImagePool
img = strip[np.newaxis, :, :] # (1, N, 3)
pool = ImagePool(max_size=2)
result = self.process_image(img, pool)
if result is not None:
out = result[0] # (N, 3)
pool.release_all()
return out
# Modified in-place — extract back
np.copyto(strip, img[0])
pool.release_all()
return None
@classmethod
def validate_options(cls, options: dict) -> dict:
"""Validate and clamp options against the schema. Returns cleaned dict."""

View File

@@ -0,0 +1,46 @@
"""CSS Filter Template meta-filter — references a color strip processing template.
This filter exists in the registry for UI discovery only. It is never
instantiated at runtime: the store expands it into the referenced
template's filters when building the processing pipeline.
"""
from typing import List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class CSSFilterTemplateFilter(PostprocessingFilter):
"""Include another color strip processing template's chain at this position."""
filter_id = "css_filter_template"
filter_name = "Strip Filter Template"
supports_strip = True
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="template_id",
label="Template",
option_type="select",
default="",
min_value=None,
max_value=None,
step=None,
choices=[], # populated dynamically by GET /api/v1/strip-filters
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
# Never called — expanded at pipeline build time.
return None
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
# Never called — expanded at pipeline build time.
return None

View File

@@ -16,6 +16,7 @@ class DownscalerFilter(PostprocessingFilter):
filter_id = "downscaler"
filter_name = "Downscaler"
supports_strip = False
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:

View File

@@ -20,6 +20,7 @@ class FilterTemplateFilter(PostprocessingFilter):
filter_id = "filter_template"
filter_name = "Filter Template"
supports_strip = False
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:

View File

@@ -15,6 +15,7 @@ class FlipFilter(PostprocessingFilter):
filter_id = "flip"
filter_name = "Flip"
supports_strip = False
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:

View File

@@ -59,15 +59,23 @@ class FrameInterpolationFilter(PostprocessingFilter):
None — image passes through unchanged (no blend needed).
ndarray — blended output acquired from image_pool.
"""
return self._blend(image, lambda shape: image_pool.acquire(*shape))
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
"""Frame interpolation for 1D LED strips — allocates directly."""
return self._blend(strip, lambda shape: np.empty(shape, dtype=np.uint8))
def _blend(self, data: np.ndarray, alloc_fn) -> Optional[np.ndarray]:
"""Shared blend logic for both images and strips."""
now = time.perf_counter()
# Detect new vs idle frame via cheap 64-byte signature
sig = bytes(image.ravel()[:64])
sig = bytes(data.ravel()[:64])
if sig != self._sig_b:
# New source frame — shift A ← B, B ← current
self._frame_a = self._frame_b
self._time_a = self._time_b
self._frame_b = image.copy()
self._frame_b = data.copy()
self._time_b = now
self._sig_b = sig
@@ -83,8 +91,7 @@ class FrameInterpolationFilter(PostprocessingFilter):
# Blend: output = (1 - alpha)*A + alpha*B (integer fast path)
alpha_i = int(alpha * 256)
h, w, c = image.shape
shape = (h, w, c)
shape = data.shape
# Resize scratch buffers on shape change
if self._blend_shape != shape:
@@ -92,9 +99,9 @@ class FrameInterpolationFilter(PostprocessingFilter):
self._u16_b = np.empty(shape, dtype=np.uint16)
self._blend_shape = shape
out = image_pool.acquire(h, w, c)
out = alloc_fn(shape)
np.copyto(self._u16_a, self._frame_a, casting='unsafe')
np.copyto(self._u16_b, image, casting='unsafe')
np.copyto(self._u16_b, data, casting='unsafe')
self._u16_a *= (256 - alpha_i)
self._u16_b *= alpha_i
self._u16_a += self._u16_b

View File

@@ -83,7 +83,7 @@ class PaletteQuantizationFilter(PostprocessingFilter):
min_value=None,
max_value=None,
step=None,
choices=[{"value": k, "label": k.capitalize()} for k in _PRESETS],
choices=[{"value": k, "label": k.capitalize(), "colors": v} for k, v in _PRESETS.items()],
),
FilterOptionDef(
key="colors",

View File

@@ -0,0 +1,30 @@
"""Reverse filter — reverses the LED order in a 1D strip."""
from typing import List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class ReverseFilter(PostprocessingFilter):
"""Reverses the order of LEDs in a color strip."""
filter_id = "reverse"
filter_name = "Reverse"
supports_strip = True
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return []
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
"""Reverse image horizontally (for 2D fallback)."""
return image[:, ::-1].copy()
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
"""Reverse the LED array order."""
return strip[::-1].copy()

View File

@@ -4,9 +4,9 @@ External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
buffers the latest frame and serves it to targets. When no data has been
received within `timeout` seconds, LEDs revert to `fallback_color`.
Thread-safe: push_colors() can be called from any thread (REST handler,
WebSocket handler) while get_latest_colors() is called from the target
processor thread.
Thread-safe: push_colors() / push_segments() can be called from any thread
(REST handler, WebSocket handler) while get_latest_colors() is called from
the target processor thread.
"""
import threading
@@ -20,13 +20,16 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
_DEFAULT_LED_COUNT = 150
class ApiInputColorStripStream(ColorStripStream):
"""Color strip stream backed by externally-pushed LED color data.
Holds a thread-safe np.ndarray buffer. External clients push colors via
push_colors(). A background thread checks for timeout and reverts to
fallback_color when no data arrives within the configured timeout window.
push_colors() or push_segments(). A background thread checks for timeout
and reverts to fallback_color when no data arrives within the configured
timeout window.
"""
def __init__(self, source):
@@ -43,14 +46,14 @@ class ApiInputColorStripStream(ColorStripStream):
fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
self._auto_size = not source.led_count
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
self._led_count = _DEFAULT_LED_COUNT
# Build initial fallback buffer
self._fallback_array = self._build_fallback(self._led_count)
self._colors = self._fallback_array.copy()
self._last_push_time: float = 0.0
self._timed_out = True # Start in timed-out state
self._push_generation: int = 0 # Incremented on each push; used by test WS
def _build_fallback(self, led_count: int) -> np.ndarray:
"""Build a (led_count, 3) uint8 array filled with fallback_color."""
@@ -59,40 +62,128 @@ class ApiInputColorStripStream(ColorStripStream):
(led_count, 1),
)
def _ensure_capacity(self, required: int) -> None:
"""Grow the buffer to at least `required` LEDs (must be called under lock)."""
if required > self._led_count:
self._led_count = required
self._fallback_array = self._build_fallback(self._led_count)
# Preserve existing data if not timed out
if not self._timed_out:
new_buf = self._fallback_array.copy()
old_len = min(len(self._colors), required)
new_buf[:old_len] = self._colors[:old_len]
self._colors = new_buf
else:
self._colors = self._fallback_array.copy()
logger.debug(f"ApiInputColorStripStream buffer grown to {required} LEDs")
def push_colors(self, colors: np.ndarray) -> None:
"""Push a new frame of LED colors.
Thread-safe. The array is truncated or zero-padded to match led_count.
Thread-safe. Auto-grows the buffer if the incoming array is larger
than the current buffer; otherwise truncates or zero-pads.
Args:
colors: np.ndarray shape (N, 3) uint8
"""
with self._lock:
n = len(colors)
# Auto-grow if incoming data is larger
if n > self._led_count:
self._ensure_capacity(n)
if n == self._led_count:
self._colors = colors.astype(np.uint8)
elif n > self._led_count:
self._colors = colors[:self._led_count].astype(np.uint8)
else:
if self._colors.shape == colors.shape:
np.copyto(self._colors, colors, casting='unsafe')
else:
self._colors = np.empty((n, 3), dtype=np.uint8)
np.copyto(self._colors, colors, casting='unsafe')
elif n < self._led_count:
# Zero-pad to led_count
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
padded[:n] = colors[:n]
self._colors = padded
self._last_push_time = time.monotonic()
self._push_generation += 1
self._timed_out = False
def push_segments(self, segments: list) -> None:
"""Apply segment-based color updates to the buffer.
Each segment defines a range and fill mode. Segments are applied in
order (last wins on overlap). The buffer is auto-grown if needed.
Args:
segments: list of dicts with keys:
start (int) starting LED index
length (int) number of LEDs in segment
mode (str) "solid" | "per_pixel" | "gradient"
color (list) [R,G,B] for solid mode
colors (list) [[R,G,B], ...] for per_pixel/gradient
"""
# Compute required buffer size from all segments
max_index = max(seg["start"] + seg["length"] for seg in segments)
with self._lock:
# Auto-grow buffer if needed
if max_index > self._led_count:
self._ensure_capacity(max_index)
# Start from current buffer (or fallback if timed out)
if self._timed_out:
buf = self._fallback_array.copy()
else:
buf = self._colors.copy()
for seg in segments:
start = seg["start"]
length = seg["length"]
mode = seg["mode"]
end = start + length
if mode == "solid":
color = np.array(seg["color"], dtype=np.uint8)
buf[start:end] = color
elif mode == "per_pixel":
colors = np.array(seg["colors"], dtype=np.uint8)
available = len(colors)
if available >= length:
buf[start:end] = colors[:length]
else:
# Pad with zeros if fewer colors than length
buf[start:start + available] = colors
buf[start + available:end] = 0
elif mode == "gradient":
stops = np.array(seg["colors"], dtype=np.float32)
num_stops = len(stops)
# Positions of stops evenly spaced 0..length-1
stop_positions = np.linspace(0, length - 1, num_stops)
pixel_positions = np.arange(length, dtype=np.float32)
for ch in range(3):
buf[start:end, ch] = np.interp(
pixel_positions,
stop_positions,
stops[:, ch],
).astype(np.uint8)
self._colors = buf
self._last_push_time = time.monotonic()
self._push_generation += 1
self._timed_out = False
def configure(self, device_led_count: int) -> None:
"""Set LED count from the target device (called on target start).
Only takes effect when led_count was 0 (auto-size).
Always resizes the buffer to the device LED count.
"""
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
if device_led_count > 0 and device_led_count != self._led_count:
with self._lock:
self._led_count = device_led_count
self._fallback_array = self._build_fallback(device_led_count)
self._colors = self._fallback_array.copy()
self._timed_out = True
logger.debug(f"ApiInputColorStripStream auto-sized to {device_led_count} LEDs")
logger.debug(f"ApiInputColorStripStream configured to {device_led_count} LEDs")
@property
def target_fps(self) -> int:
@@ -131,6 +222,11 @@ class ApiInputColorStripStream(ColorStripStream):
with self._lock:
return self._colors
@property
def push_generation(self) -> int:
"""Monotonically increasing counter, bumped on each push_colors/push_segments."""
return self._push_generation
def update_source(self, source) -> None:
"""Hot-update fallback_color and timeout from updated source config."""
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
@@ -138,19 +234,10 @@ class ApiInputColorStripStream(ColorStripStream):
fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
prev_led_count = self._led_count if self._auto_size else None
self._auto_size = not source.led_count
with self._lock:
self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out:
self._colors = self._fallback_array.copy()
# Preserve runtime LED count across updates if auto-sized
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
with self._lock:
self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out:
self._colors = self._fallback_array.copy()
logger.info("ApiInputColorStripStream params updated in-place")
def _timeout_loop(self) -> None:

View File

@@ -251,6 +251,7 @@ class AudioColorStripStream(ColorStripStream):
_full_amp = np.empty(n, dtype=np.float32)
_vu_gradient = np.linspace(0, 1, n, dtype=np.float32)
_indices_buf = np.empty(n, dtype=np.int32)
_f32_rgb = np.empty((n, 3), dtype=np.float32)
self._prev_spectrum = None # reset smoothing on resize
# Make pre-computed arrays available to render methods
@@ -260,6 +261,7 @@ class AudioColorStripStream(ColorStripStream):
self._full_amp = _full_amp
self._vu_gradient = _vu_gradient
self._indices_buf = _indices_buf
self._f32_rgb = _f32_rgb
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
@@ -352,8 +354,11 @@ class AudioColorStripStream(ColorStripStream):
# Scale brightness by amplitude — restore full_amp to [0, 1]
full_amp *= (1.0 / 255.0)
for ch in range(3):
buf[:, ch] = (colors[:, ch].astype(np.float32) * full_amp).astype(np.uint8)
f32_rgb = self._f32_rgb
np.copyto(f32_rgb, colors, casting='unsafe')
f32_rgb *= full_amp[:, np.newaxis]
np.clip(f32_rgb, 0, 255, out=f32_rgb)
np.copyto(buf, f32_rgb, casting='unsafe')
# ── VU Meter ───────────────────────────────────────────────────

View File

@@ -0,0 +1,249 @@
"""Candlelight LED stream — realistic per-LED candle flickering.
Implements CandlelightColorStripStream which produces warm, organic
flickering across all LEDs using layered sine waves and value noise.
Each "candle" is an independent flicker source that illuminates
nearby LEDs with smooth falloff.
"""
import math
import threading
import time
from typing import Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
# ── Simple hash-based noise ──────────────────────────────────────────
_PERM = np.arange(256, dtype=np.int32)
_rng = np.random.RandomState(seed=17)
_rng.shuffle(_PERM)
_PERM = np.concatenate([_PERM, _PERM]) # 512 entries for wrap-free indexing
def _noise1d(x: np.ndarray) -> np.ndarray:
"""Fast 1-D value noise (vectorized). Returns float32 in [0, 1]."""
xi = x.astype(np.int32) & 255
xf = x - np.floor(x)
# Smoothstep
u = xf * xf * (3.0 - 2.0 * xf)
a = _PERM[xi].astype(np.float32) / 255.0
b = _PERM[xi + 1].astype(np.float32) / 255.0
return a + u * (b - a)
class CandlelightColorStripStream(ColorStripStream):
"""Color strip stream simulating realistic candle flickering.
Each LED flickers independently with warm tones. Multiple
"candle sources" are distributed along the strip, each generating
its own flicker pattern with smooth spatial falloff.
"""
def __init__(self, source):
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
self._clock = None
self._led_count = 1
self._auto_size = True
# Scratch arrays
self._s_bright: Optional[np.ndarray] = None
self._s_noise: Optional[np.ndarray] = None
self._s_x: Optional[np.ndarray] = None
self._pool_n = 0
self._update_from_source(source)
def _update_from_source(self, source) -> None:
raw_color = getattr(source, "color", None)
self._color = raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41]
self._intensity = float(getattr(source, "intensity", 1.0))
self._num_candles = max(1, int(getattr(source, "num_candles", 3)))
self._speed = float(getattr(source, "speed", 1.0))
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 0 else 1
with self._colors_lock:
self._colors: Optional[np.ndarray] = None
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
if new_count != self._led_count:
self._led_count = new_count
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
return self._led_count
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name="css-candlelight",
daemon=True,
)
self._thread.start()
logger.info(f"CandlelightColorStripStream started (leds={self._led_count}, candles={self._num_candles})")
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning("CandlelightColorStripStream thread did not terminate within 5s")
self._thread = None
logger.info("CandlelightColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import CandlelightColorStripSource
if isinstance(source, CandlelightColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
logger.info("CandlelightColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
self._clock = clock
# ── Animation loop ──────────────────────────────────────────────
def _animate_loop(self) -> None:
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
try:
with high_resolution_timer():
while self._running:
wall_start = time.perf_counter()
frame_time = self._frame_time
try:
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
t = clock.get_time()
speed = clock.speed * self._speed
else:
t = wall_start
speed = self._speed
n = self._led_count
if n != _pool_n:
_pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = np.empty((n, 3), dtype=np.uint8)
self._s_bright = np.empty(n, dtype=np.float32)
self._s_noise = np.empty(n, dtype=np.float32)
self._s_x = np.arange(n, dtype=np.float32)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
self._render_candlelight(buf, n, t, speed)
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"CandlelightColorStripStream animation error: {e}")
elapsed = time.perf_counter() - wall_start
time.sleep(max(frame_time - elapsed, 0.001))
except Exception as e:
logger.error(f"Fatal CandlelightColorStripStream loop error: {e}", exc_info=True)
finally:
self._running = False
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float) -> None:
"""Render candle flickering into buf (n, 3) uint8.
Algorithm:
- Place num_candles evenly along the strip
- Each candle has independent layered-sine flicker
- Spatial falloff: LEDs near a candle are brighter
- Per-LED noise adds individual variation
- Final brightness modulates the base warm color
"""
# Scale speed so that speed=1 gives a gentle ~1.3 Hz dominant flicker
speed = speed * 0.35
intensity = self._intensity
num_candles = self._num_candles
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
bright = self._s_bright
bright[:] = 0.0
# Candle positions: evenly distributed
if num_candles == 1:
positions = [n / 2.0]
else:
positions = [i * (n - 1) / (num_candles - 1) for i in range(num_candles)]
x = self._s_x[:n]
for ci, pos in enumerate(positions):
# Independent flicker for this candle: layered sines at different frequencies
# Use candle index as phase offset for independence
offset = ci * 137.5 # golden-angle offset for non-repeating
flicker = (
0.40 * math.sin(2 * math.pi * speed * t * 3.7 + offset)
+ 0.25 * math.sin(2 * math.pi * speed * t * 7.3 + offset * 0.7)
+ 0.15 * math.sin(2 * math.pi * speed * t * 13.1 + offset * 1.3)
+ 0.10 * math.sin(2 * math.pi * speed * t * 1.9 + offset * 0.3)
)
# Normalize flicker to [0.3, 1.0] range (candles never fully go dark)
candle_brightness = 0.65 + 0.35 * flicker * intensity
# Spatial falloff: Gaussian centered on candle position
# sigma proportional to strip length / num_candles
sigma = max(n / (num_candles * 2.0), 2.0)
dist = x - pos
falloff = np.exp(-0.5 * (dist * dist) / (sigma * sigma))
bright += candle_brightness * falloff
# Per-LED noise for individual variation
noise_x = x * 0.3 + t * speed * 5.0
noise = _noise1d(noise_x)
# Modulate brightness with noise (±15%)
bright *= (0.85 + 0.30 * noise)
# Clamp to [0, 1]
np.clip(bright, 0.0, 1.0, out=bright)
# Apply base color with brightness modulation
# Candles emit warmer (more red, less blue) at lower brightness
# Add slight color variation: dimmer = warmer
warm_shift = (1.0 - bright) * 0.3
r = bright * base_r
g = bright * base_g * (1.0 - warm_shift * 0.5)
b = bright * base_b * (1.0 - warm_shift)
buf[:, 0] = np.clip(r, 0, 255).astype(np.uint8)
buf[:, 1] = np.clip(g, 0, 255).astype(np.uint8)
buf[:, 2] = np.clip(b, 0, 255).astype(np.uint8)

View File

@@ -6,7 +6,7 @@ by processing frames from a LiveStream.
Multiple WledTargetProcessors may read from the same ColorStripStream instance
(shared via ColorStripStreamManager reference counting), meaning the CPU-bound
processing — border extraction, pixel mapping, color correction — runs only once
processing — border extraction, pixel mapping, smoothing — runs only once
even when multiple devices share the same source configuration.
"""
@@ -28,54 +28,6 @@ from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
def _apply_saturation(colors: np.ndarray, saturation: float,
_i32: np.ndarray = None, _i32_gray: np.ndarray = None,
_out: np.ndarray = None) -> np.ndarray:
"""Adjust saturation via luminance mixing (Rec.601 weights, integer math).
saturation=1.0: no change
saturation=0.0: grayscale
saturation=2.0: double saturation (clipped to 0-255)
Optional pre-allocated scratch buffers (_i32, _i32_gray, _out) avoid
per-frame allocations when called from a hot loop.
"""
n = len(colors)
if _i32 is None:
_i32 = np.empty((n, 3), dtype=np.int32)
if _i32_gray is None:
_i32_gray = np.empty((n, 1), dtype=np.int32)
if _out is None:
_out = np.empty((n, 3), dtype=np.uint8)
sat_int = int(saturation * 256)
np.copyto(_i32, colors, casting='unsafe')
_i32_gray[:, 0] = (_i32[:, 0] * 299 + _i32[:, 1] * 587 + _i32[:, 2] * 114) // 1000
_i32 *= sat_int
_i32_gray *= (256 - sat_int)
_i32 += _i32_gray
_i32 >>= 8
np.clip(_i32, 0, 255, out=_i32)
np.copyto(_out, _i32, casting='unsafe')
return _out
def _build_gamma_lut(gamma: float) -> np.ndarray:
"""Build a 256-entry uint8 LUT for gamma correction.
gamma=1.0: identity (no correction)
gamma<1.0: brighter midtones (gamma < 1 lifts shadows)
gamma>1.0: darker midtones (standard LED gamma, e.g. 2.22.8)
"""
if gamma == 1.0:
return np.arange(256, dtype=np.uint8)
lut = np.array(
[min(255, int(((i / 255.0) ** gamma) * 255 + 0.5)) for i in range(256)],
dtype=np.uint8,
)
return lut
class ColorStripStream(ABC):
"""Abstract base: a runtime source of LED color arrays.
@@ -142,10 +94,7 @@ class PictureColorStripStream(ColorStripStream):
2. Extracts border pixels using the calibration's border_width
3. Maps border pixels to LED colors via PixelMapper
4. Applies temporal smoothing
5. Applies saturation correction
6. Applies gamma correction (LUT-based, O(1) per pixel)
7. Applies brightness scaling
8. Caches the result for lock-free consumer reads
5. Caches the result for lock-free consumer reads
Processing parameters can be hot-updated via update_source() without
restarting the thread (except when the underlying LiveStream changes).
@@ -167,11 +116,10 @@ class PictureColorStripStream(ColorStripStream):
else:
self._live_streams = {}
self._live_stream = live_stream
self._fps: int = 30 # internal capture rate (send FPS is on the target)
self._frame_time: float = 1.0 / 30
self._smoothing: float = source.smoothing
self._brightness: float = source.brightness
self._saturation: float = source.saturation
self._gamma: float = source.gamma
self._interpolation_mode: str = source.interpolation_mode
self._calibration: CalibrationConfig = source.calibration
self._pixel_mapper = create_pixel_mapper(
@@ -179,25 +127,21 @@ class PictureColorStripStream(ColorStripStream):
)
cal_leds = self._calibration.get_total_leds()
self._led_count: int = source.led_count if source.led_count > 0 else cal_leds
self._gamma_lut: np.ndarray = _build_gamma_lut(self._gamma)
# Thread-safe color cache
self._latest_colors: Optional[np.ndarray] = None
self._colors_lock = threading.Lock()
self._previous_colors: Optional[np.ndarray] = None
# Frame interpolation state
self._frame_interpolation: bool = source.frame_interpolation
self._interp_from: Optional[np.ndarray] = None
self._interp_to: Optional[np.ndarray] = None
self._interp_start: float = 0.0
self._interp_duration: float = 1.0 / self._fps if self._fps > 0 else 1.0
self._last_capture_time: float = 0.0
self._running = False
self._thread: Optional[threading.Thread] = None
self._last_timing: dict = {}
@property
def live_stream(self):
"""Public accessor for the underlying LiveStream (used by preview WebSocket)."""
return self._live_stream
@property
def target_fps(self) -> int:
return self._fps
@@ -239,9 +183,6 @@ class PictureColorStripStream(ColorStripStream):
self._thread = None
self._latest_colors = None
self._previous_colors = None
self._interp_from = None
self._interp_to = None
self._last_capture_time = 0.0
logger.info("PictureColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
@@ -256,7 +197,7 @@ class PictureColorStripStream(ColorStripStream):
fps = max(1, min(90, fps))
if fps != self._fps:
self._fps = fps
self._interp_duration = 1.0 / fps
self._frame_time = 1.0 / fps
logger.info(f"PictureColorStripStream capture FPS set to {fps}")
def update_source(self, source) -> None:
@@ -270,12 +211,6 @@ class PictureColorStripStream(ColorStripStream):
return
self._smoothing = source.smoothing
self._brightness = source.brightness
self._saturation = source.saturation
if source.gamma != self._gamma:
self._gamma = source.gamma
self._gamma_lut = _build_gamma_lut(source.gamma)
if (
source.interpolation_mode != self._interpolation_mode
@@ -291,11 +226,6 @@ class PictureColorStripStream(ColorStripStream):
)
self._previous_colors = None # Reset smoothing history on calibration change
if source.frame_interpolation != self._frame_interpolation:
self._frame_interpolation = source.frame_interpolation
self._interp_from = None
self._interp_to = None
logger.info("PictureColorStripStream params updated in-place")
def _processing_loop(self) -> None:
@@ -306,8 +236,7 @@ class PictureColorStripStream(ColorStripStream):
_pool_n = 0
_frame_a = _frame_b = None # double-buffered uint8 output
_use_a = True
_u16_a = _u16_b = None # uint16 scratch for smoothing / interp blending
_i32 = _i32_gray = None # int32 scratch for saturation + brightness
_u16_a = _u16_b = None # uint16 scratch for smoothing blending
def _blend_u16(a, b, alpha_b, out):
"""Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8.
@@ -323,62 +252,20 @@ class PictureColorStripStream(ColorStripStream):
_u16_a >>= 8
np.copyto(out, _u16_a, casting='unsafe')
def _apply_corrections(led_colors, frame_buf):
"""Apply saturation, gamma, brightness using pre-allocated scratch.
Returns the (possibly reassigned) led_colors array.
"""
nonlocal _i32
if self._saturation != 1.0:
_apply_saturation(led_colors, self._saturation, _i32, _i32_gray, led_colors)
if self._gamma != 1.0:
led_colors = self._gamma_lut[led_colors]
if self._brightness != 1.0:
bright_int = int(self._brightness * 256)
np.copyto(_i32, led_colors, casting='unsafe')
_i32 *= bright_int
_i32 >>= 8
np.clip(_i32, 0, 255, out=_i32)
np.copyto(frame_buf, _i32, casting='unsafe')
led_colors = frame_buf
return led_colors
try:
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
fps = self._fps
frame_time = 1.0 / fps if fps > 0 else 1.0
frame_time = self._frame_time
try:
frame = self._live_stream.get_latest_frame()
if frame is None or frame is cached_frame:
if (
frame is not None
and self._frame_interpolation
and self._interp_from is not None
and self._interp_to is not None
and _u16_a is not None
):
# Interpolate between previous and current capture
t = min(1.0, (loop_start - self._interp_start) / self._interp_duration)
frame_buf = _frame_a if _use_a else _frame_b
_use_a = not _use_a
_blend_u16(self._interp_from, self._interp_to, int(t * 256), frame_buf)
led_colors = _apply_corrections(frame_buf, frame_buf)
with self._colors_lock:
self._latest_colors = led_colors
elapsed = time.perf_counter() - loop_start
time.sleep(max(frame_time - elapsed, 0.001))
continue
interval = (
loop_start - self._last_capture_time
if self._last_capture_time > 0
else frame_time
)
self._last_capture_time = loop_start
cached_frame = frame
t0 = time.perf_counter()
@@ -410,8 +297,6 @@ class PictureColorStripStream(ColorStripStream):
_frame_b = np.empty((_n, 3), dtype=np.uint8)
_u16_a = np.empty((_n, 3), dtype=np.uint16)
_u16_b = np.empty((_n, 3), dtype=np.uint16)
_i32 = np.empty((_n, 3), dtype=np.int32)
_i32_gray = np.empty((_n, 1), dtype=np.int32)
self._previous_colors = None
# Copy/pad into double-buffered frame (avoids per-frame allocations)
@@ -440,38 +325,6 @@ class PictureColorStripStream(ColorStripStream):
int(smoothing * 256), led_colors)
t3 = time.perf_counter()
# Update interpolation buffers (smoothed colors, before corrections)
# Must be AFTER smoothing so idle-tick interpolation produces
# output consistent with new-frame ticks (both smoothed).
if self._frame_interpolation:
self._interp_from = self._interp_to
self._interp_to = led_colors.copy()
self._interp_start = loop_start
self._interp_duration = max(interval, 0.001)
# Saturation (pre-allocated int32 scratch)
saturation = self._saturation
if saturation != 1.0:
_apply_saturation(led_colors, saturation, _i32, _i32_gray, led_colors)
t4 = time.perf_counter()
# Gamma (LUT lookup — O(1) per pixel)
if self._gamma != 1.0:
led_colors = self._gamma_lut[led_colors]
t5 = time.perf_counter()
# Brightness (integer math with pre-allocated int32 scratch)
brightness = self._brightness
if brightness != 1.0:
bright_int = int(brightness * 256)
np.copyto(_i32, led_colors, casting='unsafe')
_i32 *= bright_int
_i32 >>= 8
np.clip(_i32, 0, 255, out=_i32)
np.copyto(frame_buf, _i32, casting='unsafe')
led_colors = frame_buf
t6 = time.perf_counter()
self._previous_colors = led_colors
with self._colors_lock:
@@ -481,10 +334,7 @@ class PictureColorStripStream(ColorStripStream):
"extract_ms": (t1 - t0) * 1000,
"map_leds_ms": (t2 - t1) * 1000,
"smooth_ms": (t3 - t2) * 1000,
"saturation_ms": (t4 - t3) * 1000,
"gamma_ms": (t5 - t4) * 1000,
"brightness_ms": (t6 - t5) * 1000,
"total_ms": (t6 - t0) * 1000,
"total_ms": (t3 - t0) * 1000,
}
except Exception as e:
@@ -1201,12 +1051,52 @@ class GradientColorStripStream(ColorStripStream):
elif atype == "rainbow_fade":
h_shift = (speed * t * 0.1) % 1.0
for i in range(n):
r, g, b = base[i]
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
new_h = (h + h_shift) % 1.0
nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3))
buf[i] = (int(nr * 255), int(ng * 255), int(nb * 255))
# Vectorized RGB->HSV shift->RGB (no per-LED colorsys)
rgb_f = base.astype(np.float32) * (1.0 / 255.0)
r_f = rgb_f[:, 0]
g_f = rgb_f[:, 1]
b_f = rgb_f[:, 2]
cmax = np.maximum(np.maximum(r_f, g_f), b_f)
cmin = np.minimum(np.minimum(r_f, g_f), b_f)
delta = cmax - cmin
# Hue
h_arr = np.zeros(n, dtype=np.float32)
mask_r = (delta > 0) & (cmax == r_f)
mask_g = (delta > 0) & (cmax == g_f) & ~mask_r
mask_b = (delta > 0) & ~mask_r & ~mask_g
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
h_arr *= (1.0 / 6.0)
h_arr %= 1.0
# Saturation & Value with clamping
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
np.maximum(s_arr, 0.5, out=s_arr)
v_arr = cmax.copy()
np.maximum(v_arr, 0.3, out=v_arr)
# Shift hue
h_arr += h_shift
h_arr %= 1.0
# Vectorized HSV->RGB
h6 = h_arr * 6.0
hi = h6.astype(np.int32) % 6
f_arr = h6 - np.floor(h6)
p = v_arr * (1.0 - s_arr)
q = v_arr * (1.0 - s_arr * f_arr)
tt = v_arr * (1.0 - s_arr * (1.0 - f_arr))
ro = np.empty(n, dtype=np.float32)
go = np.empty(n, dtype=np.float32)
bo = np.empty(n, dtype=np.float32)
for sxt, rv, gv, bv in (
(0, v_arr, tt, p), (1, q, v_arr, p),
(2, p, v_arr, tt), (3, p, q, v_arr),
(4, tt, p, v_arr), (5, v_arr, p, q),
):
m = hi == sxt
ro[m] = rv[m]; go[m] = gv[m]; bo[m] = bv[m]
buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8)
buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8)
buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
colors = buf
if colors is not None:

View File

@@ -18,9 +18,12 @@ from wled_controller.core.processing.color_strip_stream import (
PictureColorStripStream,
StaticColorStripStream,
)
from wled_controller.core.processing.processed_stream import ProcessedColorStripStream
from wled_controller.core.processing.effect_stream import EffectColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
from wled_controller.core.processing.daylight_stream import DaylightColorStripStream
from wled_controller.core.processing.candlelight_stream import CandlelightColorStripStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -33,6 +36,8 @@ _SIMPLE_STREAM_MAP = {
"effect": EffectColorStripStream,
"api_input": ApiInputColorStripStream,
"notification": NotificationColorStripStream,
"daylight": DaylightColorStripStream,
"candlelight": CandlelightColorStripStream,
}
@@ -64,7 +69,7 @@ class ColorStripStreamManager:
keyed by ``{css_id}:{consumer_id}``.
"""
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None):
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None):
"""
Args:
color_strip_store: ColorStripStore for resolving source configs
@@ -72,6 +77,8 @@ class ColorStripStreamManager:
audio_capture_manager: AudioCaptureManager for audio-reactive sources
audio_source_store: AudioSourceStore for resolving audio source chains
sync_clock_manager: SyncClockManager for acquiring clock runtimes
value_stream_manager: ValueStreamManager for per-layer brightness sources
cspt_store: ColorStripProcessingTemplateStore for per-layer filter chains
"""
self._color_strip_store = color_strip_store
self._live_stream_manager = live_stream_manager
@@ -79,6 +86,8 @@ class ColorStripStreamManager:
self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store
self._sync_clock_manager = sync_clock_manager
self._value_stream_manager = value_stream_manager
self._cspt_store = cspt_store
self._streams: Dict[str, _ColorStripEntry] = {}
def _inject_clock(self, css_stream, source) -> Optional[str]:
@@ -155,10 +164,12 @@ class ColorStripStreamManager:
css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store, self._audio_template_store)
elif source.source_type == "composite":
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
css_stream = CompositeColorStripStream(source, self)
css_stream = CompositeColorStripStream(source, self, self._value_stream_manager, self._cspt_store)
elif source.source_type == "mapped":
from wled_controller.core.processing.mapped_stream import MappedColorStripStream
css_stream = MappedColorStripStream(source, self)
elif source.source_type == "processed":
css_stream = ProcessedColorStripStream(source, self, self._cspt_store)
else:
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls:

View File

@@ -16,6 +16,7 @@ _BLEND_NORMAL = "normal"
_BLEND_ADD = "add"
_BLEND_MULTIPLY = "multiply"
_BLEND_SCREEN = "screen"
_BLEND_OVERRIDE = "override"
class CompositeColorStripStream(ColorStripStream):
@@ -29,23 +30,40 @@ class CompositeColorStripStream(ColorStripStream):
sub-stream's latest colors and blending bottom-to-top.
"""
def __init__(self, source, css_manager):
def __init__(self, source, css_manager, value_stream_manager=None, cspt_store=None):
import uuid as _uuid
self._source_id: str = source.id
self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races
self._layers: List[dict] = list(source.layers)
self._led_count: int = source.led_count
self._auto_size: bool = source.led_count == 0
self._css_manager = css_manager
self._value_stream_manager = value_stream_manager
self._cspt_store = cspt_store
self._fps: int = 30
self._frame_time: float = 1.0 / 30
self._running = False
self._thread: Optional[threading.Thread] = None
self._latest_colors: Optional[np.ndarray] = None
self._latest_layer_colors: Optional[List[np.ndarray]] = None
self._colors_lock = threading.Lock()
self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called
# layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams access across threads
# layer_index -> (vs_id, value_stream)
self._brightness_streams: Dict[int, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams and _brightness_streams
self._sub_streams_version: int = 0 # bumped when _sub_streams changes
self._sub_snapshot_version: int = -1 # version of cached snapshot
self._sub_snapshot_cache: Dict[int, tuple] = {} # cached dict(self._sub_streams)
# Pre-resolved blend methods: blend_mode_str -> bound method
self._blend_methods = {
k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()
}
self._default_blend_method = self._blend_normal
# Pre-allocated scratch (rebuilt when LED count changes)
self._pool_n = 0
@@ -101,6 +119,28 @@ class CompositeColorStripStream(ColorStripStream):
with self._colors_lock:
return self._latest_colors
def get_layer_colors(self) -> Optional[List[np.ndarray]]:
"""Return per-layer color snapshots (after resize/brightness, before blending)."""
self._need_layer_snapshots = True
with self._colors_lock:
return self._latest_layer_colors
def get_layer_brightness(self) -> List[Optional[float]]:
"""Return per-layer brightness values (0.0-1.0) from value sources, or None if no source."""
enabled = [l for l in self._layers if l.get("enabled", True)]
result: List[Optional[float]] = []
with self._sub_lock:
for i in range(len(enabled)):
if i in self._brightness_streams:
_vs_id, vs = self._brightness_streams[i]
try:
result.append(vs.get_value())
except Exception:
result.append(None)
else:
result.append(None)
return result
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
self._led_count = device_led_count
@@ -115,9 +155,9 @@ class CompositeColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
"""Hot-update: rebuild sub-streams if layer config changed."""
new_layers = list(source.layers)
old_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"))
old_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"), l.get("brightness_source_id"))
for l in self._layers]
new_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"))
new_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"), l.get("brightness_source_id"))
for l in new_layers]
self._layers = new_layers
@@ -136,13 +176,14 @@ class CompositeColorStripStream(ColorStripStream):
# ── Sub-stream lifecycle ────────────────────────────────────
def _acquire_sub_streams(self) -> None:
self._sub_streams_version += 1
for i, layer in enumerate(self._layers):
if not layer.get("enabled", True):
continue
src_id = layer.get("source_id", "")
if not src_id:
continue
consumer_id = f"{self._source_id}__layer_{i}"
consumer_id = f"{self._source_id}__{self._instance_id}__layer_{i}"
try:
stream = self._css_manager.acquire(src_id, consumer_id)
if hasattr(stream, "configure") and self._led_count > 0:
@@ -152,14 +193,33 @@ class CompositeColorStripStream(ColorStripStream):
logger.warning(
f"Composite layer {i} (source {src_id}) failed to acquire: {e}"
)
# Acquire brightness value stream if configured
vs_id = layer.get("brightness_source_id")
if vs_id and self._value_stream_manager:
try:
vs = self._value_stream_manager.acquire(vs_id)
self._brightness_streams[i] = (vs_id, vs)
except Exception as e:
logger.warning(
f"Composite layer {i} brightness source {vs_id} failed: {e}"
)
def _release_sub_streams(self) -> None:
self._sub_streams_version += 1
for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()):
try:
self._css_manager.release(src_id, consumer_id)
except Exception as e:
logger.warning(f"Composite layer release error ({src_id}): {e}")
self._sub_streams.clear()
# Release brightness value streams
if self._value_stream_manager:
for _idx, (vs_id, _vs) in list(self._brightness_streams.items()):
try:
self._value_stream_manager.release(vs_id)
except Exception as e:
logger.warning(f"Composite brightness release error ({vs_id}): {e}")
self._brightness_streams.clear()
# ── Scratch pool ────────────────────────────────────────────
@@ -254,16 +314,42 @@ class CompositeColorStripStream(ColorStripStream):
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_override(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
"""Override blend: per-pixel alpha derived from top brightness.
Black pixels are fully transparent (bottom shows through),
bright pixels fully opaque (top replaces bottom). Layer opacity
scales the per-pixel alpha.
"""
u16a, u16b = self._u16_a, self._u16_b
# Per-pixel brightness = max(R, G, B) for each LED
per_px_alpha = np.max(top, axis=1, keepdims=True).astype(np.uint16)
# Scale by layer opacity
per_px_alpha = (per_px_alpha * alpha) >> 8
# Lerp: out = (bottom * (256 - per_px_alpha) + top * per_px_alpha) >> 8
np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe")
u16a *= (256 - per_px_alpha)
u16b *= per_px_alpha
u16a += u16b
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
_BLEND_DISPATCH = {
_BLEND_NORMAL: "_blend_normal",
_BLEND_ADD: "_blend_add",
_BLEND_MULTIPLY: "_blend_multiply",
_BLEND_SCREEN: "_blend_screen",
_BLEND_OVERRIDE: "_blend_override",
}
# ── Processing loop ─────────────────────────────────────────
def _processing_loop(self) -> None:
# Per-layer CSPT filter cache: layer_index -> (template_id, [PostprocessingFilter, ...])
_layer_cspt_cache: Dict[int, tuple] = {}
try:
while self._running:
loop_start = time.perf_counter()
@@ -280,9 +366,13 @@ class CompositeColorStripStream(ColorStripStream):
result_buf = self._result_a if self._use_a else self._result_b
self._use_a = not self._use_a
has_result = False
layer_snapshots: List[np.ndarray] = []
with self._sub_lock:
sub_snapshot = dict(self._sub_streams)
if self._sub_streams_version != self._sub_snapshot_version:
self._sub_snapshot_cache = dict(self._sub_streams)
self._sub_snapshot_version = self._sub_streams_version
sub_snapshot = self._sub_snapshot_cache
for i, layer in enumerate(self._layers):
if not layer.get("enabled", True):
@@ -295,10 +385,52 @@ class CompositeColorStripStream(ColorStripStream):
if colors is None:
continue
# Apply per-layer CSPT filters
_layer_tmpl_id = layer.get("processing_template_id") or ""
if _layer_tmpl_id and self._cspt_store:
cached = _layer_cspt_cache.get(i)
if cached is None or cached[0] != _layer_tmpl_id:
# Resolve and cache filters for this layer
try:
from wled_controller.core.filters.registry import FilterRegistry
_resolved = self._cspt_store.resolve_filter_instances(
self._cspt_store.get_template(_layer_tmpl_id).filters
)
_filters = [
FilterRegistry.create_instance(fi.filter_id, fi.options)
for fi in _resolved
if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True)
]
_layer_cspt_cache[i] = (_layer_tmpl_id, _filters)
logger.info(
f"Composite layer {i} CSPT resolved {len(_filters)} filters "
f"from template {_layer_tmpl_id}"
)
except Exception as e:
logger.warning(f"Failed to resolve layer {i} CSPT {_layer_tmpl_id}: {e}")
_layer_cspt_cache[i] = (_layer_tmpl_id, [])
_layer_filters = _layer_cspt_cache[i][1]
if _layer_filters:
for _flt in _layer_filters:
_result = _flt.process_strip(colors)
if _result is not None:
colors = _result
# Resize to target LED count if needed
if len(colors) != target_n:
colors = self._resize_to_target(colors, target_n)
# Apply per-layer brightness from value source
if i in self._brightness_streams:
_vs_id, vs = self._brightness_streams[i]
bri = vs.get_value()
if bri < 1.0:
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
# Snapshot layer colors before blending (copy — may alias shared buf)
if self._need_layer_snapshots:
layer_snapshots.append(colors.copy())
opacity = layer.get("opacity", 1.0)
blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
alpha = int(opacity * 256)
@@ -310,16 +442,17 @@ class CompositeColorStripStream(ColorStripStream):
result_buf[:] = colors
else:
result_buf[:] = 0
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal"))
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
blend_fn(result_buf, colors, alpha, result_buf)
has_result = True
else:
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal"))
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
blend_fn(result_buf, colors, alpha, result_buf)
if has_result:
with self._colors_lock:
self._latest_colors = result_buf
self._latest_layer_colors = layer_snapshots if len(layer_snapshots) > 1 else None
except Exception as e:
logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True)

View File

@@ -0,0 +1,221 @@
"""Daylight cycle LED stream — simulates natural daylight color temperature.
Implements DaylightColorStripStream which produces a uniform LED color array
that transitions through dawn, daylight, sunset, and night over a continuous
24-hour cycle. Can use real wall-clock time or a configurable simulation speed.
"""
import math
import threading
import time
from typing import Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
# ── Daylight color table ────────────────────────────────────────────────
#
# Maps hour-of-day (024) to RGB color. Interpolated linearly between
# control points. Colors approximate natural daylight color temperature
# from warm sunrise tones through cool midday to warm sunset and dim night.
#
# Format: (hour, R, G, B)
_DAYLIGHT_CURVE = [
(0.0, 10, 10, 30), # midnight — deep blue
(4.0, 10, 10, 40), # pre-dawn — dark blue
(5.5, 40, 20, 60), # first light — purple hint
(6.0, 255, 100, 30), # sunrise — warm orange
(7.0, 255, 170, 80), # early morning — golden
(8.0, 255, 220, 160), # morning — warm white
(10.0, 255, 245, 230), # mid-morning — neutral warm
(12.0, 240, 248, 255), # noon — cool white / slight blue
(14.0, 255, 250, 240), # afternoon — neutral
(16.0, 255, 230, 180), # late afternoon — warm
(17.5, 255, 180, 100), # pre-sunset — golden
(18.5, 255, 100, 40), # sunset — deep orange
(19.0, 200, 60, 40), # late sunset — red
(19.5, 100, 30, 60), # dusk — purple
(20.0, 40, 20, 60), # twilight — dark purple
(21.0, 15, 15, 45), # night — dark blue
(24.0, 10, 10, 30), # midnight (wrap)
]
# Pre-build a (1440, 3) uint8 LUT — one entry per minute of the day
_daylight_lut: Optional[np.ndarray] = None
def _get_daylight_lut() -> np.ndarray:
global _daylight_lut
if _daylight_lut is not None:
return _daylight_lut
lut = np.zeros((1440, 3), dtype=np.uint8)
for minute in range(1440):
hour = minute / 60.0
# Find surrounding control points
prev = _DAYLIGHT_CURVE[0]
nxt = _DAYLIGHT_CURVE[-1]
for i in range(len(_DAYLIGHT_CURVE) - 1):
if _DAYLIGHT_CURVE[i][0] <= hour <= _DAYLIGHT_CURVE[i + 1][0]:
prev = _DAYLIGHT_CURVE[i]
nxt = _DAYLIGHT_CURVE[i + 1]
break
span = nxt[0] - prev[0]
t = (hour - prev[0]) / span if span > 0 else 0.0
# Smooth interpolation (smoothstep)
t = t * t * (3 - 2 * t)
for ch in range(3):
lut[minute, ch] = int(prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5)
_daylight_lut = lut
return lut
class DaylightColorStripStream(ColorStripStream):
"""Color strip stream simulating a 24-hour daylight cycle.
All LEDs display the same color at any moment. The color smoothly
transitions through a pre-defined daylight curve.
"""
def __init__(self, source):
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 10 # low FPS — transitions are slow
self._frame_time = 1.0 / 10
self._clock = None
self._led_count = 1
self._auto_size = True
self._lut = _get_daylight_lut()
self._update_from_source(source)
def _update_from_source(self, source) -> None:
self._speed = float(getattr(source, "speed", 1.0))
self._use_real_time = bool(getattr(source, "use_real_time", False))
self._latitude = float(getattr(source, "latitude", 50.0))
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 0 else 1
with self._colors_lock:
self._colors: Optional[np.ndarray] = None
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
if new_count != self._led_count:
self._led_count = new_count
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
return self._led_count
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(30, fps))
self._frame_time = 1.0 / self._fps
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name="css-daylight",
daemon=True,
)
self._thread.start()
logger.info(f"DaylightColorStripStream started (leds={self._led_count})")
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning("DaylightColorStripStream thread did not terminate within 5s")
self._thread = None
logger.info("DaylightColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import DaylightColorStripSource
if isinstance(source, DaylightColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
logger.info("DaylightColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
self._clock = clock
# ── Animation loop ──────────────────────────────────────────────
def _animate_loop(self) -> None:
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
try:
with high_resolution_timer():
while self._running:
wall_start = time.perf_counter()
frame_time = self._frame_time
try:
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
t = clock.get_time()
speed = clock.speed
else:
t = wall_start
speed = self._speed
n = self._led_count
if n != _pool_n:
_pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = np.empty((n, 3), dtype=np.uint8)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
if self._use_real_time:
# Use actual wall-clock time
import datetime
now = datetime.datetime.now()
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
else:
# Simulated cycle: speed=1.0 → full 24h in ~240s (4 min)
cycle_seconds = 240.0 / max(speed, 0.01)
phase = (t % cycle_seconds) / cycle_seconds # 0..1
minute_of_day = phase * 1440.0
idx = int(minute_of_day) % 1440
color = self._lut[idx]
buf[:] = color
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"DaylightColorStripStream animation error: {e}")
elapsed = time.perf_counter() - wall_start
time.sleep(max(frame_time - elapsed, 0.001))
except Exception as e:
logger.error(f"Fatal DaylightColorStripStream loop error: {e}", exc_info=True)
finally:
self._running = False

View File

@@ -444,21 +444,26 @@ class EffectColorStripStream(ColorStripStream):
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
np.copyto(buf[:, 2], self._s_f32_c, casting='unsafe')
# Bright white-ish head (2-3 LEDs — small, leave allocating)
head_mask = np.abs(indices - pos) < 1.5
head_brightness = np.clip(1.0 - np.abs(indices - pos), 0, 1)
buf[head_mask, 0] = np.clip(
buf[head_mask, 0].astype(np.int16) + (head_brightness[head_mask] * (255 - r)).astype(np.int16),
0, 255,
).astype(np.uint8)
buf[head_mask, 1] = np.clip(
buf[head_mask, 1].astype(np.int16) + (head_brightness[head_mask] * (255 - g)).astype(np.int16),
0, 255,
).astype(np.uint8)
buf[head_mask, 2] = np.clip(
buf[head_mask, 2].astype(np.int16) + (head_brightness[head_mask] * (255 - b)).astype(np.int16),
0, 255,
).astype(np.uint8)
# Bright white-ish head (2-3 LEDs)direct index range to avoid
# boolean mask allocations and fancy indexing temporaries.
head_lo = max(0, int(pos - 1.5) + 1)
head_hi = min(n, int(pos + 1.5) + 1)
if head_hi > head_lo:
head_sl = slice(head_lo, head_hi)
head_dist = self._s_f32_a[head_sl]
np.subtract(indices[head_sl], pos, out=head_dist)
np.abs(head_dist, out=head_dist)
# head_brightness = clip(1 - abs_dist, 0, 1)
head_br = self._s_f32_b[head_sl]
np.subtract(1.0, head_dist, out=head_br)
np.clip(head_br, 0, 1, out=head_br)
# Additive blend towards white using scratch _s_f32_c slice
tmp = self._s_f32_c[head_sl]
for ch_idx, ch_base in enumerate((r, g, b)):
np.multiply(head_br, 255 - ch_base, out=tmp)
tmp += buf[head_sl, ch_idx]
np.clip(tmp, 0, 255, out=tmp)
np.copyto(buf[head_sl, ch_idx], tmp, casting='unsafe')
# ── Plasma ───────────────────────────────────────────────────────

View File

@@ -22,6 +22,7 @@ from wled_controller.core.processing.live_stream import (
ScreenCaptureLiveStream,
StaticImageLiveStream,
)
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -178,6 +179,7 @@ class LiveStreamManager:
ProcessedPictureSource,
ScreenCapturePictureSource,
StaticImagePictureSource,
VideoCaptureSource,
)
stream_config = self._picture_source_store.get_stream(picture_source_id)
@@ -191,6 +193,9 @@ class LiveStreamManager:
elif isinstance(stream_config, StaticImagePictureSource):
return self._create_static_image_live_stream(stream_config), None
elif isinstance(stream_config, VideoCaptureSource):
return self._create_video_live_stream(stream_config), None
else:
raise ValueError(f"Unknown picture source type: {type(stream_config)}")
@@ -259,6 +264,31 @@ class LiveStreamManager:
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
return resolved
def _create_video_live_stream(self, config) -> VideoCaptureLiveStream:
"""Create a VideoCaptureLiveStream from a VideoCaptureSource config."""
stream = VideoCaptureLiveStream(
url=config.url,
loop=config.loop,
playback_speed=config.playback_speed,
start_time=config.start_time,
end_time=config.end_time,
resolution_limit=config.resolution_limit,
target_fps=config.target_fps,
)
# Attach sync clock if configured
if config.clock_id:
try:
from wled_controller.core.processing.processor_manager import ProcessorManager
manager = ProcessorManager.instance()
if manager and hasattr(manager, '_sync_clock_manager'):
clock = manager._sync_clock_manager.acquire(config.clock_id)
stream.set_clock(clock)
except Exception as e:
logger.warning(f"Could not attach clock {config.clock_id} to video stream: {e}")
return stream
def _create_static_image_live_stream(self, config) -> StaticImageLiveStream:
"""Create a StaticImageLiveStream from a StaticImagePictureSource config."""
image = self._load_static_image(config.image_source)
@@ -266,7 +296,12 @@ class LiveStreamManager:
@staticmethod
def _load_static_image(image_source: str) -> np.ndarray:
"""Load a static image from URL or file path, return as RGB numpy array."""
"""Load a static image from URL or file path, return as RGB numpy array.
Note: Uses synchronous httpx.get() for URLs, which blocks up to 15s.
This is acceptable because acquire() (the only caller chain) is always
invoked from background worker threads, never from the async event loop.
"""
from io import BytesIO
from pathlib import Path

View File

@@ -163,6 +163,9 @@ class MappedColorStripStream(ColorStripStream):
def _processing_loop(self) -> None:
frame_time = self._frame_time
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
try:
while self._running:
loop_start = time.perf_counter()
@@ -173,7 +176,14 @@ class MappedColorStripStream(ColorStripStream):
time.sleep(frame_time)
continue
result = np.zeros((target_n, 3), dtype=np.uint8)
if target_n != _pool_n:
_pool_n = target_n
_buf_a = np.zeros((target_n, 3), dtype=np.uint8)
_buf_b = np.zeros((target_n, 3), dtype=np.uint8)
result = _buf_a if _use_a else _buf_b
_use_a = not _use_a
result[:] = 0
with self._sub_lock:
sub_snapshot = dict(self._sub_streams)

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