Compare commits

...

44 Commits

Author SHA1 Message Date
alexei.dolgolyov 09792a9a05 chore: release v0.6.1
Build Release / create-release (push) Successful in 4s
Build Android APK / build-android (push) Failing after 9s
Build Release / build-linux (push) Successful in 2m13s
Build Release / build-docker (push) Successful in 3m9s
Build Release / build-windows (push) Successful in 4m6s
2026-05-10 23:57:47 +03:00
alexei.dolgolyov 75ca487be1 feat(ui): per-surface card presentation modes (C/M/D/R)
Adds a comfortable/compact/dense/row toggle to every card grid in the
app. Each surface (LED devices, targets, automations, scenes, sources,
streams, dashboard subsections, etc.) remembers its mode independently.

Persistence mirrors dashboard-layout: localStorage cache for first paint,
debounced PUT to /api/v1/preferences/card-modes (new endpoint) for
cross-browser sync. Surface registry is open — any non-empty key
accepted server-side; modes validated against {comfortable, compact,
dense, row}.

CSS is token-driven: grid min-width and gap come from --card-grid-min /
--card-grid-gap / --card-grid-min-narrow / --card-grid-gap-narrow /
--templates-grid-min / --templates-grid-gap defined on :root, overridden
per [data-card-mode]. Dense/row also hide .mod-leds, collapse secondary
button labels, and tighten .mod-metrics; row collapses the grid to one
full-width column. Coexists with the existing per-section [data-density]
on the dashboard tab — different attribute, additive concern.

Toggle UI auto-mounts into every CardSection header (18+ surfaces) plus
the six dashboard subsections via post-render mount; teardown tracking
keeps the listener Set bounded across re-renders.

i18n: card_mode.{tooltip,comfortable,compact,dense,row} in en/ru/zh.
Tests: 9 new cases in tests/test_preferences_card_modes_api.py covering
defaults, round-trip, validation, open-registry keys, row mode, delete.
2026-05-10 23:49:14 +03:00
alexei.dolgolyov e65dcb41f4 chore: clean up cfg abbreviation and stale TODO link
Rename `cfg` parameter/local in resolve_mqtt_password to `config`
for PEP 8 compliance. Drop the broken reference to the long-removed
docs/plans/device-typed-configs.md from TODO.md.
2026-05-10 23:19:15 +03:00
alexei.dolgolyov 6a07a6b1a2 fix(shutdown): apply target stop actions before tearing down HA/MQTT
Reorder the lifespan shutdown so processor_manager.stop_all() runs before
ha_manager.shutdown(), mqtt_manager.shutdown(), and mqtt_service.stop().
HA-light targets check `_ha_runtime.is_connected` before applying their
`stop_action` (turn_off / restore) and silently skip when HA is already
disconnected; MQTT-output devices need the broker connection alive to
send restore frames. The previous order tore those down first, turning
"stop_targets" into a no-op for those targets — most visible when
closing via the tray Shutdown button.

Also moves automation_engine.stop(), discovery_watcher.stop(), and the
OS notification listener stop ahead of processor stop so they can no
longer fire events into a shutting-down processor manager. Independent
services (weather, update checker, auto-backup) now run last, where
their order does not matter.

Bonus: if the daemon-thread join times out (10 s) and the rest of
shutdown is cut short, the user-visible part — targets stopping — has
already run.
2026-05-10 22:39:18 +03:00
alexei.dolgolyov 0f5850ef80 feat(ui): customisable card icon for all entity types
Extends the icon-plate work from devices and output targets to every
remaining card type — 18 new entities, 20 in total. Users can now pick
a curated icon (with optional colour override) for any card on any tab,
and the picker reuses the same modal, recent-strip, search, and
category tabs introduced for the device picker.

Foundation:
- icon-picker.ts — replace the hardcoded 2-entry adapter record with a
  Map<EntityType, EntityTypeAdapter> and expose
  registerIconEntityType() + makeSimpleIconAdapter() so each feature
  module owns its own adapter (~6 lines per type).
- bodyExtras hook on adapters, keyed off id, lets discriminated routes
  (output-targets target_type, picture-sources stream_type, audio /
  value / color-strip-sources source_type) accept icon-only PUTs.
- core/card-icon.ts — new makeCardIconFields(type, id, entity) helper
  spreads iconHtml / iconColor / iconAttrs into a mod-card head in one
  line.
- _onDocumentClick now accepts any registered type instead of a
  hardcoded device/target check.

Backend (purely additive — no migrations needed thanks to JSON-blob
storage):
- 18 dataclasses gained icon: str = "" + icon_color: str = "" with
  emit-when-truthy serialisation and "" defaults on load.
- All matching Create / Update / Response Pydantic schemas gained the
  fields with the standard Optional[str] + max_length=64/32 +
  description set.
- All routes' response builders use
  getattr(entity, "icon", "") or "" so existing rows render unchanged.
- ValueSource and CSS handle icon/icon_color on the base class so all
  source-type subclasses inherit them automatically.

Frontend wiring (12 modules):
- streams.ts — picture sources, capture templates, PP templates,
  CSPT, audio sources, audio templates, gradients (built-in
  gradients keep no plate).
- automations, scene-presets, sync-clocks, weather-sources,
  value-sources, mqtt-sources, home-assistant-sources,
  game-integration, audio-processing-templates, assets,
  color-strips/cards.
- pattern-templates skipped — uses the legacy wrapCard({content,
  actions}) string API, separate migration.

Dashboard cards now also display the chosen icon:
- Targets already had it (with device inheritance for LED targets).
- Sync clocks, automations, and scene presets gained the same plate
  via a shared _dashboardIconPlate helper that mirrors the mod-card
  layout (mod-head--with-icon class flips on when present).

i18n: 20 new device.icon.entity.<type> labels in en/ru/zh.

Verification:
- ruff check src/ tests/ — clean.
- npx tsc --noEmit — clean.
- npm run build — 2.6 MB bundle.
- pytest tests/ --no-cov — 949 passed (no regressions).

Pending: manual smoke test on each card type — open picker, save, and
confirm the channel-color preview matches the live card.
2026-05-09 16:19:20 +03:00
alexei.dolgolyov a79f4bf73c feat(ha-light): broadcast a single Color Value Source to all entities
HALightOutputTarget gains a `source_kind` field with two modes:
- `css` (existing): per-mapping LED segments averaged from a ColorStripSource.
- `color_vs` (new): one colour from a colour-returning ValueSource pushed to
  every mapped entity (mapping LED ranges are ignored in this mode).

Backend wiring:
- Schema/route: add `source_kind` + `color_value_source_id` to create/update/
  response payloads, with VS existence + return_type=color validation.
- Storage: persist new fields, with defensive `or ""` coalesce so legacy rows
  written via resolve_ref with None survive the str-typed response schema.
- Processor: ha_light_target_processor reworked to drive both source kinds
  (incl. update_target_settings hot-swap of source mode). New unit tests in
  tests/core/test_ha_light_target_processor.py and extended store tests.

Frontend:
- ha-light editor modal: collapsed Color Strip + Color VS into one
  "Color Source" picker with grouped headers; mappings list shows a
  mode-aware hint when broadcasting a single colour.
- EntityPalette: support non-selectable header rows (with keyboard / filter
  handling) for grouped source pickers.

Bundled UI polish (icon inheritance + cleanup):
- Custom card icons now flow into more surfaces: command palette, dashboard
  target cards, scene-preset target picker, calibration test-device picker,
  and the LED-target device picker. LED targets inherit their device's icon
  when none is set on the target itself.
- Empty mod-card icon plates render as a dashed "+" placeholder when an
  icon-picker hook is wired, so the action stays discoverable.
- Icon picker: distinct "HA light target" eyebrow label and supports
  HA-light cards (data-ha-target-id) for channel-colour resolution.
- Update banner: "View release" now opens the in-app Update settings tab
  instead of an external link; uses the sparkles icon.
- Color-strip delete: cleaner toast on 409 conflict.
2026-05-04 14:27:22 +03:00
alexei.dolgolyov ced72fc864 feat(targets): customisable card icon + HA-light stop action
Extends the icon-plate work from the device cards to LED and HA-light
output targets, and adds finalization behaviour for HA-light targets.

Targets:
- Add `icon` and `icon_color` fields to OutputTarget (LED + HA-light).
- LED target cards inherit the icon from their referenced device when
  no override is set; the icon picker shows an "inherited" indicator.
- Promote the device link from the meta line to a chip with the
  device's custom icon, leaving the head row free for the icon plate.

HA-light:
- New `stop_action` field with three modes: `none` / `turn_off` /
  `restore`. Processor snapshots mapped-entity states at start and
  applies the chosen action on stop (rgb / hs / color_temp / brightness
  restored where present).
- Editor modal exposes the choice via an IconSelect of three modes.

Adjacent fixes:
- Fader slider hit-zone now overlays the visible track exactly,
  regardless of label/value column widths.
- Dashboard customise drag-drop indicator splits into before/after
  rather than highlighting the whole row.
- Picture-source EntitySelect resyncs its visible value on load.
2026-05-04 00:43:55 +03:00
alexei.dolgolyov 49ddabbc36 feat(ui): customisable card icon plate for devices
A user-chosen icon ("mouse", "motherboard", "keyboard"…) renders as a
44x44 instrument-panel face plate at the leading edge of .mod-head on
device cards. Optional per-card; null hides the plate and reverts to
the existing badge-only head.

- Storage/schema: new icon, icon_color fields on Device + DeviceUpdate /
  DeviceResponse. SQLite stores entities as a JSON blob, so no migration
  is needed; from_dict defaults handle existing rows.
- Curated 47-icon library across six categories (Hardware / Lighting /
  Rooms / Media / Signal / Ambience), reusing the existing Lucide path
  module; adds circuit-board, bed, armchair, leaf paths.
- mod-card.ts: ModHeadOpts gains iconHtml / iconColor / iconAttrs;
  ModMenuItemOpts gains optional dataAttrs. The plate is rendered when
  iconHtml is supplied; otherwise no layout change.
- Picker modal (icon-picker.html + features/icon-picker.ts): live
  preview, search, six category tabs, recent strip, channel-color
  override toggle. Wired through document-level click delegation on
  [data-icon-picker-trigger="<deviceId>"] — no window globals, no
  inline onclick string. Sets the precedent for migrating other card
  actions off window in a follow-up.
- en/ru/zh locales for picker UI + categories.

Includes a docs/ mockup that's the source-of-truth for the design.
2026-05-03 15:08:17 +03:00
alexei.dolgolyov a026f0b349 ci(android): fail-fast on missing release keystore before SDK setup
Move the keystore guard from after the Decode step (step 9) to right
after Resolve build label (step 3). A release tag pushed without
ANDROID_KEYSTORE_BASE64 configured now fails in seconds instead of
after JDK + Python + Android SDK + NDK install (~3-5 min of wasted
runner time). Switched the condition from steps.keystore.outputs.present
to env.ANDROID_KEYSTORE_BASE64 since the env var is set at job level
and the keystore decode step has not yet run at the new position.
2026-05-01 19:18:46 +03:00
alexei.dolgolyov 5ef6ac1317 chore: release v0.6.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 3m52s
Build Release / build-linux (push) Successful in 5m20s
Build Release / build-docker (push) Successful in 6m20s
Build Release / build-windows (push) Successful in 7m7s
2026-05-01 19:11:15 +03:00
alexei.dolgolyov 0980cf4dde fix(ui): audio-source modal — preserve device on refresh, relocate refresh action
- Move the device refresh button into the label row next to "Audio Device:"
  so it can no longer overflow the Source panel edge; introduces a small
  .label-row-action style alongside .hint-toggle.
- Restore device selection after refresh by matching on (index, loopback)
  value first, with a trimmed name fallback for OS-side reindexing.
- _selectAudioDevice now syncs the EntitySelect trigger so the visible
  label matches the underlying <select> when the modal opens in edit mode.
- Drop unused min-width/overflow on .transport-status.
2026-05-01 19:04:36 +03:00
alexei.dolgolyov fdac26b9d9 feat: daylight tz, camera engine, value stream + modal/UI polish
- daylight: new daylight_settings module + daylight-tz frontend helper; expanded daylight_stream behavior
- camera engine: capture path additions plus new test_camera_engine suite
- value stream: schema + processing updates (~178 lines)
- color strip: drop cycle effect (cycle.py / color-cycle.ts removed), tighten static path
- modal CSS: large refactor (+883), components.css polish (+110)
- templates: settings, css-editor, value-source-editor, test-template, display-picker, image-lightbox
- frontend core: state, modal, icons, graph-nodes, app
- frontend features: displays, streams, streams-capture-templates, value-sources, settings, color-strips/cards
- locales: en/ru/zh
- storage: color_strip, picture_source, value_source loaders touched
- preferences/sync_clocks/picture_sources routes; home_assistant + templates schemas
2026-05-01 18:42:43 +03:00
alexei.dolgolyov 816a27db73 refactor(ui): drop app footer, move author info to About panel
The "Created by Alexei Dolgolyov..." line lived in a global app
footer that took up vertical space on every page. Move the author
+ contact details into the About tab of the global settings modal
(rendered by renderAboutPanel), where they sit next to the version
pill and license. Adds a localized "donation.about_author" key
(en/ru/zh) and matching .about-hero .about-author styles. Removes
the now-unused .app-footer / .footer-content rules.
2026-05-01 10:55:31 +03:00
alexei.dolgolyov 797b806972 feat: LED hot-path perf, tutorials expansion, modal markup polish
Performance (LED hot path, allocation-free per-frame):
- Adalight: dedicated single-worker tx executor (avoids asyncio.to_thread
  overhead), pre-allocated wire buffer + uint8 scratch, header struct
  precomputed. New tests cover header format, buffer reuse, non-contiguous
  input, and brightness scaling.
- DDP: pre-built struct.Struct for the 10-byte header, allocation-free
  send buffer + memoryview emit path. New tests cover RGB/RGBW packets,
  sequence/PUSH semantics, and multi-packet fragmentation.
- Calibration: precomputed Phase 3 skip-LED resampling (floor/ceil indices,
  fractional weights, take/blend scratch buffers) — per-frame work is now
  np.take + in-place blend, no allocations.
- WLED target processor: matching hot-path tightening.

Tutorials:
- Sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks
  so a tour can open/close the dashboard customize panel and resolve
  targets behind sub-tabs.
- New steps for integrations tab, dashboard customize panel (presets,
  global, sections, perf cells), targets, scenes, sync-clocks.
- en/ru/zh locales updated with the new tour strings.

Dashboard layout:
- Structural deep-equal so the "modified" indicator reflects truth after
  a user edits then reverts, instead of a stale flag.

UI polish:
- Mod-card / modal markup pass across ~33 modal templates and the
  tutorial overlay partial.
- appearance.css, modal.css, tutorials.css refresh.

Tooling:
- Add .mcp.json with code-review-graph MCP server config so the graph
  tools are available to the team out of the box.
2026-05-01 03:02:13 +03:00
alexei.dolgolyov 9d4a534ec6 feat(ui): release notes overlay v2 + settings/streams/dashboard polish
Release Notes overlay redesign (scoped via .release-notes-shell)
- Backend exposes release.assets (name/size/download_url) through
  UpdateReleaseInfo so the frontend can render real download links.
- New masthead: eyebrow + display-font title + tag/published/pre-release
  chip strip + close/external action buttons; opts out of layout.css's
  global `header { height: 60px }` and `header::before` accent bar that
  were leaking into the overlay's <header>.
- Markdown body: <code> filenames are wrapped in clickable <a> via fuzzy
  asset match (exact basename, then same-extension token-overlap), with
  per-asset description tooltip and a small download glyph.
- Per-asset description derived from filename pattern (Windows installer
  /portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android
  apk/aab, iOS ipa, generic archives) with i18n keys in en/ru/zh.
- Hide checksum / signature side-files (.sha256/.sha512/.sig/.asc/...).

Settings modal & dashboard polish
- ds-section refresh, rail-channel routing, notif matrix updates.
- Dashboard customize panel + per-account layout updates.
- New docs/settings-modal-redesign.html design reference.

Streams / targets / color-strip
- Stream cards rewrite (cards.css, streams.css, streams.ts).
- Composite stream + metrics history adjustments.
- WLED target processor + color-strip pipeline refinements.
- Color-strip WS source streamer touch-ups.

Misc
- Perf charts overhaul; tabular game-integration / HA / MQTT / weather
  source cards; donation/sync-clocks/scene-presets minor polish.
- New i18n keys across en/ru/zh.

Test infrastructure
- conftest pre-creates the test DB so main.py's legacy-data migration
  doesn't shovel the user's production DB into the test temp dir.
- test_preferences_notifications wipes its own setting at the start of
  the defaults test (was relying on isolation it never enforced).

Pre-commit gates: ruff clean, tsc clean, npm run build clean,
pytest 899/899 passing.
2026-04-29 17:14:05 +03:00
alexei.dolgolyov 51eebf21d5 feat(ui): redesign target pipeline as compact strip + chip row
Drops the legacy "Pipeline details" collapsible block on running
LED target cards. Instead:

- Always-visible 4px segmented timing bar (extract / map / smooth /
  send for video, read / fft / render / send for audio) — same
  stage colors as before, scaled by per-segment ms cost
- One chip row beneath it: total ms / frames count / keepalive
  count, using a new .chip--inline variant (display-weighted number
  + tiny mono-caps unit)
- _patchTargetMetrics now writes only the bar's segments and the
  data-tm spans — bar wrapper survives across polls so the
  flex-transition animates smoothly between samples
- _buildLedTimingHTML replaced by _buildLedTimingSegments (no more
  header / total / legend wrappers — those live in the chip row)

Cleanup
- Drop .target-metrics-collapse / -toggle / -animate / -expanded
  CSS — no callers remain
- Drop targets.metrics.pipeline from en/ru/zh locales — toggle
  label is gone
2026-04-27 01:52:24 +03:00
alexei.dolgolyov 9067db2639 feat(ui): align Targets metric cells with dashboard pattern
mod-card.ts
- ModMetricOpts.extra: raw HTML appended after the .v cell — used
  to embed a sparkline canvas inside the FPS metric block
- ModMetricOpts.valueDataAttrs: data-attrs on the .v element so
  live-update patchers can target the value directly

LED target card
- FPS sparkline (mod-metric-spark-canvas) is embedded INSIDE the
  FPS cell as a sibling of .v — was a separate target-fps-row
  block before, which floated under the metrics grid
- Label hardcoded to "FPS" (the i18n value "Target FPS:" was
  meant for the editor field, not the readout)
- Uptime cell gets ICON_CLOCK; Errors cell gets ICON_OK / ICON_WARNING
  based on count — matches dashboard cell decorations
- Drops the leading FPS icon (display-font number is the focal
  element; no icon needed)
- _patchTargetMetrics now emits the dashboard FPS shape:
  current<span.dashboard-fps-target>/target</span>
  <span.dashboard-fps-avg>avg N.N</span> — picks up dashboard.css
  styling for free

HA Light target card
- Same icon treatment (Uptime → clock; HA → ok/warning by
  ha_connected); FPS icon dropped

Grid sizing
- .devices-grid bumped from minmax(300px, 1fr) / gap 20px to
  minmax(min(380px, 100%), 1fr) / gap 14px — matches the
  dashboard's section grid so metric values like "1m 43s" stop
  truncating at the typical desktop width
2026-04-27 01:42:26 +03:00
alexei.dolgolyov 233b463ac3 feat(ui): migrate Targets cards to mod-card system
LED targets and HA Light targets adopt the dashboard's instrument-
readout vocabulary (mod-head, mod-leds, mod-metrics, mod-foot,
mod-patch, mod-btn, kebab menu) — same classes and tokens already
used by Dashboard and device cards.

mod-card.ts
- ModBtnOpts.dataAttrs for arbitrary data-attrs (used by the LED
  preview toggle's data-led-preview-btn binding)
- ModBodyOpts.extraHtml escape-hatch for live-update widgets that
  don't fit the predefined slots (FPS sparkline canvas, entity
  swatch grid, collapsible pipeline metrics)

LED target card (targets.ts)
- Badge "LED · TGT" pairs with device "WLED · OUT"-style badges
- Meta row: device link → protocol badge → fps → pixel count
- LED bezel: 1-3 dots reflecting checking / streaming / online /
  offline / unreachable
- Headline metrics on running cards (FPS / ERR / UPTIME) preserve
  data-tm selectors so _patchTargetMetrics still patches in place
- Chips for CSS source link, brightness/value-source, threshold
- Patch indicator: STREAMING / UNREACHABLE / STANDBY / OFFLINE /
  CHECKING
- Foot: START/STOP go/stop variant + LED preview + Edit
- Kebab menu: Duplicate / Hide / Delete (replaces top-right trash)
- FPS sparkline + collapsible pipeline preserved via extraHtml
- Tag chips and LED preview panel appended after wrap (mirrors
  devices.ts pattern)

HA Light target card (ha-light-targets.ts)
- Badge "HA · LIGHT"
- Meta: HA source link → light count → update rate
- LEDs: blink running, fault when ha_connected === false, off idle
- Running metrics: RATE / UPTIME / HA status
- Patch: STREAMING / DISCONNECTED / STANDBY / NOT CONFIGURED
- Buttons keep [data-action] for initHALightTargetDelegation
- Live entity color swatches preserved via extraHtml

Misc
- Chip border-radius dropped from 999px (pill) to var(--lux-r-sm,
  3px) — sharp corners match badges/metrics/buttons elsewhere
- _patchTargetMetrics FPS readout uses <small> for the target
  fraction instead of the legacy target-fps-target span
2026-04-27 01:33:13 +03:00
alexei.dolgolyov de13f44f24 feat(autostart): suppress browser auto-open on Windows login
When the user enables "Start with Windows" in the installer, the app
launches on every PC login. Previously each login popped a fresh WebUI
tab, which is noisy for a tray-resident background service.

The autostart shortcut now passes --autostart to start-hidden.vbs, which
sets LEDGRAB_AUTOSTART=1 in the child env. __main__ checks this flag
alongside LEDGRAB_RESTART when deciding whether to open the browser.

Manual launches (desktop/start-menu shortcuts) and the installer's
post-install "Launch LedGrab" finish-page action are unchanged — they
don't pass the arg, so they still open the WebUI tab.
2026-04-26 23:41:03 +03:00
alexei.dolgolyov 1c9acc5afb feat(api-input): make SegmentPayload start/length optional
start defaults to 0, length defaults to led_count - start (the rest of
the strip from start). A single segment with only mode + color now
fills the entire strip — no more length: 9999 magic value clients have
to pass.

Buffer auto-grow only fires for segments with an explicit length past
the current end; implicit "to the end" segments adapt to the current
strip size.
2026-04-26 23:34:42 +03:00
alexei.dolgolyov a56569b02f feat(ui): cards redesign + settings, modal, toolbar polish
Dashboard cards (mod-card system)
- New mod-card / mod-menu modules backing dashboard cards
- Reworked card colors, sections, dashboard layout, perf charts
- Channel-stripe styling, hairline borders, signal-flow animation
  on running cards, refined metric grid

Multiselect bulk toolbar
- Replaced tri-state checkbox with explicit Select-all / Deselect-all
  icon buttons; both disable when not applicable
- Dim + slight blur on non-selected siblings during selection mode so
  the active picks pop; selected card gains a subtle lift + primary-color
  glow halo
- Bulk tick uses ICON_CHECK from the icon registry (was U+2713) and
  scale-pops in via a cubic-bezier overshoot keyframe
- Toolbar restyled with luxury gradient bg, top accent stripe, glass
  blur, neon hover glows on each button group

Settings modal
- Tab bar converted to icon-only (cog / hard-drive / bell / palette /
  refresh / help) so labels never overflow at any locale; title and
  aria-label preserve translated names. Tabs distribute evenly via
  flex: 1 1 0 + space-around — no overflow possible
- IconSelect auto-populates <option> elements when the underlying select
  is empty, fixing the blank notification triggers (root cause: setting
  .value on an empty select is a no-op)
- Tab activation calls scrollIntoView on the active button as a safety
  net for narrow viewports

Modal exit animation
- Added symmetric fadeOut + slideDown keyframes; .modal.closing applies
  them with animation-fill-mode: forwards
- Modal.forceClose() defers display:none until animationend (with timer
  fallback). State cleanup (focus, body lock, stack) runs immediately so
  callers querying state get correct values
- isOpen returns false during the close animation; open() cancels any
  in-flight close so re-open works during the animation
- prefers-reduced-motion disables all modal animations

Locale picker
- Dropped redundant English/Русский/中文 long-form labels — picker now
  shows only EN / RU / ZH
- IconSelect trigger/cell hides empty icon/label slots via :empty so the
  layout collapses cleanly for minimal items

Filter input (cards section)
- Embedded magnifier icon via data URI (no HTML change); monospace
  uppercase placeholder, lux-bg-0 background, neon focus ring with inset
  shadow + outer glow
- Reset button only shows when the input has content (CSS-only via
  :placeholder-shown sibling selector — JS-resilient)

Snack toast
- Glass background (gradient + backdrop-blur) with top channel-color
  accent stripe matching the modal/toolbar language
- Per-type --toast-ch drives border/glow/timer color (success → primary,
  error → danger, info → info)
- Undo button gets a tinted hover with channel-color halo

Top header toolbar
- Removed hairline border from .header-btn for a flatter look; hover
  keeps the subtle background tint and primary-color glow

Device URL hyperlink
- Styled .mod-meta__link to pick up the card's --ch accent (instead of
  inheriting browser-blue underline). Dotted underline at rest solidifies
  on hover; soft text-shadow glow; web icon dims at rest, brightens on
  hover

Misc
- ICON_CHECK and ICON_HARD_DRIVE added to the icon registry
- Existing card-redesign demos checked in under docs/
- Removed obsolete docs/plans/device-typed-configs.md
2026-04-26 03:10:16 +03:00
alexei.dolgolyov ccf4406349 Merge branch 'feat/device-event-notifications'
Configurable device-event notifications: snackbar + Web Notifications
for online/offline (configured targets) and discovery (new WLED/serial
on the LAN/USB) events, with per-event channel matrix and background
discovery toggle.
2026-04-25 17:49:30 +03:00
alexei.dolgolyov 8aa3a323d6 feat(notifications): device event notifications (snack + Web Notifications)
Surface device connection state changes (configured target online/offline)
and discovery events (new WLED on LAN, new serial port, devices that
disappear) through a configurable per-event channel matrix:
none / snack / OS / both.

- Backend: long-running mDNS browser + 10 s serial poller in
  core/devices/discovery_watcher.py, gated by user pref. Reuses the
  existing device_health_changed event for online/offline transitions.
  New GET/PUT /api/v1/preferences/notifications endpoint with Pydantic v2
  schema (channel matrix + background-discovery flag + grace/debounce).
  13 new tests, full suite still 899 passing.
- Frontend: features/notifications-watcher.ts with startup-grace +
  flap-debounce + bulk-coalesce pipeline. Web Notifications API for the
  OS channel (no platform-specific code, works in PWA shell).
  New "Notifications" tab in Settings with 4 IconSelect rows + bg toggle
  + permission row + test button. en/ru/zh translations.

Defaults: device_offline=both (urgent), online/discovered=snack, lost=none,
background discovery on. Already-configured devices are filtered from
discovery events to avoid double-notifications.
2026-04-25 17:49:20 +03:00
alexei.dolgolyov 8e109f32b9 fix(pwa): add mobile-web-app-capable meta tag
Chrome deprecated apple-mobile-web-app-capable in favor of the
standard mobile-web-app-capable. Add the new tag while keeping the
Apple variant for iOS Safari compatibility.
2026-04-25 15:36:59 +03:00
alexei.dolgolyov 033c1f6a92 ci: add workflow_dispatch and skip lint/test on release commits
Release-bump commits don't change code that affects lint/tests, and
release.yml already runs in parallel. Manual dispatch lets us re-run
on demand if needed.
2026-04-25 15:36:51 +03:00
alexei.dolgolyov 0804f54537 chore: release v0.5.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 2m16s
Build Release / build-linux (push) Successful in 3m54s
Build Release / build-docker (push) Successful in 7m30s
Build Release / build-windows (push) Successful in 8m37s
Lint & Test / test (push) Successful in 8m45s
2026-04-25 15:21:02 +03:00
alexei.dolgolyov 66f921c07f Merge branch 'feat/lumenworks-ui-redesign'
Lint & Test / test (push) Successful in 2m36s
Lumenworks studio-console redesign + per-account dashboard customization
+ Inputs/Integrations/Graph treatment + transport-bar uptime + server
shutdown action.

Sub-features (in order on the branch):
- feat(ui): Lumenworks tokens, fonts, transport bar, channel-strip sidebar
- feat(ui): dashboard polish, perf strip, transport-bar controls
- feat(dashboard): per-account customizable dashboard with slide-in panel
- feat(ui): item-card restyle, perf hover tooltips, FPS ceiling
- feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs
- fix(ui): cards on pure black/white, decoupled from bg-anim
- fix(ui): single-row header + readable sidebar labels at narrow widths
- feat: server shutdown action with public cancel_task lifecycle method
- feat(ui): live card-color picker, monotonic uptime ticker, default
  preset uses base palette
- fix(ui): channel stripe paints only on custom-color or running cards
- chore: harden test isolation, gitignore stale src/data, mark TODO done

Pre-merge audit:
- 886/886 pytest passed twice in a row
- ruff + tsc clean
- frontend bundle rebuilt at static/dist
- python package reinstalled in editable mode (dev WebUI now reports
  0.4.2 instead of stale 0.3.0 dist-info)
2026-04-25 15:12:27 +03:00
alexei.dolgolyov 80f01d4813 chore: harden test isolation, gitignore stale src/data, mark shutdown action done
- ``tests/test_preferences_api.py`` no longer captures the auth API
  key at module-import time. The new ``client`` fixture resolves it
  inside its body and bakes the Bearer header into ``TestClient.headers``,
  so the e2e conftest swapping the global config singleton during
  collection cannot leave the test holding a stale 401-bound header.
  Same proven pattern as ``test_audio_processing_templates_api.py``.
- ``.gitignore`` now anchors ``/server/src/data/`` defensively. If the
  server is launched from ``server/src/`` (uncommon but possible during
  ad-hoc debugging), its relative ``data/`` resolves there. Templates
  now live in SQLite (``capture_templates`` / ``pattern_templates`` /
  ``postprocessing_templates`` tables); any stale ``*.json`` that
  lands in that directory is a runtime export and must not be
  committed.
- Three such stale exports were untracked at the start of the
  pre-merge audit and have been deleted from the working tree.
- ``TODO.md`` flips the shutdown-action checklist to done and notes
  that real-hardware verification (WLED + serial after Ctrl+C) is
  still pending.
2026-04-25 15:11:39 +03:00
alexei.dolgolyov b1ee3c3942 fix(ui): channel stripe paints only on custom-color or running cards
Reported during pre-merge review of the Lumenworks redesign:
non-dashboard cards (Inputs, Integrations, Targets) all showed
aggressive cyan / green left stripes "regardless of custom color set
or not", and even the ``+`` Add card carried a stripe.

Root cause: ``.template-card`` defaulted to ``--ch: cyan`` and
``::before`` painted it unconditionally; ``.add-template-card``
inherits ``.template-card`` so it picked the stripe up too.

Fix: gate the ``::before`` channel stripe behind opt-in selectors.
It now paints only when the card carries ``data-has-color="1"``
(the user picked a personal colour via the picker, set by ``wrapCard``)
or has the ``card-running`` class (the "patched and live" indicator).
Dashboard module rows are unchanged — their ``::before`` already
runs at ``opacity: 0.6`` and was approved as the visual benchmark.

``add-template-card::before`` is hidden unconditionally with
``!important`` since the Add card is not an entity and should never
carry a channel hue.
2026-04-25 15:11:24 +03:00
alexei.dolgolyov e0ff40f4f5 feat(ui): live card-color picker, monotonic uptime ticker tweaks, default preset uses base palette
Three adjacent UI fixes that surfaced while soaking the Lumenworks
redesign:

- ``card-colors.ts`` now writes the user's picked color through to
  *every* card representing the same entity (e.g. the targets-tab card
  AND its dashboard mirror), not just the one that owns the picker.
  Sets the ``--ch`` custom property on each match instead of a literal
  ``border-left``, which avoided the double-stripe (custom border +
  Lumenworks ``::before`` channel stripe) the old approach produced
  and reaches mirrors the picker callback's ``.closest()`` lookup
  couldn't.
- ``appearance.ts`` "default" preset now *clears* its colour overrides
  instead of stamping the historic muted greys (#1a1a1a / #2d2d2d /
  #f5f5f5 / #ffffff). With the redesign's pure-black / pure-white base
  palette in ``base.css``, "default" should mean "use the base" — the
  preset swatch in Appearance now matches what ships out of the box.
  Existing users with "default" selected will see a one-time visual
  shift to the new neutrals on next reload; this is intentional.
- ``dashboard.css`` mod-metric label row gets explicit sizing for the
  small status glyphs (clock / check / warning) so they sit beside the
  mono-caps label without competing with the big value. Errors cell
  picks up the coral channel tint when the count is non-zero.
2026-04-25 15:11:09 +03:00
alexei.dolgolyov 3f80ef2101 feat: server shutdown action with public cancel_task lifecycle method
Lets users choose what happens to LED targets when the server shuts
down. Default ("stop_targets") runs the existing per-device stop
sequence, so devices with auto-restore replay their prior state.
"Nothing" cancels the capture tasks without sending restore frames,
so the LEDs keep displaying their last frame on shutdown.

Backend:
- New setting ``shutdown_action`` persisted in db.settings
  (``stop_targets`` default | ``nothing``) with GET/PUT
  ``/api/v1/system/shutdown-action`` endpoints
- ``ProcessorManager.stop_all(restore_devices: bool = True)`` now
  picks the path based on the flag — ``proc.stop()`` for the normal
  branch, public ``proc.cancel_task()`` for the "nothing" branch.
- ``TargetProcessor.cancel_task()`` (new, on the abstract base) cancels
  the loop task and *awaits* its termination so no half-written frame
  is in flight when the process exits. Replaces an earlier draft that
  reached into the private ``_task`` attribute via ``getattr``.
- Lifespan in ``main.py`` reads the setting at shutdown and forwards
  the flag; falls back to ``stop_targets`` on any read error.
- ``/health`` exposes ``uptime_seconds`` (process-wide monotonic clock
  captured at first import of ``api.routes.system``) so the WebUI can
  show the *server's* uptime instead of the browser session's.

Browser launch:
- ``__main__._open_browser`` now polls ``/health`` for up to 30 s
  instead of sleeping a flat 2 s, so the tab opens once the server
  actually accepts requests.

Frontend:
- New "Shutdown action" picker in Settings → General, rendered via
  IconSelect with ICON_SQUARE / ICON_CIRCLE (added to ``core/icons.ts``
  + ``circle`` path to ``icon-paths.ts``).
- Transport-bar uptime ticker reads ``window.__serverUptime`` (typed
  in ``global.d.ts``); shows "—" until the first /health response
  lands so refresh doesn't briefly flash 00:00:00. After 99 h the
  format widens to "Dd HH:MM:SS".
- New i18n keys for the action picker (label, hint, opt.stop /
  opt.nothing + descriptions, saved / save_error toasts) in en/ru/zh.

No data migration needed — the setting is additive and defaults to
the existing behavior.
2026-04-25 15:10:48 +03:00
alexei.dolgolyov 2bae304107 fix(ui): single-row header + readable sidebar labels at narrow widths
At ≤1100px the header grid only declared 3 tracks for 4 children, so
the toolbar wrapped to a second row, doubling the header height. Add a
4th track, tighten the meta cluster, and hide non-essential toolbar
items (API link, tour-restart) so everything fits in one row. At
≤900px drop CPU/Mem cells (Uptime + Poll remain) so the toolbar still
fits beside the meta cluster.

Sidebar tab captions on the 56 px icon rail were ellipsis-truncated to
"DASHBO…" / "AUTOMA…" / "INTEGR…". Switch to a 2-line clamp with
tighter font/tracking so each label renders in full.
2026-04-25 13:54:18 +03:00
alexei.dolgolyov dd415e2813 fix(ui): cards on pure black/white, decoupled from bg-anim
Three related fixes after the Phase-4 migration landed:

- `--card-bg` flipped from `#101216` / `#f5f6f8` to pure `#000000` /
  `#ffffff` in base.css. Off-pure greys read as muddy when sitting on a
  pure-black/white page background; pure values keep card surfaces flush
  with the rest of the chrome and let the channel stripe + corner
  bracket carry all the visual differentiation.

- Removed the `[data-bg-anim="on"] .card { background: rgba(...) }`
  block that turned every entity card translucent whenever the WebGL
  background was enabled. Card backgrounds are now stable across the
  toggle — the shader bleeds through `body { background: transparent }`
  only, not through cards. The same card now reads identically with the
  shader on or off.

- WebGL shader base colour (`_bgColor` in bg-anim.ts and bg-shaders.ts)
  was using the legacy mid-grey `#1a1a1a` / `#f5f5f5`. That added a
  constant grey haze under the additive accent glow that didn't exist
  on the surrounding pure-black/white page. Switched to `[0,0,0]` /
  `[1,1,1]` so the shader composes against the same base as the page.

- Reverted two leftovers from the Phase-4 commit where I had migrated
  `.template-card` and `.graph-node-body` away from `var(--card-bg)`
  toward `var(--lux-bg-1, …)`. Those backgrounds now live on
  `var(--card-bg)` again, matching every other migrated card.
2026-04-25 02:42:57 +03:00
alexei.dolgolyov b43e1cf375 feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs
Brings the remaining tabs in line with the Channels-tab visual language:

- .template-card now mirrors .card and .dashboard-target — channel stripe
  on the left edge with glow, silkscreened corner bracket top-right,
  hairline border on --lux-bg-1, hover lift + stripe widen-and-glow.
  Covers streams, capture / pp / cspt / pattern / audio templates and
  every Integrations card (HA / MQTT / weather / value / sync clocks /
  game integrations).

- Channel mapping extended in cards.css. Direct attribute hooks for the
  per-domain ids; section-scoped hooks via [data-card-section="…"] for
  the cards that share a generic data-id (HA / MQTT / weather / value
  → cyan, game-integrations → amber, sync-clocks → violet,
  HA-light-targets → signal). No JS changes — uses the section markup
  CardSection.render already emits.

- Graph editor nodes pick up the studio-console palette: --lux-bg-1
  fill with hairline stroke, hover bold-line, selected/running stroke
  --ch-signal with drop-shadow glow. Title font moved off Big Shoulders
  Display (which read as "stretched" at 12 px) onto --font-body
  (Manrope); subtitle keeps the mono-uppercase caption treatment with a
  conservative letter-spacing. Running gradient now rides the channel
  palette (signal → cyan → signal) rather than the legacy primary /
  success colours. Port labels and grid dots adopt --lux-line tokens.

- Graph node titles get real text-overflow:ellipsis behaviour. SVG
  <text> can't do that natively, so renderNodes runs a post-mount fit
  pass that binary-searches the longest character prefix that fits
  inside the clip rect (with 2 px slack), suffixed with "…". Trailing
  whitespace is stripped before the ellipsis so we never get "Foo …".
  Full text is stashed on data-full-text so the fit can be re-run on
  re-renders.

Also bundles two perf-charts fixes from the same session:

- Hover regression — listener was bound to .perf-charts-grid, which
  rerenderPerfGrid() replaces. Moved to document.body with a guard, and
  the cursor → sample math now uses the same sliceN as the spark
  rendering so the tooltip stays accurate when the user changes the
  window setting.

- Color picker on every perf cell. Patches / Total FPS / Devices now
  expose the same color picker as the spark cells; defaults added to
  METRIC_CSS_VARS. Each card gets an inline --perf-accent on render so
  saved colours apply immediately, including across rerenderPerfGrid.
2026-04-25 02:27:38 +03:00
alexei.dolgolyov 56853b7123 feat(dashboard): per-account customizable dashboard with slide-in panel
Open-registry section/perf-cell schema persisted server-side under
db.get_setting('dashboard_layout'); localStorage cache for instant
first-paint, server sync after auth. 5 built-in presets
(Studio/Operator/Showrunner/Diagnostics/TV); JSON export/import.

Slide-in Customize panel toggles section + perf-cell visibility,
reorders via hand-rolled HTML5 drag (with up/down buttons for
keyboard/TV-remote use), changes density per section, and exposes
global Width / Animations / Perf-mode / Window with per-cell Inherit
overrides.

Window setting now drives the actual sparkline slice (30s/1m/2m/5m at
configurable poll interval) instead of always rendering 120 fixed
samples. Perf-grid edits re-render in place — sparklines repaint from
persistent module-level history, value labels replay from cached
last-fetch payload, so there is no flicker frame and no zero-data
window between layout change and next poll. initPerfCharts now fires
an immediate fetch on init so reload no longer shows "—" until the
first interval tick.

Reset confirmation uses the project's themed showConfirm modal
instead of the browser dialog. Reserved registry keys (audio-meters,
alerts, led-preview, source-thumbs, pinned, flow) are forward-
compatible so v1.1 cards slot in without a schema bump.

Backend exposes GET/PUT/DELETE /api/v1/preferences/dashboard-layout
treating the body as opaque JSON with a numeric version gate; covered
by 6 round-trip / validation / unknown-field tests.
2026-04-25 01:43:14 +03:00
alexei.dolgolyov 70c95d1c09 feat(ui): item-card restyle, perf hover tooltips, FPS ceiling
Item cards (Automations, Channels, Inputs, Integrations):
- `.card-title` — bumped to weight 700, -0.01em tracking, solid --lux-ink
  for better presence against the flat card bg.
- `.card-subtitle` / `.card-meta` — mono font, 0.04em tracking, tighter
  gap so rule chips pack in a readable row.
- `.stream-card-prop` rule chips — rectangular 2px radius + hairline
  border + flat dark bg (was rounded 10px grey pill). Channel-signal
  icon tint; hover fades in a channel-green wash with matching border.
- `.badge` generic — rectangular 2px radius, mono 0.62rem, 0.12em
  tracking, hairline border slot for variants.
  - `.badge-automation-active` — channel-signal tinted bg + border +
    soft outer glow so the "ACTIVE" state reads at a glance.
  - `.badge-automation-inactive` / `-disabled` — transparent with a
    hairline outline so they sit quietly alongside the active variant.
- `.device-url-badge` — switched from rounded pill to rectangular
  hairline mono chip; hover shifts to filled bg + bolder border +
  brighter ink.
- `.card-actions` — 1px hairline top divider, 6px gap.
- `.btn-icon` — 7/10px padding, 1rem icon, hairline border, channel-
  signal glow on hover (replaces the old scale(1.1) jiggle).
  - `.btn-icon.btn-warning` — amber ink + hairline + amber hover glow
    (drives the "disable" action in the automation card).
  - `.btn-icon.btn-success` — signal-green ink + hairline + green hover
    glow ("enable" action).

Cross-link navigation highlight:
- `cardHighlight` keyframes were using an undefined `--primary-rgb` var,
  so the outer glow fell back to 59/130/246 (the Tailwind blue default).
  Rewritten with `var(--ch-signal)` + color-mix so the highlight tracks
  the accent picker and reads as signal-green. Added double-layer
  box-shadow (ring + 32px/10px bloom) so the highlight is obvious on
  the flat dark/light card surfaces. Added .dashboard-target to the
  selector + `isolation: isolate` so the glow isn't clipped inside
  overflow: hidden containers (perf strip cells, tree-nav panels).

Perf strip (follow-up polish):
- Total FPS cell shows `/<N>` ceiling suffix next to the live value —
  sum of fps_target across running targets, styled like the Patches
  "/12". A dashed horizontal reference line at that ceiling is rendered
  on the sparkline so the live value reads as "percentage of max
  achievable throughput." Y-axis ceiling grows to targetSum * 1.1 so
  the dashed line never clips.
- Removed the empty `.perf-chart-app` pill in the FPS cell (no app
  variant). Added `:empty { display: none }` as a safety so any other
  unpopulated cell doesn't render a ghost pill.
- Hover tooltips on all sparks — single floating `.perf-chart-tooltip`
  in <body> with fixed positioning; event-delegated from the perf
  grid so re-renders don't need rebinding. Shows metric label + sys
  value + app value (in both-mode) + "−Ns ago" age line derived from
  the poll interval. Vertical marker line follows the cursor over the
  spark; `cursor: crosshair` on the spark container signals interact-
  ability. `pointer-events: none` shifted from the spark container
  down to the inner SVG so hover events land on the container.

Grid:
- Perf strip capped at 4 cols even on widescreen; wraps to 2 rows ×
  4 when the full 7 cells are present. Responsive breakpoints at
  1100 / 760 / 480 px.
- Big value font uses `clamp(1.8rem, 2.8vw, 2.8rem)` so readouts
  like "18.9/31.8 GB" fit a 1fr cell at desktop while still scaling
  down on narrow viewports. `white-space: nowrap; flex-wrap: nowrap;
  overflow: hidden; text-overflow: clip` prevents mid-text wrapping.
- `.perf-chart-spark` uses `margin-top: auto` so sparkline baselines
  align across cells regardless of whether a subtitle is present
  (CPU/GPU model name, FPS min/max).

Dashboard target meta:
- Integrations card stripe reverted to the default signal color so it
  matches the overall accent picker; the health-dot inside the card
  carries the connection state. Removed the per-integration channel
  override in both cards.css and dashboard.css.

Section headers:
- `.dashboard-section-header` / `.subtab-section-header` underline
  switched from dashed to solid; channel-green 40px accent rule on
  the left remains.
- Section count badge (`.dashboard-section-count`) restyled to match
  the rest of the badge family (mono tabular-nums, 2px radius, hairline
  border, --lux-bg-3 fill).

Build: tsc --noEmit clean; CSS bundle stable at ~216 KB.
2026-04-24 21:59:30 +03:00
alexei.dolgolyov e5a2af9821 feat(ui): dashboard polish, richer perf strip, transport-bar controls
Dashboard perf strip:
- Unified rack-module shell with hairline-divided cells (mockup parity)
  replacing 3 separate perf cards. Cells auto-wrap to 2 rows of 4 on
  widescreen; responsive breakpoints at 1100 / 760 / 480 px.
- Active Patches cell (first) shows running/total channel count plus up
  to 4 live FPS readouts with channel-colored stripes; bottom-right
  radial glow anchors the "live channel bank" corner.
- Total FPS cell — aggregate throughput across running targets, mono
  "fps" unit suffix, session-peak-scaled sparkline with a 60 FPS floor.
- Devices cell — online/total count + per-device dot strip (green when
  online with signal-glow, coral when offline, tooltip with name +
  latency), fed from /devices/batch/states (added to the dashboard
  batch poll).
- Value font uses clamp(1.8rem, 2.8vw, 2.8rem) + white-space: nowrap so
  long readouts (RAM "18.9/31.8 GB", GPU "50% · 37°C") scale down
  instead of wrapping.
- Sparklines anchor to the cell bottom via margin-top: auto so baselines
  align across cells regardless of subtitle presence.
- App-load tag ("APP 3.1%") moved to a pinned top-right position per
  card, accent-colored pill; replaces the subdued inline badge.
- Perf mode toggle (System / App / Both) triggers an immediate poll so
  positioning updates without waiting for the next tick.
- Chart.js removed from perf-charts — inline SVG sparklines with
  drop-shadow filter for the "lit instrument" feel. Chart.js still used
  for per-target FPS charts via chart-utils (now owns the registration).
- Fixed history seed bug: app_ram is MB in the server history payload,
  not percent — convert to percent using sample's ram_total before
  pushing into _appHistory.ram. Skip seeding app_gpu_mem since the
  history schema has no gpu_memory_total.
- Temperature card reveals with an explanatory hint when the backend
  reports cpu_temp_hint_key (e.g. Windows without LibreHardwareMonitor)
  instead of silently hiding; .perf-chart-card-hint neutralizes the big
  display font so the message reads as plain body copy.

Transport bar:
- LED brand mark — 28 px, double-layer signal glow (0 22px + 0 8px),
  brandPulse animation. Brand-stack wraps the title + version so
  "LED GRAB" sits above "V0.3.0" on a single line each.
- Transport status chip — bigger (9/18 padding), mono uppercase,
  inner+outer signal glow when .is-armed.
- Transport meta cells — Uptime (JS-local session ticker), CPU (app
  CPU share), Mem (app RAM, G/M format) as stacked KEY/VALUE mono
  readouts with hairline separators.
- New interactive Poll cell cycles through 1/2/5/10s presets on click;
  replaces the range slider that used to live in the Dashboard toolbar
  (it controlled the whole app, not just the Dashboard).
- Header icon buttons — hairline-bordered 30 px squares with channel-
  glow on hover, replacing the pill container.
- Perf poll moved to global bootstrap so transport CPU / Mem stay live
  across all tabs (was paused when leaving the Dashboard).
- Connection pip (#server-status) hidden; the brand mark itself turns
  coral when offline via :has() selector on .header-title.

Dashboard cards:
- renderDashboardTarget now emits full rack-module markup with CH badge,
  name, meta, LED cluster, 3-cell metric grid (FPS / Uptime / Errors),
  and patch-label + stop button. Running cards get the signal-flow
  strip at the bottom. data-fps-text / data-uptime-text / data-errors-
  text hooks preserved so _updateRunningMetrics updates in place.
- LED count surfaced in the target card meta line (e.g. "LED · WLED ·
  144 LED · GRADIENT") when the linked device reports led_count > 0.
- Integrations (HA + MQTT) picked up .mod-head markup — compact module
  layout with online/offline patch indicator. Integration card stripe
  uses the default signal color (not cyan or amber).
- Scene presets, sync clocks, automations gain the same compact module
  treatment. Automations/scenes dropped into a dashboard-autostart-grid
  so they share the visual language.
- Perf mode toggle, stream sub-tabs, cs-count / tree-count /
  tab-badge / dashboard-section-count badges all use the mono
  rectangular style with tabular-nums.

Command palette:
- Flat background (no gradient), channel-accent rule across the top,
  mono placeholder / group headers / footer, active result gets a
  channel-green left stripe.

Modals:
- Popover + backdrop get a stronger radial dim + 6 px blur.
- Per-modal-ID channel lanes (target→green, source→cyan, audio→magenta,
  automation/scene→violet, settings→amber, confirm→coral) via --modal-ch
  override.
- Modal header picks up a vertical channel stripe + hairline divider;
  footer gets hairline top + subtle wash.

Components:
- Inputs use hairline borders + tabular-nums mono for number fields;
  focus state has channel-green ring + soft glow.
- Buttons switch to mono-uppercase with signal-glow on primary,
  coral-glow on danger, hairline border on secondary.
- Card background flattened — removed gradient wash in favor of solid
  --lux-bg-1 for both dark (#0e1014) and light (#f6f8fb).
- Page background: pure black for dark, pure white for light.

Color-picker:
- Always detaches to <body> with fixed positioning when its swatch sits
  inside an overflow: hidden / auto / clip ancestor (perf strip, modal
  bodies, tree-dd panels). Prevents the popover getting clipped.

Settings modal:
- Remembers the last-opened tab via localStorage key
  settings_active_tab; falls back to 'general' if the tab id no longer
  exists. Explicit overrides (donation → about, update badge →
  updates) still work because callers invoke switchSettingsTab after
  openSettingsModal.

Microcopy:
- Sidebar / transport localization for en/ru/zh:
  sidebar.workspaces · transport.meta.{uptime,cpu,mem,poll,poll_hint}
  · transport.status.{ready,armed} · dashboard.perf.{active_patches,
  total_fps,devices}

Backend (coordinated with frontend):
- /system/performance now returns cpu_temp_hint_key when no live CPU
  temperature is available, so the Temperature card can render an
  actionable explainer instead of being hidden. Frontend respects the
  key via t() lookup.

Section headers:
- Underline switched from dashed to solid; channel-green accent rule
  (40 px) on the left remains.

Build / tests:
- ruff clean on touched Python files.
- tsc --noEmit clean.
- Python metrics-provider tests: 18 passed.
- CSS bundle ~214 KB.
2026-04-24 20:28:44 +03:00
alexei.dolgolyov 539e43195f feat(ui): Lumenworks studio-console WebUI redesign
Full-app UI/UX refresh committing to a tech-instrument / studio-console
aesthetic inspired by hardware synths, Eurorack panels, and DAW layouts.

Design tokens and fonts:
- Embed Manrope (body), JetBrains Mono (labels/metrics), Big Shoulders
  Display (numeric readouts) as local .woff2 variable fonts with
  latin + latin-ext + cyrillic + cyrillic-ext subsets via unicode-range.
- New Lumenworks token layer in base.css: --lux-bg-0..3, --lux-line(-bold),
  --lux-ink(-dim/-mute/-faint), --ch-signal/-cyan/-magenta/-amber/-coral/
  -violet channel palette, --lux-signal-glow, --lux-shadow-rack, all
  theme-aware for dark + light. Existing tokens untouched for compat.

Shell (header + sidebar):
- Header rebuilt as a 3-column CSS-grid transport bar (brand | center |
  toolbar) with a glowing LED brand mark rendered via pseudo-elements on
  .header-title. Gradient channel-color rule under the bottom border.
- New sidebar.css introduces a vertical channel-strip nav. Active tab
  gets a glowing left stripe + radial tint + LED pip. .sidebar-foot
  contains a live CPU/FPS meter plate.
- Sidebar collapses to a 56 px icon rail at <=1100 px and hides via
  display:contents at <=600 px so mobile.css's fixed bottom tab-bar
  flows through unchanged.

Cards and dashboard:
- .card gets channel stripe (data-card-type + .ch-* utilities auto-map
  from data-target-id / data-stream-id / data-automation-id etc.), corner
  bracket, gradient background, subtle rack shadow.
- .card-running replaces the old @property --border-angle conic-gradient
  rotating border with a lightweight signalFlow linear-gradient strip on
  the bottom edge (cheaper paint, no GPU layer compositing per card).
- Skeleton loaders rewritten: left hairline + corner bracket + gradient
  shimmer instead of the old text-color opacity pulse.
- .dashboard-target rows pick up the same channel-stripe + signalFlow
  treatment. Section headers use mono micro-caps with a channel-green
  underline accent consistent across the app.
- .perf-chart-card: channel stripe replaces old border-top; per-metric
  accents moved to the channel palette (CPU=coral, RAM=violet, GPU=green,
  temp=amber). Metric values use tabular-nums + a soft glow.

Live bindings (no new endpoints):
- _updateSidebarMeter: binds the sidebar Load + FPS bars to the existing
  /system/performance poll.
- _updateTransportStatus: toggles the transport chip between "Ready" and
  "Armed - N live" whenever the dashboard's running-target set is
  recomputed.

Tree-nav + sub-tabs:
- tree-nav.css trigger pill gets a channel-stripe left edge that glows
  when open; panel has a gradient channel-accent rule across the top;
  group headers use silkscreened micro-caps; active leaf has a pulsing
  LED pip + channel tint.
- .stream-tab-btn / .subtab-section-header adopt the same mono-caps +
  channel-underline language for consistency.
- Graph editor toolbar gets gradient + hairline + rack shadow + backdrop
  blur. Canvas and nodes untouched.

Modals (40+ modals share modal.css):
- Radial-dim + 6 px blur backdrop. Content gets a gradient background,
  hairline border, deep rack shadow, top channel-accent rule driven by
  --modal-ch, bottom-right corner bracket (hidden on mobile fullscreen).
- Per-modal-ID channel lanes: target editors = green, source/input
  editors = cyan, audio = magenta, automation/scene/game = violet,
  settings/auth = amber, confirm = coral.
- Modal headers: vertical channel stripe left of the title + hairline
  divider. Modal footers: hairline top border + subtle gradient wash.

Forms:
- Inputs use hairline borders; number inputs switch to mono + tabular-nums
  for column alignment. Focus state: channel-green ring + soft glow.
- Buttons use mono-uppercase type with signal-glow on primary and coral-
  glow on danger.

Mobile (<=600 px):
- Fixed bottom .tab-bar gets the full Lumenworks treatment: gradient fill,
  top channel-accent rule matching the transport bar, backdrop blur.
  Active tab has an LED pip above the icon + channel tint + icon recolor.
- Fullscreen modals: corner bracket hidden, header stripe slimmed.

Microcopy (en / ru / zh):
- "Targets" -> "Channels" / "Каналы" / "通道"
- "Sources" -> "Inputs"    / "Входы"   / "输入"
- Internal tab keys (dashboard/automations/targets/streams/integrations/
  graph) kept stable so no JS or localStorage migration is needed.
- Added: sidebar.workspaces, sidebar.load, sidebar.fps,
  transport.status.ready, transport.status.armed.

Compatibility:
- All existing class hooks preserved (.tab-bar, .tab-btn, .card,
  .card-running, .tree-dd-*, .cs-*, .perf-chart-card, .modal-content,
  .dashboard-target, etc.). No JS or API changes required for the new
  look to take effect.
- Tour selectors survive (header .header-title, #tab-btn-*, onclick
  markers on theme/settings/search, #cp-wrap-accent, etc.).
- Mobile <=600 px bottom tab-bar keeps working via display:contents
  fall-through in the new sidebar.

Build: tsc --noEmit clean; npm run build clean. CSS bundle grew from
~177 KB to ~201 KB for the full new visual system. Fonts loaded lazily
per unicode-range subset (~98 KB critical path for English).

Phased plan + deferred follow-ups (dashboard hero strip, legacy-token
cleanup) recorded at the top of TODO.md.

Reference mockup: server/docs/ui-redesign-mockup.html.
2026-04-24 15:46:47 +03:00
alexei.dolgolyov c44bb38c43 docs(release): refresh v0.4.2 notes with fix(release) and refactor commits
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 2m5s
Build Release / build-linux (push) Successful in 4m53s
Build Release / build-docker (push) Successful in 5m39s
Build Release / build-windows (push) Successful in 6m55s
Lint & Test / test (push) Successful in 7m13s
2026-04-22 20:20:30 +03:00
alexei.dolgolyov be2d5e1670 refactor(color-strips): move Key Colors test from lightbox into test-css-source modal
Lint & Test / test (push) Successful in 6m37s
Removes the inlined FPS select and auto-refresh button from the shared
image lightbox and rehosts the Key Colors live preview inside the
dedicated test-css-source modal alongside the other CSS test views.

- Drop initLightbox() / lightbox-fps-select IconSelect — the lightbox no
  longer owns streaming controls.
- Add #css-test-kc-view (canvas + meta) and .css-test-kc-* styles.
- Reroute _testKeyColorsSource() through the existing modal session
  lifecycle so KC, CSPT, and standard CSS tests share teardown paths.
2026-04-22 20:18:46 +03:00
alexei.dolgolyov 5db6eddcf8 fix(release): ship prebuilt assets and bump fallback version
Two release-blocking bugs traced to the same root cause: the unanchored
`data/` rule in .gitignore matched server/src/ledgrab/data/, which is
where shipped package assets live (prebuilt sounds, game adapters).
The files were never `git add`-able without -f, so they never reached
the v0.4.2 tag and CI builds couldn't include them.

- .gitignore: anchor /data/ and /server/data/ so nested package data
  dirs are not ignored.
- Track previously-excluded shipped assets:
  - server/src/ledgrab/data/prebuilt_sounds/{alert,bell,chime,ping,pop}.wav
  - server/src/ledgrab/data/game_adapters/{minecraft,rocket_league,valorant}.yaml
- Bump _FALLBACK_VERSION 0.3.0 -> 0.4.2 to match pyproject.toml.
  The Windows installer strips ledgrab-*.dist-info, so
  importlib.metadata falls back to this literal — which is why
  v0.4.2 reports v0.3.0 in the WebUI.
- Patch _FALLBACK_VERSION at bundle time in build-common.sh and
  build-dist.ps1 so future drift is auto-corrected by the build.
2026-04-22 20:17:10 +03:00
alexei.dolgolyov a8a4296a56 chore: release v0.4.2
Build Android APK / build-android (push) Failing after 1m48s
Build Release / create-release (push) Successful in 3s
Build Release / build-linux (push) Successful in 3m58s
Build Release / build-docker (push) Successful in 5m6s
Build Release / build-windows (push) Successful in 5m54s
Lint & Test / test (push) Successful in 6m14s
2026-04-22 19:48:37 +03:00
alexei.dolgolyov 9ce1dc33bf feat(ui): restyle enhanced header locale picker as LED-accent badge 2026-04-22 19:48:08 +03:00
alexei.dolgolyov 03d2e6b1f2 ci(release): publish .sha256 sidecars alongside release assets
Lint & Test / test (push) Successful in 2m4s
The in-app update service (`ledgrab.core.update.update_service`) refuses
to install any downloaded artifact that has no published sha256 — either
as a sibling `<asset>.sha256` asset on the Gitea release, or embedded in
the release body. The release workflow uploaded the ZIP, setup.exe, and
Linux tarball but never published checksums, so every auto-update 500'd
with "Update checksum unavailable; install aborted".

Generate sha256sum sidecars for the Windows ZIP, Windows setup.exe, and
Linux tar.gz and upload them next to the primary asset on each tagged
release. Existing v0.4.x releases stay broken — ship v0.4.2 (or manually
upload sidecars to v0.4.1) to unblock in-app updates.
2026-04-22 19:40:46 +03:00
303 changed files with 38692 additions and 6344 deletions
+11 -9
View File
@@ -54,6 +54,17 @@ jobs:
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT" echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
echo "Build label: $LABEL (release=$IS_RELEASE)" echo "Build label: $LABEL (release=$IS_RELEASE)"
- name: Guard release tag against missing keystore
# Release tags MUST produce a release-signed APK, otherwise existing
# installs can't upgrade (signature mismatch). Fail loudly instead
# of silently falling back to the debug signing config.
# Runs before JDK/Python/SDK/NDK setup so a misconfigured release
# tag fails in seconds instead of after several minutes of setup.
if: ${{ steps.label.outputs.is_release == 'true' && env.ANDROID_KEYSTORE_BASE64 == '' }}
run: |
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
exit 1
- name: Setup JDK ${{ env.JAVA_VERSION }} - name: Setup JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
@@ -122,15 +133,6 @@ jobs:
echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT" echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT"
echo "present=true" >> "$GITHUB_OUTPUT" echo "present=true" >> "$GITHUB_OUTPUT"
- name: Guard release tag against missing keystore
# Release tags MUST produce a release-signed APK, otherwise existing
# installs can't upgrade (signature mismatch). Fail loudly instead
# of silently falling back to the debug signing config.
if: ${{ steps.label.outputs.is_release == 'true' && steps.keystore.outputs.present != 'true' }}
run: |
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
exit 1
- name: Build APK - name: Build APK
working-directory: android working-directory: android
env: env:
+37 -19
View File
@@ -191,11 +191,21 @@ jobs:
echo "Uploaded: $NAME" echo "Uploaded: $NAME"
} }
# Publish an asset plus its .sha256 sidecar. The in-app update
# service refuses to install without a published checksum, so
# every artifact needs its hash uploaded alongside.
upload_with_sha256() {
local FILE="$1"
upload_asset "$FILE"
(cd "$(dirname "$FILE")" && sha256sum "$(basename "$FILE")" > "$(basename "$FILE").sha256")
upload_asset "$FILE.sha256"
}
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1) ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
[ -f "$ZIP_FILE" ] && upload_asset "$ZIP_FILE" [ -f "$ZIP_FILE" ] && upload_with_sha256 "$ZIP_FILE"
SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1) SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1)
[ -f "$SETUP_FILE" ] && upload_asset "$SETUP_FILE" [ -f "$SETUP_FILE" ] && upload_with_sha256 "$SETUP_FILE"
# ── Linux tarball ────────────────────────────────────────── # ── Linux tarball ──────────────────────────────────────────
build-linux: build-linux:
@@ -242,26 +252,34 @@ jobs:
run: | run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}" RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
upload_asset() {
local FILE="$1"
local NAME
NAME=$(basename "$FILE")
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $NAME"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
echo "Uploaded: $NAME"
}
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1) TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
TAR_NAME=$(basename "$TAR_FILE") if [ -f "$TAR_FILE" ]; then
upload_asset "$TAR_FILE"
# Delete existing asset with same name to prevent duplicates on re-run (cd "$(dirname "$TAR_FILE")" && sha256sum "$(basename "$TAR_FILE")" > "$(basename "$TAR_FILE").sha256")
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \ upload_asset "$TAR_FILE.sha256"
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$TAR_NAME'),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $TAR_NAME"
fi fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$TAR_NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$TAR_FILE"
echo "Uploaded: $TAR_NAME"
# ── Docker image ─────────────────────────────────────────── # ── Docker image ───────────────────────────────────────────
build-docker: build-docker:
needs: create-release needs: create-release
+6
View File
@@ -5,9 +5,15 @@ on:
branches: [master] branches: [master]
pull_request: pull_request:
branches: [master] branches: [master]
# Allow manual runs (e.g. to validate after a release commit was skipped).
workflow_dispatch:
jobs: jobs:
test: test:
# Skip release-publishing commits — version bumps don't affect lint/tests
# and the release.yml pipeline is already running. PRs and manual dispatch
# always run.
if: ${{ github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore: release') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
+13 -2
View File
@@ -62,8 +62,17 @@ htmlcov/
logs/ logs/
*.log.* *.log.*
# Runtime data # Runtime data — anchor to repo root so nested package data dirs
data/ # (server/src/ledgrab/data/prebuilt_sounds, game_adapters) are NOT ignored.
# An unanchored `data/` rule silently broke the v0.4.2 release by keeping
# shipped sound assets out of the CI tag checkout.
/data/
/server/data/
# Defensive: if the server is launched from server/src/ (uncommon path),
# its relative `data/` dir resolves to server/src/data/. Templates now
# live in SQLite, so any *.json that lands here is stale runtime export
# and must not be committed.
/server/src/data/
*.db *.db
*.sqlite *.sqlite
*.json.bak *.json.bak
@@ -86,3 +95,5 @@ tmp/
# OS # OS
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# Added by code-review-graph
.code-review-graph/
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"type": "stdio"
}
}
}
+39
View File
@@ -104,3 +104,42 @@ Do NOT commit code that fails linting or tests. Fix the issues first.
- Follow existing code style and patterns - Follow existing code style and patterns
- Update documentation when changing behavior - Update documentation when changing behavior
- Never make commits or pushes without explicit user approval - Never make commits or pushes without explicit user approval
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
+24 -10
View File
@@ -1,18 +1,28 @@
## v0.4.1 (2026-04-22) ## v0.6.1 (2026-05-10)
### Features
- Per-surface card presentation modes (C/M/D/R) for the UI ([75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487))
- Customisable card icon for all entity types ([0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e))
- HA-Light: broadcast a single Color Value Source to all entities ([a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf))
- Targets: customisable card icon plus HA-light stop action ([ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc))
- Customisable card icon plate for devices ([49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb))
### Bug Fixes ### Bug Fixes
- Installer now bundles `cryptography` and `just-playback`, sets the `TCL` environment for Tk, and removes the stale `debug.bat` shim ([4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c))
- Shutdown: apply target stop actions before tearing down HA/MQTT so devices end up in their configured state ([6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b))
--- ---
### Development / Internal ### Development / Internal
#### CI/Build #### CI/Build
- Scope the Android keystore env correctly and fail loudly when a release build is attempted without a signing key ([35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2))
#### Documentation - Android: fail-fast on missing release keystore before SDK setup ([a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b))
- Drop the stale WLED-rename task and document the Android signing secrets ([a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3))
- Remove WLED-specific language from the auto-generated release notes template ([4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d)) #### Chores
- Clean up `cfg` abbreviation and stale TODO link ([e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4))
--- ---
@@ -21,9 +31,13 @@
| Hash | Message | Author | | Hash | Message | Author |
|------|---------|--------| |------|---------|--------|
| [4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c) | fix(installer): bundle cryptography + just-playback, set TCL env, clean stale debug.bat | alexei.dolgolyov | | [75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487) | feat(ui): per-surface card presentation modes (C/M/D/R) | alexei.dolgolyov |
| [a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3) | docs(release): drop stale WLED-rename task, document android signing secrets | alexei.dolgolyov | | [e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4) | chore: clean up cfg abbreviation and stale TODO link | alexei.dolgolyov |
| [35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2) | ci(android): fix keystore env scoping, fail loudly on release without key | alexei.dolgolyov | | [6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b) | fix(shutdown): apply target stop actions before tearing down HA/MQTT | alexei.dolgolyov |
| [4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d) | docs(release): drop WLED-specific language from auto-generated release notes | alexei.dolgolyov | | [0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e) | feat(ui): customisable card icon for all entity types | alexei.dolgolyov |
| [a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf) | feat(ha-light): broadcast a single Color Value Source to all entities | alexei.dolgolyov |
| [ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc) | feat(targets): customisable card icon + HA-light stop action | alexei.dolgolyov |
| [49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb) | feat(ui): customisable card icon plate for devices | alexei.dolgolyov |
| [a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b) | ci(android): fail-fast on missing release keystore before SDK setup | alexei.dolgolyov |
</details> </details>
+405 -1
View File
@@ -1,5 +1,409 @@
# LedGrab TODO # LedGrab TODO
## Custom card icons — extend to all card types
Migrate the existing icon-plate work (devices, LED targets, HA-light targets)
to all remaining card types. ~17 entity types. Branch: `feat/icons-everywhere`.
### Foundation
- [x] Refactor `icon-picker.ts` — replace hardcoded 2-entry `_adapters`
record with a `Map<EntityType, EntityTypeAdapter>` and expose
`registerIconEntityType()` for feature modules to register their
own. Added `makeSimpleIconAdapter()` helper that reduces a
registration to ~6 lines.
- [x] Generalised `bodyExtras` for discriminated routes (output-targets
`target_type` etc.) — now keyed off id, adapter does its own
lookup.
- [x] `_onDocumentClick` accepts any registered type instead of
hardcoded device/target check.
- [x] Locale entity-type labels added to en/ru/zh for 18 new types
(picture_source, audio_source, weather_source, value_source,
mqtt_source, ha_source, automation, scene_preset, sync_clock,
game_integration, audio_processing_template, pattern_template,
capture_template, pp_template, cspt, audio_template, gradient,
color_strip_source, asset).
### Backend (storage + schemas + routes per entity)
Recipe: add `icon: str = ""` + `icon_color: str = ""` to dataclass,
emit-when-truthy in `to_dict`, default `""` in `from_dict`; add 3
`Optional[str]` Field defs to Create/Response/Update schemas; thread
`getattr(entity, "icon", "") or ""` into the response builder.
SQLite JSON-blob storage means **no migration required**.
- [x] Integrations (6): weather_sources, value_sources, mqtt_source,
home_assistant_source, sync_clocks, game_integration
- [x] Streams (10): picture_source, audio_source, audio_template,
audio_processing_template, pattern_template, postprocessing_template,
color_strip_processing_template, color_strip_source, gradient,
capture_template (`storage/template.py` — was missed by initial pass)
- [x] Other (3): automation, scene_preset, asset
### Frontend (per feature module)
For each card render call:
- Use the new `core/card-icon.ts` helper:
`...makeCardIconFields('<type>', entity.id, entity)` spread into the
mod-card head — computes `iconHtml`/`iconColor`/`iconAttrs` in one go.
- Register the entity type in the feature module via
`registerIconEntityType('<type>', makeSimpleIconAdapter({ … }))`.
Modules wired:
- [x] streams.ts (7 cards: picture, capture, pp, cspt, audio source,
audio template, gradient — built-in gradients skip the plate)
- [x] automations.ts
- [x] scene-presets.ts
- [x] sync-clocks.ts
- [x] weather-sources.ts
- [x] value-sources.ts (bodyExtras propagates `source_type`)
- [x] mqtt-sources.ts
- [x] home-assistant-sources.ts
- [x] game-integration.ts
- [x] audio-processing-templates.ts
- [x] assets.ts
- [x] color-strips/cards.ts (bodyExtras propagates `source_type`)
- [WONTDO] pattern-templates.ts — uses legacy `wrapCard({content, actions})`
string API, not the mod-card system. Migration would be a separate
effort and the cards are tiny (name + rect count) so the value is low.
### Discriminated routes
Adapters provide `bodyExtras` to inject the discriminator field on PUT
so the Pydantic discriminated-union route validators don't reject the
icon-only update:
- output-targets → `target_type` (already wired before)
- color-strip-sources → `source_type`
- audio-sources → `source_type`
- value-sources → `source_type`
- picture-sources → `stream_type`
### Verification
- [x] `cd server && ruff check src/ tests/` clean
- [x] `cd server && npx tsc --noEmit` clean
- [x] `cd server && npm run build` produces 2.6 MB bundle
- [x] `cd server && py -3.13 -m pytest tests/ --no-cov -q` — 949 passed
- [ ] Manual: open picker on each card type, confirm save persists,
confirm channel-color preview matches the live card
## Device Event Notifications
Notify the user when LED devices come online/go offline (configured targets), and when new
WLED/serial devices are discovered or disappear from the LAN/USB. Each event class has a
configurable channel: `none` | `snack` | `os` | `both`. OS channel uses Web Notifications
(works in any browser tab and in the PWA shell — no platform-specific Python).
Branch: `feat/device-event-notifications`. Default ON.
### Backend
- [x] `core/devices/discovery_watcher.py` — long-running mDNS browser
(`AsyncServiceBrowser` kept alive for the process lifetime) + 10 s serial-port
poller. Fires `device_discovered`/`device_lost` via `processor_manager.fire_event`,
suppresses events for URLs already in `device_store`. Seeded ports do NOT generate
startup-time toasts.
- [x] Wired into `lifespan` (`main.py`). Gated by `notification_preferences.
background_discovery_enabled`. Default True. Stops before health monitor stop.
- [x] `api/schemas/preferences.py` — `NotificationPreferences` Pydantic v2 model with
the 4-event channel matrix, `background_discovery_enabled`, `startup_grace_sec`
(0..300), `flap_debounce_sec` (0..60).
- [x] `api/routes/preferences.py` — `GET/PUT /api/v1/preferences/notifications`,
persisted under `db.set_setting("notification_preferences", …)`. Corrupt stored
values fall back to defaults instead of 500.
- [x] Reuses existing `device_health_changed` event from `device_health.py` (already
fires online/offline transitions on the same event bus).
- [x] Tests: 7 in `tests/test_preferences_notifications_api.py`, 6 in
`tests/test_discovery_watcher.py`. Full pytest suite still 899 passing.
### Frontend
- [x] `js/features/notifications-watcher.ts` — listens to the three `server:*` DOM
events. Applies user prefs. Pipeline: startup grace → flap debounce → bulk
coalesce (≥3 events / 800 ms collapse to one summary).
- [x] Web Notification permission requested from the Settings → Notifications panel
via a user-gesture button. State chip reflects granted/denied/default.
- [x] Settings panel — new "Notifications" subtab between Backup and Appearance.
4 IconSelects (`none`/`snack`/`os`/`both`) + background-discovery toggle +
permission row + Test-notification button.
- [x] i18n: `settings.notifications.*` and `notifications.*` keys in en/ru/zh.
### Verification
- [x] `npx tsc --noEmit` clean, `npm run build` produces 2.5 MB bundle.
- [x] `ruff check src/ tests/` clean. 899/899 pytest pass.
- [x] App import smoke-test (`from ledgrab.main import app`) loads 233 routes
without errors.
- [ ] Real-hardware test pending — verify on user's network:
(1) plug a fresh WLED in → snack toast appears, (2) configure it → next
offline transition fires both snack + OS toast, (3) Background-discovery
toggle off → no more discovered/lost events.
### Out of scope for v1
- Per-device-type granularity (we ship one matrix per event-type, no device-type split)
- Per-device mute list (deferred — user can globally toggle off if noisy)
- Native OS toast via Windows winrt API (Web Notifications cover the use case;
also avoids the `os_notification_listener` feedback loop)
- Notification history panel — could land later as the reserved `alerts` dashboard cell
## Server shutdown action
Let user choose what happens to LED targets on server shutdown.
- [x] Backend storage: `shutdown_action` in `db.settings` (`"stop_targets"` default | `"nothing"`)
- [x] Backend route: `GET/PUT /api/v1/system/shutdown-action` in `system_settings.py`
- [x] Backend schema: `ShutdownActionResponse/Request` in `schemas/system.py`
- [x] Backend wiring: lifespan shutdown in `main.py` reads action, passes `restore_devices` flag to `processor_manager.stop_all()`
- [x] `processor_manager.stop_all(restore_devices: bool = True)` — when False, calls public `proc.cancel_task()` (defined on `TargetProcessor`) which awaits cancellation without restoring device state; skips `_restore_device_idle_state` loop. No reach into private `_task` attribute.
- [x] Frontend: hidden `<select>` + IconSelect in `settings.html` General tab (icons via `ICON_SQUARE` / `ICON_CIRCLE` from `core/icons.ts`)
- [x] Frontend: load/save handlers in `features/settings.ts`, wired into `openSettingsModal()`
- [x] i18n: en / ru / zh keys for label, hint, item descriptions
- [ ] Real-hardware test pending — verify that "nothing" actually leaves a WLED + a serial device on the last frame after `Ctrl+C`/SIGTERM.
## WebUI Redesign — "Lumenworks" Studio-Console Aesthetic
Full-app UI/UX refresh. Design direction committed to by user 2026-04-24.
Mockup lives at [server/docs/ui-redesign-mockup.html](server/docs/ui-redesign-mockup.html).
Phases are independent and CSS-only where possible — backend untouched.
### Phase 1 — Design tokens & font embed
- [x] Embed variable fonts (`server/src/ledgrab/static/fonts/`):
Manrope (latin + latin-ext + cyrillic + cyrillic-ext),
JetBrains Mono (same 4 subsets),
Big Shoulders Display (latin + latin-ext). Total +201 KB gzipped,
served via `unicode-range` so only latin paints on first load.
- [x] `fonts.css` — declare `@font-face` entries for all new families with
proper `unicode-range` subsetting; keep DM Sans + Orbitron registered
for legacy-token callers during migration.
- [x] `base.css` — add additive Lumenworks tokens:
`--font-display/--font-brand/--font-body`, `--lux-r-*`, `--lux-hairline`,
`--lux-rule`. Both `[data-theme="dark"]` and `[data-theme="light"]`
define `--lux-bg-0…3`, `--lux-line/-bold`, `--lux-ink/-dim/-mute/-faint`,
`--ch-signal/-cyan/-magenta/-amber/-coral/-violet`, `--lux-signal-glow`,
`--lux-shadow-rack`. Existing tokens untouched — no visual regression.
### Phase 2 — Shell (header → transport bar + channel-strip sidebar)
- [x] `index.html` — `.tab-bar` moved out of `<header>` into a new
`<aside class="sidebar">`; wrapped content in `.app-body` 2-col grid
(sidebar | main). `.transport-center` section added between
`.header-title` and `.header-toolbar` with a placeholder `.transport-status`
chip ("Ready" → "Armed · N live" wired in Phase 3). All tab-button IDs,
`data-tab` attributes, and `onclick="switchTab(…)"` handlers preserved.
- [x] `layout.css` — `<header>` rebuilt as the transport bar: 3-column grid
(brand | center | toolbar), 60 px fixed height, sticky, gradient bottom
rule with channel-color wash. `.header-title::before/::after` render
the glowing LED brand mark; `#server-status` repositioned as the LED
core pip. `#server-version` restyled as a mono-type console badge.
- [x] `sidebar.css` (new) — vertical channel-strip navigation. Active tab
gets a glowing left stripe + radial tint. `.sidebar-foot` contains
a `.cpu-meter` plate with two live bars (Load, FPS) ready to be
JS-bound in Phase 3. Collapses to a 56 px icon rail at ≤1100 px;
hides entirely at ≤600 px via `display: contents` so `.tab-bar`
falls through to `mobile.css`'s fixed-bottom strip unchanged.
- [x] `all.css` — new sidebar import after layout.
- [x] `base.css` — body font-family switched to `var(--font-body)` which
resolves to Manrope (with DM Sans + system fallbacks). Added
`font-feature-settings` for stylistic set + alternate 1.
- [x] Locale additions: `sidebar.workspaces`, `sidebar.load`, `sidebar.fps`,
`transport.status.ready`, `transport.status.armed` in en/ru/zh.
- [x] Tutorial + auth selectors (`header .header-title`, `#tab-btn-*`,
`.tab-bar` querySelector, `a.header-link[href="/docs"]`, onclick
markers on theme/settings/search) all survive the move.
- [ ] JS: bind `.cpu-meter` + `.transport-status` chip to existing
`performance` WebSocket / poller. Done as part of Phase 3.
- [ ] Tablet-range visual polish pass once other phases render (some tabs
currently have their own internal sticky headers that may overlap
the transport bar on narrow viewports).
### Phase 3 — Dashboard hero + module redesign
- [x] `cards.css` — `.card` gets rack-module treatment: channel stripe on
left edge (color-coded via `data-card-type` + `.ch-*` utility classes),
`::after` corner bracket in top-right, mono-typed metric labels
planned for Phase 4. Running cards glow the stripe brighter + emit a
`signalFlow` keyframe strip along the bottom edge.
- [x] Removed the `@property --border-angle` rotating conic-gradient border
(retired the WebKit mask workaround + light-theme variant + fallback
for `@supports not (mask-composite: exclude)`). Replaced with the
signal-flow strip — one animated linear-gradient on a 2 px line, no
GPU layer compositing per card.
- [x] `dashboard.css` — `.dashboard-target` rows pick up the same channel
stripe + signal-flow treatment. Section headers now use mono caps
with a channel-green underline accent. Metric values use mono with
tabular numerics; labels use silkscreened micro-caps.
- [x] Skeleton-card rewritten: left hairline + corner bracket so it reads
as "loading module" instead of a generic flashing block.
`skeletonShimmer` gradient replaces the old opacity-pulse on
`--text-color`.
- [x] `_updateSidebarMeter` binds CPU% (Load) and app-CPU share (FPS)
to the sidebar meter plate on every perf poll.
- [x] `_updateTransportStatus` updates the transport chip ("Ready" →
"Armed · N live") whenever the dashboard's running-target set is
recomputed.
- [ ] `.hero` 4-cell readout row (Active Patches / Throughput / CPU /
Latency + inline sparklines) — CSS tokens + layout are ready; HTML
render deferred until the dashboard JS is refactored to emit it
(Phase 3b, non-blocking).
### Phase 4 — Other tabs adopt module language
- [x] `tree-nav.css` — trigger pill gets a channel stripe on its left edge
(glows + widens when open). Trigger title uses mono-uppercase with
wide letter-spacing. Dropdown panel has a gradient channel-accent
rule across its top edge. Group headers use silkscreened micro-caps
with a small square marker instead of the old bold-uppercase. Active
leaf has a pulsing LED pip on the left and a channel tint behind it.
Count badges switched to mono tabular-nums in 2-px-radius pills.
- [x] `.subtab-section-header` — channel-green underline accent + mono
micro-caps. Consistent with the dashboard-section pattern so the
whole app shares one section-header language.
- [x] `.stream-tab-btn` sub-tabs — mono uppercase with wide tracking,
active tab shows channel-green underline + glowing count badge.
- [x] `.perf-chart-card` — channel stripe on the left (replaces old
`border-top` accent). Per-metric accents swapped to channel palette
(`--ch-coral` for CPU, `--ch-violet` for RAM, `--ch-signal` for GPU,
`--ch-amber` for temp). Corner bracket added. Metric values pick up
`tabular-nums` + a soft glow.
- [x] `cards.css` — channel-color mapping extended to attributes the JS
already emits (`data-target-id` → green, `data-stream-id` → cyan,
`data-audio-source-id` → magenta, `data-automation-id` /
`data-scene-id` → violet). No JS changes required; cards pick up
their correct stripe automatically on the Targets/Sources/Automations
tabs.
- [x] Graph editor — toolbar gets a gradient background + hairline +
rack shadow + backdrop blur. Canvas and nodes untouched.
- [x] `.template-card` — Lumenworks treatment (channel stripe on left,
corner bracket top-right, hairline border, hover lift + stripe
glow). Brings Inputs (streams / capture / pp / cspt / pattern
templates) and Integrations (HA / MQTT / weather / value /
sync-clock / game-integration cards) up to the same visual
language as `.card` and `.dashboard-target`.
- [x] `cards.css` — channel mapping extended to `.template-card`.
Direct attr hooks for `data-stream-id`/`data-template-id`/`data-pp-template-id`
(cyan), `data-cspt-id`/`data-pattern-template-id` (signal),
`data-audio-template-id`/`data-apt-id` (magenta). Section-scoped
hooks via `[data-card-section="…"]` for cards that share a
generic `data-id` (HA / MQTT / weather / value → cyan;
game-integrations → amber; sync-clocks → violet; HA-light-targets
→ signal). No JS changes — uses the section markup `CardSection`
already emits.
- [x] Graph editor nodes — body fill `--lux-bg-1` with hairline stroke,
hover bold-line, selected/running stroke `--ch-signal` with
drop-shadow glow. Title font switched from DM Sans to
`--font-display`; subtitle to mono uppercase wide-tracking.
Port-drop-target glow recoloured to `--ch-signal`. Port labels
adopt the mono caption treatment. Grid dots use `--lux-line`.
Running gradient stops switched from `--primary-color`/`--success-color`
to channel palette (signal → cyan → signal).
### Phase 5 — Modal restyle
- [x] `modal.css` — backdrop gains a radial dim + 6 px blur for stronger
separation. `.modal-content` gets a gradient background + hairline +
deep rack shadow. Channel-accent rule across the top edge driven by
`--modal-ch` (per-modal override). Corner bracket bottom-right on
desktop. `.modal-header` gains a vertical channel-color stripe to
the left of the title; `.modal-footer` picks up a hairline divider.
- [x] Per-modal channel mapping by modal ID:
- Target editors → green
- Input/Source editors → cyan
- Audio editors → magenta
- Automation / Scene / Game editors → violet
- Settings / API key / Setup / Notifications → amber
- Confirm dialog → coral
- [x] `components.css` — inputs use hairline borders, tabular-nums mono
for `input[type="number"]`, channel-green focus ring + glow. Buttons
use mono-uppercase type, signal-glow on primary, coral-glow on
danger. `<select>` audit deferred (project already enforces via
CLAUDE.md rule + IconSelect/EntitySelect wrappers).
### Phase 6 — Mobile dedicated shell
- [x] `mobile.css` (existing file, not forked) — fixed-bottom `.tab-bar`
promoted to full Lumenworks treatment: gradient background + hairline
divider at top + channel-accent rule matching the transport-bar
bottom. Active tab gets an LED pip above the icon and a channel-tint
background. Tab labels + badges use mono uppercase to match the
rest of the app. Phone (≤600 px): modal corner-bracket hidden
(fullscreen modals), modal-header stripe slimmed to 18 px.
- [x] Phase 2's layout.css already strips the transport-center on phones
and collapses the sidebar via `display: contents`, so the mobile
shell automatically routes the tab-bar to the bottom without a
separate JS hook.
- [WONTDO] Fork into `mobile-shell.css` — keeping changes in `mobile.css`
since the cascade was already organized by viewport. A rename adds
churn without improving maintainability.
### Phase 7 — Microcopy + retire legacy
- [x] Locale rename: `targets.title` + `dashboard.section.targets` →
"Channels" (en) / "Каналы" (ru) / "通道" (zh);
`streams.title` → "Inputs" / "Входы" / "输入".
Automations kept as-is (Automations + Scenes is a meaningful
distinction; "Patches" would conflate them). Internal tab keys
(`dashboard` / `automations` / `targets` / `streams` / `integrations`
/ `graph`) unchanged so no JS or localStorage migration needed.
- [x] Ambient WebGL background — default is already `off`; kept the
toggle button and localStorage preference so users who want the
shader can turn it on. No entry-point change needed: `data-bg-anim`
is initialized from localStorage with `off` fallback.
- [DEFERRED] Delete DM Sans + legacy color tokens — would cascade through
every file that reads `--primary-color` / `--text-color` etc. Safer
as a separate cleanup PR after the new design has soaked.
- [WONTDO] Delete `mobile.css` — Phase 6 kept the filename.
## Dashboard Customization
Per-account dashboard layout — slide-in Customize panel lets users
toggle section / perf-cell visibility, reorder via drag, change density,
pick presets, and import/export the layout as JSON. Server-synced via
`db.get_setting('dashboard_layout')` so settings follow the user.
- [x] `js/features/dashboard-layout.ts` — schema (open registry of section
/ perf-cell keys so v1.1 cards slot in with no migration), defaults,
5 built-in presets (Studio/Operator/Showrunner/Diagnostics/TV),
localStorage cache + server sync, legacy-key migration from
`dashboard_collapsed`, `perfMetricsMode`, `perfChartColor_*`.
- [x] `api/routes/preferences.py` — `GET/PUT/DELETE
/api/v1/preferences/dashboard-layout`. Treats payload as opaque
(frontend owns the schema); validates only that body is an object
with a numeric `version`. 6 pytest tests in
`tests/test_preferences_api.py` cover round-trip, default-empty,
validation, delete, and unknown-field passthrough.
- [x] `js/features/dashboard.ts` — sections rendered into a fragment map,
then assembled in layout-driven order; perf section stays pinned
top (chart-persistence reasons) but its visibility is layout-
driven. Layout-change subscription invalidates the in-place-update
optimization so density / order / visibility changes always
rebuild section HTML.
- [x] `js/features/perf-charts.ts` — `renderPerfSection()` iterates
`getOrderedPerfCells()`; existing legacy `setPerfMode` writes
through to the layout so the global toggle and the customize
panel stay in sync.
- [x] `js/features/dashboard-customize.ts` + `css/dashboard-customize.css`
— slide-in panel, hand-rolled HTML5 drag-and-drop reorder, ↑/↓
buttons for keyboard / TV remote, debounced (300 ms) autosave,
live preview while open. Reset / export / import actions.
- [x] i18n keys for `dashboard.customize.*` in en/ru/zh.
- [ ] (v1.1) Audio meters section — peak / RMS / BPM bars per audio
source. Schema key `audio-meters` already reserved.
- [ ] (v1.1) Alerts section — quiet by default, loud on issues.
Reserved key `alerts`.
- [ ] (v1.1) Live LED preview strip per running device. Reserved
key `led-preview`.
- [ ] (v1.1) Source thumbnails grid (1 fps multiviewer). Reserved
key `source-thumbs`.
- [ ] (v1.2) Pinned section (user-curated mix of targets / scenes /
devices). Reserved key `pinned`.
- [ ] (v1.2) Patch/flow map — read-only mini graph of routing.
Reserved key `flow`.
## BLE LED Controller Support (SP110E / Triones / Zengge / Govee) ## BLE LED Controller Support (SP110E / Triones / Zengge / Govee)
Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming. Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming.
@@ -120,7 +524,7 @@ Beyond the `/proc`-based AndroidMetricsProvider that's now in place:
## Refactor: Per-Provider Device Configs ## Refactor: Per-Provider Device Configs
Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses. Full plan: [docs/plans/device-typed-configs.md](docs/plans/device-typed-configs.md). Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses.
- [x] Phase 1 — `DeviceConfig` hierarchy + `Device.to_config()` (non-breaking, additive only) - [x] Phase 1 — `DeviceConfig` hierarchy + `Device.to_config()` (non-breaking, additive only)
- [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR) - [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR)
+1 -1
View File
@@ -40,7 +40,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before — // in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install. // sideload updates silently refused to install.
versionCode = ledgrabVersionCode versionCode = ledgrabVersionCode
versionName = "0.4.1" versionName = "0.6.1"
ndk { ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern // All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
+10
View File
@@ -69,6 +69,16 @@ copy_app_files() {
# Clean up source maps and __pycache__ # Clean up source maps and __pycache__
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# Patch the fallback version in the bundled __init__.py. Bundled installs
# strip ledgrab-*.dist-info from site-packages, so importlib.metadata
# falls back to this literal at runtime — and a stale literal is what
# silently shipped v0.4.2 reporting "0.3.0" in the WebUI.
local bundled_init="$APP_DIR/src/ledgrab/__init__.py"
if [ -f "$bundled_init" ] && [ -n "${VERSION_CLEAN:-}" ]; then
sed -i "s/_FALLBACK_VERSION = \"[^\"]*\"/_FALLBACK_VERSION = \"${VERSION_CLEAN}\"/" "$bundled_init"
echo " Patched _FALLBACK_VERSION -> ${VERSION_CLEAN}"
fi
} }
# ── Site-packages cleanup ──────────────────────────────────── # ── Site-packages cleanup ────────────────────────────────────
+11
View File
@@ -196,6 +196,17 @@ New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue 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 Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Patch the fallback version in the bundled __init__.py so the WebUI always
# reports the release version — the installer strips ledgrab-*.dist-info from
# site-packages (above), so importlib.metadata falls back to this literal.
$bundledInit = Join-Path $srcDest "ledgrab\__init__.py"
if (Test-Path $bundledInit) {
$initContent = Get-Content $bundledInit -Raw
$patched = [regex]::Replace($initContent, '_FALLBACK_VERSION\s*=\s*"[^"]*"', "_FALLBACK_VERSION = `"$VersionClean`"")
Set-Content -Path $bundledInit -Value $patched -NoNewline
Write-Host " Patched _FALLBACK_VERSION -> $VersionClean"
}
# ── Create launcher ──────────────────────────────────────────── # ── Create launcher ────────────────────────────────────────────
Write-Host "[8/8] Creating launcher..." Write-Host "[8/8] Creating launcher..."
+4 -1
View File
@@ -162,8 +162,11 @@ Section "Desktop shortcut" SecDesktop
SectionEnd SectionEnd
Section "Start with Windows" SecAutostart Section "Start with Windows" SecAutostart
; Pass --autostart so the VBS sets LEDGRAB_AUTOSTART=1 and the app suppresses
; the browser auto-open on Windows login. Manual launches (desktop / start
; menu) don't pass the arg, so they keep opening the WebUI tab.
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \ CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \ "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}" --autostart' \
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0 "$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
SectionEnd SectionEnd
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-274
View File
@@ -1,274 +0,0 @@
# Refactor Plan: Per-Provider Typed Device Configs
**Status:** Planned, not started.
**Target branch:** `refactor/device-typed-configs`
**Intended executor:** Sonnet agent (one phase per invocation; human review between phases).
## Goal
Replace the flat [`DeviceInfo`](../../server/src/ledgrab/core/processing/target_processor.py) dataclass (and the `**kwargs`-based `LEDDeviceProvider.create_client(url, **kwargs)` contract) with a **discriminated union of per-provider config dataclasses**. Each provider owns its config type and reads typed fields instead of guessing kwargs.
## Motivation
Current pain points:
- [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) unpacks ~21 fields by hand into `create_led_client(**kwargs)`.
- Every provider's `create_client` starts with `kwargs.get("x", default)` — no type safety, no IDE hints, no way to know at a glance which fields a provider actually uses.
- Adding a new per-device-type field requires threading it through `Device``DeviceInfo``_DEVICE_FIELD_DEFAULTS` → call-site unpacking → kwargs bag → provider.
- Fields leak across device types (a WLED device carries `ble_govee_key=""` at runtime for no reason).
## Scope guardrails
- **Storage schema (SQLite) unchanged.** Columns stay, dead-for-this-type fields stay, no destructive migration.
- **Frontend HTML/TS unchanged in phases 1-4.** It already branches on `device_type` with show/hide logic. Frontend changes are deferred to Phase 5.
- **API schemas are last.** Phase 5 converts `DeviceCreate`/`DeviceUpdate`/`DeviceResponse` to a Pydantic v2 discriminated union. This is the only breaking external change and can be deferred indefinitely if needed.
---
## Phase 1 — Config hierarchy (foundation, non-breaking)
### Create
**File:** `server/src/ledgrab/core/devices/device_config.py`
Pattern:
```python
from dataclasses import dataclass
from typing import List, Literal, Optional, Union
@dataclass(frozen=True)
class BaseDeviceConfig:
device_id: str
device_url: str
led_count: int
software_brightness: int = 255
test_mode_active: bool = False
auto_shutdown: bool = False
rgbw: bool = False
@dataclass(frozen=True)
class WLEDConfig(BaseDeviceConfig):
device_type: Literal["wled"] = "wled"
use_ddp: bool = False
# ... one @dataclass(frozen=True) per provider
```
### Config field inventory
Base: `device_id`, `device_url`, `led_count`, `software_brightness`, `test_mode_active`, `auto_shutdown`, `rgbw`.
| Config | Extra fields beyond Base |
| -------------- | ------------------------ |
| WLEDConfig | `use_ddp: bool = False` |
| AdalightConfig | `baud_rate: Optional[int] = None` |
| AmbiLEDConfig | `baud_rate: Optional[int] = None` |
| DMXConfig | `dmx_protocol`, `dmx_start_universe`, `dmx_start_channel` |
| ESPNowConfig | `baud_rate`, `espnow_peer_mac`, `espnow_channel` |
| HueConfig | `hue_username`, `hue_client_key`, `hue_entertainment_group_id` |
| SPIConfig | `spi_speed_hz`, `spi_led_type` |
| ChromaConfig | `chroma_device_type` |
| GameSenseConfig| `gamesense_device_type` |
| BLEConfig | `ble_family`, `ble_govee_key` |
| GroupConfig | `group_mode`, `group_device_ids` (**no `device_store` here** — see Phase 2) |
| OpenRGBConfig | `zone_mode` |
| MockConfig | `send_latency_ms: int = 0` |
| DemoConfig | `send_latency_ms: int = 0` |
| MQTTConfig | (none) |
| WSConfig | (none) |
| USBHIDConfig | (none — `hid_usage_page` is parsed from the URL, not config) |
```python
DeviceConfig = Union[
WLEDConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, ESPNowConfig,
HueConfig, SPIConfig, ChromaConfig, GameSenseConfig, BLEConfig,
GroupConfig, MQTTConfig, WSConfig, USBHIDConfig, OpenRGBConfig,
MockConfig, DemoConfig,
]
```
### Add
**`Device.to_config() -> DeviceConfig`** in [server/src/ledgrab/storage/device_store.py](../../server/src/ledgrab/storage/device_store.py) (around lines 14-97 where `Device` lives).
- Dispatches on `self.device_type`.
- Constructs the right subclass, pulling only relevant columns.
- Ignores columns that don't apply to the type.
- This is the **only** place that knows the flat→typed mapping.
### Do NOT touch in Phase 1
- Provider signatures (still `create_client(self, url, **kwargs)`).
- `create_led_client` factory.
- Any call site.
- `DeviceInfo` itself.
### Acceptance
- New unit test `server/tests/core/devices/test_device_config.py`:
- For each provider, build a `Device` with that `device_type`, call `to_config()`, assert right subclass and right fields.
- Edge case: extra/irrelevant Device fields must not leak into the wrong config type.
- `cd server && ruff check src/ tests/ --fix` — green.
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green (existing tests untouched, new test passes).
- `cd server && npx tsc --noEmit` — green (no TS impact this phase, just a sanity check).
---
## Phase 2 + Phase 3 — Provider API migration + call-site migration (single PR)
**These must land in one commit** because the provider signature change would otherwise break the 3 call sites immediately.
### Change the abstract base
[server/src/ledgrab/core/devices/led_client.py](../../server/src/ledgrab/core/devices/led_client.py):
```python
class LEDDeviceProvider(ABC):
@abstractmethod
def create_client(self, config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient: ...
```
`ProviderDeps` is a tiny new dataclass:
```python
@dataclass(frozen=True)
class ProviderDeps:
device_store: "DeviceStore"
# Add future cross-cutting runtime deps here (http_client, etc.)
```
`create_led_client`:
```python
def create_led_client(config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient:
return get_provider(config.device_type).create_client(config, deps=deps)
```
### Update every provider (17 files)
- Narrow signature per provider: e.g. `WLEDDeviceProvider.create_client(self, config: WLEDConfig, *, deps: ProviderDeps)`.
- Drop all `kwargs.get("x")` lookups — read typed fields directly.
- Providers that don't need `deps` just ignore it.
- **GroupDeviceProvider** is the only current consumer of `deps`: reads `deps.device_store`.
### Call sites (3)
1. [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) lines ~120-148 — the 21-field unpacking. Replace with:
```python
config = device.to_config()
self._led_client = create_led_client(config, deps=self._provider_deps)
```
`self._provider_deps` is plumbed in from `ProcessorManager` when the target processor is constructed.
2. [server/src/ledgrab/core/processing/device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78 — minimal test-mode client. Build a synthetic config via a helper `_minimal_config_for_test_mode(device)` (keeps just `device_id`, `device_url`, `led_count`, `baud_rate`) and pass it.
3. [server/src/ledgrab/core/devices/group_client.py](../../server/src/ledgrab/core/devices/group_client.py) lines 47-70 — child client construction inside the group. Same pattern: `child_config = child_device.to_config()`; pass `deps` through.
### Delete
- `DeviceInfo` dataclass in [server/src/ledgrab/core/processing/target_processor.py](../../server/src/ledgrab/core/processing/target_processor.py) lines 71-109.
- `ProcessorManager._get_device_info()` and `_DEVICE_FIELD_DEFAULTS` in [server/src/ledgrab/core/processing/processor_manager.py](../../server/src/ledgrab/core/processing/processor_manager.py) lines 230-275 — `Device.to_config()` subsumes this. Verify no other callers via `ast-index usages "_get_device_info"`.
### Acceptance
- `ast-index search "device_info\."` — no hits in non-test code.
- `ast-index search "DeviceInfo"` — no hits outside archival comments.
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all tests pass.
- Manual smoke: start server, create a WLED device, start processing, verify LEDs update (or mock output shows frames).
- `cd server && ruff check src/ tests/ --fix` — green.
---
## Phase 4 — Test migration
Update these files:
- `server/tests/storage/test_device_store.py` — add `to_config()` cases per device type.
- `server/tests/api/routes/test_devices_routes.py` — should be mostly untouched (API schemas still flat until Phase 5).
- `server/tests/e2e/test_device_flow.py` — update internal assertions only if they touch `DeviceInfo` directly.
- `server/tests/test_group_device.py` — construct child clients with `GroupConfig`.
- Any fixture helper that builds a fake `DeviceInfo` — migrate to the right `*Config` subclass.
### Acceptance
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all green.
- Coverage of `device_config.py` and `Device.to_config()` ≥ 90%.
---
## Phase 5 — API discriminated union (OPTIONAL, separate PR)
**Do not start until Phases 1-4 are merged and stable.** Flag this to the human before beginning. This is the only phase with an externally breaking change.
### Backend
[server/src/ledgrab/api/schemas/devices.py](../../server/src/ledgrab/api/schemas/devices.py) — replace flat `DeviceCreate`/`DeviceUpdate` with Pydantic v2 tagged unions:
```python
class WLEDDeviceCreate(BaseModel):
device_type: Literal["wled"]
name: str
url: str
led_count: int
use_ddp: bool = False
# ... base fields only
DeviceCreate = Annotated[
Union[WLEDDeviceCreate, AdalightDeviceCreate, ...],
Field(discriminator="device_type"),
]
```
Add `model_config = ConfigDict(extra="ignore")` on each union member for **one release cycle** so existing clients (frontend, HAOS integration, curl scripts) that send extra fields don't 422 immediately. Add a deprecation note and tighten to `extra="forbid"` in a follow-up.
### Frontend
- [server/src/ledgrab/static/js/features/devices.ts](../../server/src/ledgrab/static/js/features/devices.ts) and related — when building the POST/PATCH body, scope the payload to the selected `device_type` using the show/hide knowledge already in `device-discovery.ts`.
- **No plain `<select>` elements** — any new pickers use IconSelect or EntitySelect (see root CLAUDE.md UI rules).
### Tests
- Update `test_devices_routes.py` to assert discriminated union rejection of mismatched shapes.
- Add round-trip tests: create device of each type via API → fetch → compare fields.
### Acceptance
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green.
- `cd server && npx tsc --noEmit && npm run build` — green.
- Manual smoke for at least 3 device types (WLED, DMX, Hue) — create, edit, delete via UI.
- HAOS integration still works against the server (spot-check; not automated).
---
## Conventions the implementing agent must follow
- **Project task tracker is `TODO.md`** — check the "Refactor: Per-Provider Device Configs" section, tick boxes as phases land. Do **not** use the `TodoWrite` tool.
- **Auto-restart after Python changes.** See [contexts/server-operations.md](../../contexts/server-operations.md).
- **No commits without explicit user approval.** Present each phase's diff for review first.
- **Pre-commit gate every phase:**
- `cd server && ruff check src/ tests/ --fix`
- `cd server && py -3.13 -m pytest tests/ --no-cov -q`
- Phase 5 additionally: `cd server && npx tsc --noEmit && npm run build`
- **No plain `<select>`** — Phase 5 uses IconSelect / EntitySelect.
- **Android parity:** if you add any new runtime dep to `server/pyproject.toml`, update `android/app/build.gradle.kts` per the root [CLAUDE.md](../../CLAUDE.md) "Android Dependency Sync" section. This refactor should not need any new deps.
- **Data migration policy:** storage schema is unchanged, so no JSON-file migration is needed. But if you rename any serialized field during `to_dict`/`from_dict`, add migration logic per the root [CLAUDE.md](../../CLAUDE.md) "Data Migration Policy" section.
- **Use `ast-index`** for code search (`ast-index search`, `ast-index usages`, `ast-index callers`, `ast-index class`). Fall back to Grep only for regex/string-literal/comment searches.
- **Never run `cd` in Bash.** Use absolute paths or the project-relative `cd server && <cmd>` idiom (one-shot, same invocation).
## Known risks
1. **Frozen dataclass + inheritance + defaults** — Python's `@dataclass(frozen=True)` with inheritance requires every subclass field to have a default if any parent field does. Base has defaulted fields. Verify in Phase 1. If it breaks, use `kw_only=True` (Python 3.10+).
2. **`use_ddp` origin** — currently inferred from `self._protocol == "ddp"` at the call site, not from Device storage. Options: add a column (schema change, more work), **or** keep inference logic inside `Device.to_config()` (recommended — no schema change). Prefer the latter.
3. **Test-mode minimal client** ([device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78) may not have all `BaseDeviceConfig` fields available. Build a synthetic config via a named helper; do not leak the hack into `Device.to_config()`.
4. **Group `device_store` import cycle** — `GroupConfig` must **not** hold `device_store` (would pull storage into the config module). `ProviderDeps` is the deliberate cut.
5. **BLE optional import** — `BLEDeviceProvider` is conditionally registered (see [led_client.py](../../server/src/ledgrab/core/devices/led_client.py) lines 321-330). Ensure `BLEConfig` still imports cleanly even when `bleak` is absent — put `BLEConfig` in `device_config.py` (not in `ble_provider.py`) so it's always importable.
## Deliverables per phase
1. Branch: `refactor/device-typed-configs`.
2. One commit per phase, conventional-commit messages:
- `refactor(devices): phase 1 — add DeviceConfig hierarchy`
- `refactor(devices): phases 2+3 — typed provider signatures + call-site migration`
- `refactor(devices): phase 4 — test migration to typed configs`
- `refactor(devices): phase 5 — API discriminated union` (separate PR)
3. Phase-by-phase diffs presented for user review **before** each commit.
4. Final PR body linking all phases, with manual test plan per device type touched.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+48
View File
@@ -14,6 +14,9 @@
"marked": "^17.0.5" "marked": "^17.0.5"
}, },
"devDependencies": { "devDependencies": {
"@fontsource-variable/big-shoulders-display": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"esbuild": "^0.27.4", "esbuild": "^0.27.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
@@ -434,6 +437,33 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fontsource-variable/big-shoulders-display": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/manrope": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@kurkle/color": { "node_modules/@kurkle/color": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
@@ -704,6 +734,24 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"@fontsource-variable/big-shoulders-display": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
"dev": true
},
"@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"dev": true
},
"@fontsource-variable/manrope": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
"dev": true
},
"@kurkle/color": { "@kurkle/color": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+3
View File
@@ -16,6 +16,9 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@fontsource-variable/big-shoulders-display": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"esbuild": "^0.27.4", "esbuild": "^0.27.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "ledgrab" name = "ledgrab"
version = "0.4.1" version = "0.6.1"
description = "Ambient lighting system that captures screen content and drives LED strips in real time" description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [ authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"} {name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+9
View File
@@ -10,6 +10,15 @@ Set procEnv = WshShell.Environment("Process")
procEnv("PYTHONPATH") = appRoot & "\app\src" procEnv("PYTHONPATH") = appRoot & "\app\src"
procEnv("LEDGRAB_CONFIG_PATH") = appRoot & "\app\config\default_config.yaml" procEnv("LEDGRAB_CONFIG_PATH") = appRoot & "\app\config\default_config.yaml"
' If launched as Windows autostart (via the SMSTARTUP shortcut), suppress the
' browser auto-open. Manual launches (desktop / start menu) pass no args.
For Each arg In WScript.Arguments
If arg = "--autostart" Then
procEnv("LEDGRAB_AUTOSTART") = "1"
Exit For
End If
Next
' Use embedded python.exe (NOT pythonw.exe) with WindowStyle=0. ' Use embedded python.exe (NOT pythonw.exe) with WindowStyle=0.
' Same pattern as the Media Server sibling app. ' Same pattern as the Media Server sibling app.
embeddedPython = appRoot & "\python\python.exe" embeddedPython = appRoot & "\python\python.exe"
+7 -3
View File
@@ -2,10 +2,14 @@
from importlib.metadata import version, PackageNotFoundError from importlib.metadata import version, PackageNotFoundError
# Fallback version — kept in sync with pyproject.toml. # Fallback version — kept in sync with pyproject.toml. MUST match the
# version declared there on every release. The Windows installer build
# (build/build-dist.ps1) also patches this literal to the resolved build
# version, so any drift here is corrected for bundled distributions.
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy # Used when the package isn't pip-installed (e.g. embedded via Chaquopy
# on Android, where the source is included directly via source sets). # on Android, where the source is included directly via source sets, or
_FALLBACK_VERSION = "0.3.0" # in the Windows bundle where the installed dist-info is stripped).
_FALLBACK_VERSION = "0.4.2"
try: try:
__version__ = version("ledgrab") __version__ = version("ledgrab")
+33 -5
View File
@@ -12,6 +12,8 @@ import threading
import time import time
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
def _fix_embedded_tcl_paths() -> None: def _fix_embedded_tcl_paths() -> None:
@@ -54,9 +56,25 @@ def _run_server(server: uvicorn.Server) -> None:
loop.run_until_complete(server.serve()) loop.run_until_complete(server.serve())
def _open_browser(port: int, delay: float = 2.0) -> None: def _wait_for_server(port: int, timeout: float = 30.0, interval: float = 0.25) -> bool:
"""Open the UI in the default browser after a short delay.""" """Poll /health until the server responds or *timeout* seconds elapse."""
time.sleep(delay) url = f"http://localhost:{port}/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with urlopen(url, timeout=1) as resp: # noqa: S310 - localhost only
if 200 <= resp.status < 500:
return True
except (URLError, ConnectionError, OSError, TimeoutError):
pass
time.sleep(interval)
return False
def _open_browser(port: int) -> None:
"""Open the UI in the default browser once the server is ready."""
if not _wait_for_server(port):
logger.warning("Server did not become ready in time; opening browser anyway")
webbrowser.open(f"http://localhost:{port}") webbrowser.open(f"http://localhost:{port}")
@@ -65,6 +83,16 @@ def _is_restart() -> bool:
return os.environ.get("LEDGRAB_RESTART", "") == "1" return os.environ.get("LEDGRAB_RESTART", "") == "1"
def _is_autostart() -> bool:
"""Detect if launched via the Windows autostart shortcut."""
return os.environ.get("LEDGRAB_AUTOSTART", "") == "1"
def _should_skip_browser() -> bool:
"""Skip auto-opening the browser on restarts and on Windows login autostart."""
return _is_restart() or _is_autostart()
def _check_port(host: str, port: int) -> None: def _check_port(host: str, port: int) -> None:
"""Exit with a clear message if the port is already in use.""" """Exit with a clear message if the port is already in use."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
@@ -102,8 +130,8 @@ def main() -> None:
) )
server_thread.start() server_thread.start()
# Browser after a short delay (skip on restart — user already has a tab) # Browser after a short delay (skip on restart and on Windows login autostart)
if not _is_restart(): if not _should_skip_browser():
threading.Thread( threading.Thread(
target=_open_browser, target=_open_browser,
args=(config.server.port,), args=(config.server.port,),
+2
View File
@@ -31,6 +31,7 @@ from .routes.game_integration import router as game_integration_router
from .routes.audio_processing_templates import router as audio_processing_templates_router from .routes.audio_processing_templates import router as audio_processing_templates_router
from .routes.audio_filters import router as audio_filters_router from .routes.audio_filters import router as audio_filters_router
from .routes.pattern_templates import router as pattern_templates_router from .routes.pattern_templates import router as pattern_templates_router
from .routes.preferences import router as preferences_router
router = APIRouter() router = APIRouter()
router.include_router(system_router) router.include_router(system_router)
@@ -62,5 +63,6 @@ router.include_router(game_integration_router)
router.include_router(audio_processing_templates_router) router.include_router(audio_processing_templates_router)
router.include_router(audio_filters_router) router.include_router(audio_filters_router)
router.include_router(pattern_templates_router) router.include_router(pattern_templates_router)
router.include_router(preferences_router)
__all__ = ["router"] __all__ = ["router"]
+2
View File
@@ -142,6 +142,8 @@ async def update_asset(
name=body.name, name=body.name,
description=body.description, description=body.description,
tags=body.tags, tags=body.tags,
icon=body.icon,
icon_color=body.icon_color,
) )
except EntityNotFoundError: except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}") raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
@@ -36,6 +36,8 @@ def _apt_to_response(t) -> AudioProcessingTemplateResponse:
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=t.tags, tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
) )
@@ -73,6 +75,8 @@ async def create_audio_processing_template(
filters=filters, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("audio_processing_template", "created", template.id) fire_entity_event("audio_processing_template", "created", template.id)
return _apt_to_response(template) return _apt_to_response(template)
@@ -129,6 +133,8 @@ async def update_audio_processing_template(
filters=filters, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("audio_processing_template", "updated", template_id) fire_entity_event("audio_processing_template", "updated", template_id)
# Hot-update: rebuild filter pipelines for running streams using this template # Hot-update: rebuild filter pipelines for running streams using this template
@@ -46,6 +46,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
device_index=s.device_index, device_index=s.device_index,
is_loopback=s.is_loopback, is_loopback=s.is_loopback,
audio_template_id=s.audio_template_id, audio_template_id=s.audio_template_id,
@@ -57,6 +59,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
audio_source_id=s.audio_source_id, audio_source_id=s.audio_source_id,
audio_processing_template_id=s.audio_processing_template_id, audio_processing_template_id=s.audio_processing_template_id,
), ),
@@ -75,6 +79,8 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
tags=source.tags, tags=source.tags,
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
device_index=getattr(source, "device_index", -1), device_index=getattr(source, "device_index", -1),
is_loopback=getattr(source, "is_loopback", True), is_loopback=getattr(source, "is_loopback", True),
audio_template_id=getattr(source, "audio_template_id", None), audio_template_id=getattr(source, "audio_template_id", None),
@@ -53,6 +53,8 @@ async def list_audio_templates(
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
) )
for t in templates for t in templates
] ]
@@ -81,6 +83,8 @@ async def create_audio_template(
engine_config=data.engine_config, engine_config=data.engine_config,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("audio_template", "created", template.id) fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse( return AudioTemplateResponse(
@@ -92,6 +96,8 @@ async def create_audio_template(
created_at=template.created_at, created_at=template.created_at,
updated_at=template.updated_at, updated_at=template.updated_at,
description=template.description, description=template.description,
icon=getattr(template, "icon", "") or "",
icon_color=getattr(template, "icon_color", "") or "",
) )
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -127,6 +133,8 @@ async def get_audio_template(
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
) )
@@ -150,6 +158,8 @@ async def update_audio_template(
engine_config=data.engine_config, engine_config=data.engine_config,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("audio_template", "updated", template_id) fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse( return AudioTemplateResponse(
@@ -161,6 +171,8 @@ async def update_audio_template(
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
) )
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -122,6 +122,8 @@ def _automation_to_response(
last_activated_at=state.get("last_activated_at"), last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"), last_deactivated_at=state.get("last_deactivated_at"),
tags=automation.tags, tags=automation.tags,
icon=getattr(automation, "icon", "") or "",
icon_color=getattr(automation, "icon_color", "") or "",
created_at=automation.created_at, created_at=automation.created_at,
updated_at=automation.updated_at, updated_at=automation.updated_at,
) )
@@ -191,6 +193,8 @@ async def create_automation(
deactivation_mode=data.deactivation_mode, deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id, deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
if automation.enabled: if automation.enabled:
@@ -285,6 +289,8 @@ async def update_automation(
rules=rules, rules=rules,
deactivation_mode=data.deactivation_mode, deactivation_mode=data.deactivation_mode,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
if data.scene_preset_id is not None: if data.scene_preset_id is not None:
update_kwargs["scene_preset_id"] = data.scene_preset_id update_kwargs["scene_preset_id"] = data.scene_preset_id
@@ -43,6 +43,8 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=t.tags, tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
) )
@@ -84,6 +86,8 @@ async def create_cspt(
filters=filters, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("cspt", "created", template.id) fire_entity_event("cspt", "created", template.id)
return _cspt_to_response(template) return _cspt_to_response(template)
@@ -141,6 +145,8 @@ async def update_cspt(
filters=filters, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("cspt", "updated", template_id) fire_entity_event("cspt", "updated", template_id)
return _cspt_to_response(template) return _cspt_to_response(template)
@@ -4,7 +4,6 @@ from ledgrab.api.schemas.color_strip_sources import (
ApiInputCSSResponse, ApiInputCSSResponse,
AudioCSSResponse, AudioCSSResponse,
CandlelightCSSResponse, CandlelightCSSResponse,
ColorCycleCSSResponse,
ColorStop as ColorStopSchema, ColorStop as ColorStopSchema,
ColorStripSourceResponse, ColorStripSourceResponse,
CompositeCSSResponse, CompositeCSSResponse,
@@ -31,7 +30,6 @@ from ledgrab.storage.color_strip_source import (
ApiInputColorStripSource, ApiInputColorStripSource,
AudioColorStripSource, AudioColorStripSource,
CandlelightColorStripSource, CandlelightColorStripSource,
ColorCycleColorStripSource,
CompositeColorStripSource, CompositeColorStripSource,
DaylightColorStripSource, DaylightColorStripSource,
EffectColorStripSource, EffectColorStripSource,
@@ -67,6 +65,8 @@ def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
tags=source.tags, tags=source.tags,
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
) )
@@ -121,10 +121,6 @@ _RESPONSE_MAP: dict = {
easing=s.easing, easing=s.easing,
gradient_id=s.gradient_id, gradient_id=s.gradient_id,
), ),
ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse(
**kw,
colors=[list(c) for c in s.colors],
),
EffectColorStripSource: lambda s, kw: EffectCSSResponse( EffectColorStripSource: lambda s, kw: EffectCSSResponse(
**kw, **kw,
effect_type=s.effect_type, effect_type=s.effect_type,
@@ -31,7 +31,6 @@ router = APIRouter()
_PREVIEW_ALLOWED_TYPES = { _PREVIEW_ALLOWED_TYPES = {
"static", "static",
"gradient", "gradient",
"color_cycle",
"effect", "effect",
"daylight", "daylight",
"candlelight", "candlelight",
@@ -476,13 +475,16 @@ async def test_color_strip_ws(
meta["layer_infos"] = layer_infos meta["layer_infos"] = layer_infos
await websocket.send_text(_json.dumps(meta)) await websocket.send_text(_json.dumps(meta))
# For api_input: send the current buffer immediately so the client # For api_input: only send an initial frame if a client has actually
# gets a frame right away (fallback color if inactive) rather than # pushed data (push_generation > 0). Without prior data, the preview
# leaving the canvas blank/stale until external data arrives. # stays blank instead of showing the fallback buffer as a stray frame.
if is_api_input: if is_api_input:
initial_colors = stream.get_latest_colors() initial_gen = stream.push_generation
if initial_colors is not None: if initial_gen > 0:
await websocket.send_bytes(initial_colors.tobytes()) _last_push_gen = initial_gen
initial_colors = stream.get_latest_colors()
if initial_colors is not None:
await websocket.send_bytes(initial_colors.tobytes())
# For picture sources, grab the live stream for frame preview # For picture sources, grab the live stream for frame preview
_frame_live = None _frame_live = None
+4
View File
@@ -71,6 +71,8 @@ def _device_to_response(device) -> DeviceResponse:
default_css_processing_template_id=device.default_css_processing_template_id, default_css_processing_template_id=device.default_css_processing_template_id,
group_device_ids=device.group_device_ids, group_device_ids=device.group_device_ids,
group_mode=device.group_mode, group_mode=device.group_mode,
icon=getattr(device, "icon", "") or "",
icon_color=getattr(device, "icon_color", "") or "",
created_at=device.created_at, created_at=device.created_at,
updated_at=device.updated_at, updated_at=device.updated_at,
) )
@@ -439,6 +441,8 @@ async def update_device(
ble_govee_key=update_data.ble_govee_key, ble_govee_key=update_data.ble_govee_key,
group_device_ids=update_data.group_device_ids, group_device_ids=update_data.group_device_ids,
group_mode=update_data.group_mode, group_mode=update_data.group_mode,
icon=update_data.icon,
icon_color=update_data.icon_color,
) )
# Sync connection info in processor manager # Sync connection info in processor manager
@@ -158,6 +158,8 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
updated_at=config.updated_at, updated_at=config.updated_at,
description=config.description, description=config.description,
tags=config.tags, tags=config.tags,
icon=getattr(config, "icon", "") or "",
icon_color=getattr(config, "icon_color", "") or "",
) )
@@ -255,6 +257,8 @@ async def create_integration(
event_mappings=mappings, event_mappings=mappings,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("game_integration", "created", config.id) fire_entity_event("game_integration", "created", config.id)
@@ -323,6 +327,8 @@ async def update_integration(
event_mappings=mappings, event_mappings=mappings,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("game_integration", "updated", integration_id) fire_entity_event("game_integration", "updated", integration_id)
@@ -35,6 +35,8 @@ def _to_response(gradient: Gradient) -> GradientResponse:
tags=gradient.tags, tags=gradient.tags,
created_at=gradient.created_at, created_at=gradient.created_at,
updated_at=gradient.updated_at, updated_at=gradient.updated_at,
icon=getattr(gradient, "icon", "") or "",
icon_color=getattr(gradient, "icon_color", "") or "",
) )
@@ -66,6 +68,8 @@ async def create_gradient(
stops=[s.model_dump() for s in data.stops], stops=[s.model_dump() for s in data.stops],
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("gradient", "created", gradient.id) fire_entity_event("gradient", "created", gradient.id)
return _to_response(gradient) return _to_response(gradient)
@@ -103,6 +107,8 @@ async def update_gradient(
stops=stops, stops=stops,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("gradient", "updated", gradient_id) fire_entity_event("gradient", "updated", gradient_id)
return _to_response(gradient) return _to_response(gradient)
@@ -55,6 +55,8 @@ def _to_response(
entity_count=len(runtime.get_all_states()) if runtime else 0, entity_count=len(runtime.get_all_states()) if runtime else 0,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
token=token_field, token=token_field,
@@ -105,6 +107,8 @@ async def create_ha_source(
entity_filters=data.entity_filters, entity_filters=data.entity_filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -158,6 +162,8 @@ async def update_ha_source(
entity_filters=data.entity_filters, entity_filters=data.entity_filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except EntityNotFoundError: except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found") raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
@@ -316,6 +322,7 @@ async def get_ha_status(
name=source.name, name=source.name,
connected=connected, connected=connected,
entity_count=status["entity_count"] if status else 0, entity_count=status["entity_count"] if status else 0,
host=source.host or "",
) )
) )
+6
View File
@@ -45,6 +45,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
connected=runtime.is_connected if runtime else False, connected=runtime.is_connected if runtime else False,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
) )
@@ -90,6 +92,8 @@ async def create_mqtt_source(
base_topic=data.base_topic, base_topic=data.base_topic,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -139,6 +143,8 @@ async def update_mqtt_source(
base_topic=data.base_topic, base_topic=data.base_topic,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except EntityNotFoundError: except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found") raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found")
@@ -11,6 +11,7 @@ from ledgrab.api.dependencies import (
get_device_store, get_device_store,
get_output_target_store, get_output_target_store,
get_processor_manager, get_processor_manager,
get_value_source_store,
) )
from ledgrab.api.schemas.output_targets import ( from ledgrab.api.schemas.output_targets import (
HALightMappingSchema, HALightMappingSchema,
@@ -30,6 +31,7 @@ from ledgrab.storage.ha_light_output_target import (
HALightOutputTarget, HALightOutputTarget,
) )
from ledgrab.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
@@ -54,6 +56,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
protocol=target.protocol, protocol=target.protocol,
description=target.description, description=target.description,
tags=target.tags, tags=target.tags,
icon=getattr(target, "icon", "") or "",
icon_color=getattr(target, "icon_color", "") or "",
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
) )
@@ -66,8 +70,11 @@ def _ha_light_target_to_response(
return HALightOutputTargetResponse( return HALightOutputTargetResponse(
id=target.id, id=target.id,
name=target.name, name=target.name,
ha_source_id=target.ha_source_id, ha_source_id=target.ha_source_id or "",
color_strip_source_id=target.color_strip_source_id, source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
# Defensive coalesce — older records stored via resolve_ref may hold None.
color_strip_source_id=target.color_strip_source_id or "",
color_value_source_id=target.color_value_source_id or "",
brightness=target.brightness.to_dict(), brightness=target.brightness.to_dict(),
ha_light_mappings=[ ha_light_mappings=[
HALightMappingSchema( HALightMappingSchema(
@@ -82,13 +89,42 @@ def _ha_light_target_to_response(
transition=target.transition.to_dict(), transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(), color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(), min_brightness_threshold=target.min_brightness_threshold.to_dict(),
stop_action=target.stop_action,
description=target.description, description=target.description,
tags=target.tags, tags=target.tags,
icon=getattr(target, "icon", "") or "",
icon_color=getattr(target, "icon_color", "") or "",
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
) )
def _validate_color_value_source(
value_source_store: ValueSourceStore, color_value_source_id: str
) -> None:
"""Ensure the referenced ValueSource exists and returns colour."""
if not color_value_source_id:
raise HTTPException(
status_code=400,
detail="color_value_source_id is required when source_kind='color_vs'",
)
try:
source = value_source_store.get_source(color_value_source_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(
status_code=422,
detail=f"Color value source {color_value_source_id} not found",
)
if source.to_dict().get("return_type") != "color":
raise HTTPException(
status_code=400,
detail=(
f"Value source {color_value_source_id} does not return colour "
"(return_type must be 'color')"
),
)
def _target_to_response(target) -> OutputTargetResponse: def _target_to_response(target) -> OutputTargetResponse:
"""Convert any OutputTarget to the appropriate typed response.""" """Convert any OutputTarget to the appropriate typed response."""
if isinstance(target, WledOutputTarget): if isinstance(target, WledOutputTarget):
@@ -119,6 +155,7 @@ async def create_target(
target_store: OutputTargetStore = Depends(get_output_target_store), target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store), device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
value_source_store: ValueSourceStore = Depends(get_value_source_store),
): ):
"""Create a new output target.""" """Create a new output target."""
try: try:
@@ -130,6 +167,15 @@ async def create_target(
except ValueError: except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found") raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
# Validate color VS reference for HA-light targets in color_vs mode
if (
getattr(data, "target_type", "") == "ha_light"
and getattr(data, "source_kind", "css") == "color_vs"
):
_validate_color_value_source(
value_source_store, getattr(data, "color_value_source_id", "")
)
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None) ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = ( ha_mappings = (
[ [
@@ -161,10 +207,13 @@ async def create_target(
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
ha_source_id=getattr(data, "ha_source_id", ""), ha_source_id=getattr(data, "ha_source_id", ""),
source_kind=getattr(data, "source_kind", "css"),
color_value_source_id=getattr(data, "color_value_source_id", ""),
ha_light_mappings=ha_mappings, ha_light_mappings=ha_mappings,
update_rate=getattr(data, "update_rate", 2.0), update_rate=getattr(data, "update_rate", 2.0),
transition=getattr(data, "transition", 0.5), transition=getattr(data, "transition", 0.5),
color_tolerance=getattr(data, "color_tolerance", 5), color_tolerance=getattr(data, "color_tolerance", 5),
stop_action=getattr(data, "stop_action", "none"),
) )
# Register in processor manager # Register in processor manager
@@ -243,6 +292,7 @@ async def update_target(
target_store: OutputTargetStore = Depends(get_output_target_store), target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store), device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
value_source_store: ValueSourceStore = Depends(get_value_source_store),
): ):
"""Update a output target.""" """Update a output target."""
try: try:
@@ -254,6 +304,21 @@ async def update_target(
except ValueError: except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found") raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
# Validate color VS reference for HA-light targets switching into / staying in color_vs
if getattr(data, "target_type", "") == "ha_light":
new_kind = getattr(data, "source_kind", None)
new_color_vs = getattr(data, "color_value_source_id", None)
if new_kind == "color_vs" or (new_kind is None and new_color_vs):
# Determine effective id: payload id if provided, else existing target's id
effective_id = new_color_vs
if effective_id is None:
try:
existing = target_store.get_target(target_id)
effective_id = getattr(existing, "color_value_source_id", "")
except ValueError:
effective_id = ""
_validate_color_value_source(value_source_store, effective_id or "")
# Build HA light mappings if provided # Build HA light mappings if provided
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None) ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = None ha_mappings = None
@@ -283,11 +348,16 @@ async def update_target(
protocol=getattr(data, "protocol", None), protocol=getattr(data, "protocol", None),
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
ha_source_id=getattr(data, "ha_source_id", None), ha_source_id=getattr(data, "ha_source_id", None),
source_kind=getattr(data, "source_kind", None),
color_value_source_id=getattr(data, "color_value_source_id", None),
ha_light_mappings=ha_mappings, ha_light_mappings=ha_mappings,
update_rate=getattr(data, "update_rate", None), update_rate=getattr(data, "update_rate", None),
transition=getattr(data, "transition", None), transition=getattr(data, "transition", None),
color_tolerance=getattr(data, "color_tolerance", None), color_tolerance=getattr(data, "color_tolerance", None),
stop_action=getattr(data, "stop_action", None),
) )
# Sync processor manager (run in thread — css release/acquire can block) # Sync processor manager (run in thread — css release/acquire can block)
@@ -301,6 +371,9 @@ async def update_target(
transition = getattr(data, "transition", None) transition = getattr(data, "transition", None)
color_tolerance = getattr(data, "color_tolerance", None) color_tolerance = getattr(data, "color_tolerance", None)
brightness = getattr(data, "brightness", None) brightness = getattr(data, "brightness", None)
stop_action = getattr(data, "stop_action", None)
source_kind = getattr(data, "source_kind", None)
color_value_source_id = getattr(data, "color_value_source_id", None)
try: try:
await asyncio.to_thread( await asyncio.to_thread(
@@ -317,6 +390,9 @@ async def update_target(
or color_tolerance is not None or color_tolerance is not None
or ha_light_mappings_raw is not None or ha_light_mappings_raw is not None
or brightness is not None or brightness is not None
or stop_action is not None
or source_kind is not None
or color_value_source_id is not None
), ),
css_changed=color_strip_source_id is not None, css_changed=color_strip_source_id is not None,
brightness_changed=brightness is not None, brightness_changed=brightness is not None,
@@ -39,6 +39,8 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=t.tags, tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
) )
@@ -83,6 +85,8 @@ async def create_pattern_template(
rectangles=rectangles, rectangles=rectangles,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("pattern_template", "created", template.id) fire_entity_event("pattern_template", "created", template.id)
return _pat_template_to_response(template) return _pat_template_to_response(template)
@@ -139,6 +143,8 @@ async def update_pattern_template(
rectangles=rectangles, rectangles=rectangles,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("pattern_template", "updated", template_id) fire_entity_event("pattern_template", "updated", template_id)
return _pat_template_to_response(template) return _pat_template_to_response(template)
@@ -12,6 +12,7 @@ from fastapi.responses import Response
from ledgrab.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_color_strip_store,
get_picture_source_store, get_picture_source_store,
get_output_target_store, get_output_target_store,
get_pp_template_store, get_pp_template_store,
@@ -37,6 +38,7 @@ from ledgrab.api.schemas.picture_sources import (
) )
from ledgrab.core.capture_engines import EngineRegistry from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry, ImagePool from ledgrab.core.filters import FilterRegistry, ImagePool
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.template_store import TemplateStore from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
@@ -63,6 +65,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
display_index=s.display_index, display_index=s.display_index,
capture_template_id=s.capture_template_id, capture_template_id=s.capture_template_id,
target_fps=s.target_fps, target_fps=s.target_fps,
@@ -74,6 +78,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
source_stream_id=s.source_stream_id, source_stream_id=s.source_stream_id,
postprocessing_template_id=s.postprocessing_template_id, postprocessing_template_id=s.postprocessing_template_id,
), ),
@@ -84,6 +90,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
image_asset_id=s.image_asset_id, image_asset_id=s.image_asset_id,
), ),
VideoCaptureSource: lambda s: VideoPictureSourceResponse( VideoCaptureSource: lambda s: VideoPictureSourceResponse(
@@ -93,6 +101,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
video_asset_id=s.video_asset_id, video_asset_id=s.video_asset_id,
loop=s.loop, loop=s.loop,
playback_speed=s.playback_speed, playback_speed=s.playback_speed,
@@ -361,11 +371,12 @@ async def delete_picture_source(
_auth: AuthRequired, _auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store), store: PictureSourceStore = Depends(get_picture_source_store),
target_store: OutputTargetStore = Depends(get_output_target_store), target_store: OutputTargetStore = Depends(get_output_target_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
): ):
"""Delete a picture source.""" """Delete a picture source."""
try: try:
# Check if any target references this stream # Check if any target transitively references this stream via a CSS
target_names = store.get_targets_referencing(stream_id, target_store) target_names = store.get_targets_referencing(stream_id, target_store, css_store)
if target_names: if target_names:
names = ", ".join(target_names) names = ", ".join(target_names)
raise HTTPException( raise HTTPException(
@@ -373,6 +384,16 @@ async def delete_picture_source(
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. " detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
"Please reassign those targets before deleting.", "Please reassign those targets before deleting.",
) )
# Block when any CSS still references this picture source, even if no
# target depends on it — deletion would leave the CSS broken.
css_refs = css_store.get_referencing_picture_source(stream_id)
if css_refs:
css_names = ", ".join(css.name for css in css_refs)
raise HTTPException(
status_code=409,
detail=f"Cannot delete picture source: it is used by color strip source(s): "
f"{css_names}. Please reassign or delete those first.",
)
store.delete_stream(stream_id) store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id) fire_entity_event("picture_source", "deleted", stream_id)
except HTTPException: except HTTPException:
@@ -49,6 +49,8 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=t.tags, tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
) )
@@ -86,6 +88,8 @@ async def create_pp_template(
filters=filters, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("pp_template", "created", template.id) fire_entity_event("pp_template", "created", template.id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
@@ -143,6 +147,8 @@ async def update_pp_template(
filters=filters, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("pp_template", "updated", template_id) fire_entity_event("pp_template", "updated", template_id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
@@ -0,0 +1,288 @@
"""User preferences routes — dashboard layout + notification settings + daylight tz.
The dashboard layout schema is owned by the frontend (open registry of
section/cell keys); the backend treats the value as an opaque JSON blob,
validates it's a dict with a `version` field, and persists it under the
`dashboard_layout` settings key.
Notification preferences are validated server-side via Pydantic so the
backend can read them when deciding whether to start the background
discovery watcher.
Daylight timezone is a single global IANA tz name shared by every
daylight value-source / color-strip-source. Stored as
``{"value": "Europe/Berlin"}`` under the ``daylight_timezone`` key, with
empty/missing meaning "use system local time".
"""
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel, Field
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_database
from ledgrab.api.schemas.preferences import NotificationPreferences
from ledgrab.core.processing.daylight_settings import (
DAYLIGHT_TIMEZONE_KEY,
get_daylight_timezone,
set_daylight_timezone,
)
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
_NOTIFICATION_PREFS_KEY = "notification_preferences"
_CARD_MODES_KEY = "card_modes"
class DaylightTimezonePreference(BaseModel):
"""Global IANA timezone applied to every daylight cycle source."""
timezone: str = Field("", description="IANA timezone name; empty = system local")
def load_notification_preferences(db: Database | None = None) -> NotificationPreferences:
"""Read notification prefs, returning defaults when unset or corrupt.
Used by both the route handler and `main.lifespan` (so the discovery
watcher can decide whether to start without going through HTTP).
"""
if db is None:
from ledgrab.api.dependencies import get_database as _get_db
db = _get_db()
raw = db.get_setting(_NOTIFICATION_PREFS_KEY)
if not raw:
return NotificationPreferences()
try:
return NotificationPreferences.model_validate(raw)
except Exception as e:
logger.warning("Stored notification preferences invalid (%s); using defaults", e)
return NotificationPreferences()
@router.get(
"/api/v1/preferences/dashboard-layout",
tags=["Preferences"],
)
async def get_dashboard_layout(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, Any]:
"""Read the saved dashboard layout. Returns an empty object when no
layout has been saved yet the frontend falls back to its built-in
default in that case."""
value = db.get_setting(_DASHBOARD_LAYOUT_KEY)
return value if value is not None else {}
@router.put(
"/api/v1/preferences/dashboard-layout",
tags=["Preferences"],
)
async def put_dashboard_layout(
_: AuthRequired,
body: dict[str, Any] = Body(...),
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Save the dashboard layout. The body must be a JSON object with a
numeric `version` field; everything else is treated as opaque payload
that the frontend will validate on read."""
if not isinstance(body, dict):
raise HTTPException(status_code=422, detail="Body must be a JSON object")
if not isinstance(body.get("version"), int):
raise HTTPException(
status_code=422,
detail="Layout must include a numeric 'version' field",
)
db.set_setting(_DASHBOARD_LAYOUT_KEY, body)
return {"ok": True}
@router.delete(
"/api/v1/preferences/dashboard-layout",
tags=["Preferences"],
)
async def delete_dashboard_layout(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Delete the saved layout — frontend will revert to the default
on next load. Used by the 'Reset' button when the user wants
to clear the server-side override entirely."""
db.set_setting(_DASHBOARD_LAYOUT_KEY, {})
return {"ok": True}
# ---------------------------------------------------------------------------
# Notification preferences
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/preferences/notifications",
response_model=NotificationPreferences,
tags=["Preferences"],
)
async def get_notification_preferences(
_: AuthRequired,
db: Database = Depends(get_database),
) -> NotificationPreferences:
"""Read notification prefs, returning defaults when unset.
Defaults: device_offline=both, device_online/discovered=snack,
device_lost=none, background discovery on, 10 s startup grace,
5 s flap debounce.
"""
return load_notification_preferences(db)
@router.put(
"/api/v1/preferences/notifications",
response_model=NotificationPreferences,
tags=["Preferences"],
)
async def put_notification_preferences(
_: AuthRequired,
body: NotificationPreferences,
db: Database = Depends(get_database),
) -> NotificationPreferences:
"""Persist the notification prefs. Pydantic enforces channel
enum + grace/debounce ranges so a bad client cannot poison
the stored value."""
db.set_setting(_NOTIFICATION_PREFS_KEY, body.model_dump())
logger.info(
"Notification preferences updated (background_discovery=%s, " "channels=%s)",
body.background_discovery_enabled,
body.channels.model_dump(),
)
return body
# ---------------------------------------------------------------------------
# Card presentation modes (per-surface comfortable/compact/dense)
# ---------------------------------------------------------------------------
_VALID_CARD_MODES = {"comfortable", "compact", "dense", "row"}
@router.get(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def get_card_modes(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, Any]:
"""Read the saved card-mode preferences. Returns an empty object when
nothing has been saved yet the frontend falls back to the default
mode ("compact") for every surface in that case."""
value = db.get_setting(_CARD_MODES_KEY)
return value if value is not None else {}
@router.put(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def put_card_modes(
_: AuthRequired,
body: dict[str, Any] = Body(...),
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Save card-mode preferences. The body must be a JSON object shaped
like ``{"version": 1, "surfaces": {"<surface>": "<mode>", }}``.
The surface registry is intentionally open (any string accepted) so
new card surfaces can adopt the toggle without a server migration.
Invalid mode values are rejected to prevent a bad client from
poisoning the stored value."""
if not isinstance(body, dict):
raise HTTPException(status_code=422, detail="Body must be a JSON object")
if not isinstance(body.get("version"), int):
raise HTTPException(
status_code=422,
detail="Body must include a numeric 'version' field",
)
surfaces = body.get("surfaces", {})
if not isinstance(surfaces, dict):
raise HTTPException(
status_code=422,
detail="'surfaces' must be an object mapping surface keys to modes",
)
for key, mode in surfaces.items():
if not isinstance(key, str) or not key:
raise HTTPException(
status_code=422,
detail=f"Surface keys must be non-empty strings (got {key!r})",
)
if mode not in _VALID_CARD_MODES:
raise HTTPException(
status_code=422,
detail=(
f"Surface {key!r} has invalid mode {mode!r}; "
f"expected one of {sorted(_VALID_CARD_MODES)}"
),
)
db.set_setting(_CARD_MODES_KEY, body)
return {"ok": True}
@router.delete(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def delete_card_modes(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Delete saved card-mode preferences — every surface reverts to the
frontend default on next load."""
db.set_setting(_CARD_MODES_KEY, {})
return {"ok": True}
# ---------------------------------------------------------------------------
# Daylight timezone (global)
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/preferences/daylight-timezone",
response_model=DaylightTimezonePreference,
tags=["Preferences"],
)
async def get_daylight_timezone_preference(
_: AuthRequired,
) -> DaylightTimezonePreference:
"""Return the global daylight cycle timezone (empty = system local)."""
return DaylightTimezonePreference(timezone=get_daylight_timezone())
@router.put(
"/api/v1/preferences/daylight-timezone",
response_model=DaylightTimezonePreference,
tags=["Preferences"],
)
async def put_daylight_timezone_preference(
_: AuthRequired,
body: DaylightTimezonePreference,
) -> DaylightTimezonePreference:
"""Persist the global daylight cycle timezone.
The string is stored verbatim clients should send a valid IANA name
(e.g. ``Europe/Berlin``) or an empty string for "use server local".
Daylight streams pick up the new value within ~1 second.
"""
saved = set_daylight_timezone(body.timezone)
logger.info("Daylight timezone updated: %r", saved or "<system local>")
return DaylightTimezonePreference(timezone=saved)
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
@@ -51,6 +51,8 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
], ],
order=preset.order, order=preset.order,
tags=preset.tags, tags=preset.tags,
icon=getattr(preset, "icon", "") or "",
icon_color=getattr(preset, "icon_color", "") or "",
created_at=preset.created_at, created_at=preset.created_at,
updated_at=preset.updated_at, updated_at=preset.updated_at,
) )
@@ -84,6 +86,8 @@ async def create_scene_preset(
targets=targets, targets=targets,
order=store.count(), order=store.count(),
tags=data.tags if data.tags is not None else [], tags=data.tags if data.tags is not None else [],
icon=data.icon or "",
icon_color=data.icon_color or "",
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -182,6 +186,8 @@ async def update_scene_preset(
order=data.order, order=data.order,
targets=new_targets, targets=new_targets,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except ValueError as e: except ValueError as e:
raise HTTPException( raise HTTPException(
+13 -1
View File
@@ -8,6 +8,7 @@ from ledgrab.api.dependencies import (
get_color_strip_store, get_color_strip_store,
get_sync_clock_manager, get_sync_clock_manager,
get_sync_clock_store, get_sync_clock_store,
get_value_source_store,
) )
from ledgrab.api.schemas.sync_clocks import ( from ledgrab.api.schemas.sync_clocks import (
SyncClockCreate, SyncClockCreate,
@@ -18,6 +19,7 @@ from ledgrab.api.schemas.sync_clocks import (
from ledgrab.storage.sync_clock import SyncClock from ledgrab.storage.sync_clock import SyncClock
from ledgrab.storage.sync_clock_store import SyncClockStore from ledgrab.storage.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_store import ColorStripStore from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.core.processing.sync_clock_manager import SyncClockManager from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
@@ -36,6 +38,8 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
speed=rt.speed if rt else clock.speed, speed=rt.speed if rt else clock.speed,
description=clock.description, description=clock.description,
tags=clock.tags, tags=clock.tags,
icon=getattr(clock, "icon", "") or "",
icon_color=getattr(clock, "icon_color", "") or "",
is_running=rt.is_running if rt else True, is_running=rt.is_running if rt else True,
elapsed_time=rt.get_time() if rt else 0.0, elapsed_time=rt.get_time() if rt else 0.0,
created_at=clock.created_at, created_at=clock.created_at,
@@ -73,6 +77,8 @@ async def create_sync_clock(
speed=data.speed, speed=data.speed,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("sync_clock", "created", clock.id) fire_entity_event("sync_clock", "created", clock.id)
return _to_response(clock, manager) return _to_response(clock, manager)
@@ -118,6 +124,8 @@ async def update_sync_clock(
speed=data.speed, speed=data.speed,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
# Hot-update runtime speed # Hot-update runtime speed
if data.speed is not None: if data.speed is not None:
@@ -137,14 +145,18 @@ async def delete_sync_clock(
_auth: AuthRequired, _auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store), store: SyncClockStore = Depends(get_sync_clock_store),
css_store: ColorStripStore = Depends(get_color_strip_store), css_store: ColorStripStore = Depends(get_color_strip_store),
vs_store: ValueSourceStore = Depends(get_value_source_store),
manager: SyncClockManager = Depends(get_sync_clock_manager), manager: SyncClockManager = Depends(get_sync_clock_manager),
): ):
"""Delete a synchronization clock (fails if referenced by CSS sources).""" """Delete a synchronization clock (fails if referenced by CSS or value sources)."""
try: try:
# Check references # Check references
for source in css_store.get_all_sources(): for source in css_store.get_all_sources():
if getattr(source, "clock_id", None) == clock_id: if getattr(source, "clock_id", None) == clock_id:
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'") raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
for vs in vs_store.get_all_sources():
if getattr(vs, "clock_id", None) == clock_id:
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
manager.release_all_for(clock_id) manager.release_all_for(clock_id)
store.delete_clock(clock_id) store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id) fire_entity_event("sync_clock", "deleted", clock_id)
+19
View File
@@ -7,6 +7,7 @@ import asyncio
import platform import platform
import subprocess import subprocess
import sys import sys
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -92,6 +93,13 @@ def _get_cpu_name() -> str | None:
_cpu_name: str | None = _get_cpu_name() _cpu_name: str | None = _get_cpu_name()
# Captured at first import of this module. Process-wide elapsed time is
# the closest the server has to "app start" without instrumenting main.py;
# the system module is imported during router setup, before the server
# accepts requests, so the drift is negligible. Used by /health to expose
# uptime_seconds for the transport-bar ticker.
_APP_START_MONOTONIC: float = time.monotonic()
router = APIRouter() router = APIRouter()
@@ -122,6 +130,7 @@ async def health_check(request: Request):
setup_required=setup_required, setup_required=setup_required,
repo_url=REPO_URL, repo_url=REPO_URL,
donate_url=DONATE_URL, donate_url=DONATE_URL,
uptime_seconds=time.monotonic() - _APP_START_MONOTONIC,
) )
@@ -316,6 +325,15 @@ def get_system_performance(_: AuthRequired):
except Exception as e: except Exception as e:
logger.debug("NVML query failed: %s", e) logger.debug("NVML query failed: %s", e)
# Windows has no user-space CPU die temperature source without a kernel
# driver. We rely on LibreHardwareMonitor / OpenHardwareMonitor publishing
# WMI sensors when the user runs them. When no reading arrives, surface
# that explicitly so the dashboard can show a "here's how to enable it"
# hint instead of silently hiding the card.
cpu_temp_hint_key: str | None = None
if thermals.cpu_temp_c is None and platform.system() == "Windows":
cpu_temp_hint_key = "dashboard.perf.temp.install_lhm"
return PerformanceResponse( return PerformanceResponse(
cpu_name=_cpu_name, cpu_name=_cpu_name,
cpu_percent=metrics.cpu_percent(), cpu_percent=metrics.cpu_percent(),
@@ -328,6 +346,7 @@ def get_system_performance(_: AuthRequired):
battery_percent=thermals.battery_percent, battery_percent=thermals.battery_percent,
battery_temp_c=thermals.battery_temp_c, battery_temp_c=thermals.battery_temp_c,
cpu_temp_c=thermals.cpu_temp_c, cpu_temp_c=thermals.cpu_temp_c,
cpu_temp_hint_key=cpu_temp_hint_key,
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
@@ -19,6 +19,9 @@ from ledgrab.api.schemas.system import (
LogLevelResponse, LogLevelResponse,
MQTTSettingsRequest, MQTTSettingsRequest,
MQTTSettingsResponse, MQTTSettingsResponse,
ShutdownAction,
ShutdownActionRequest,
ShutdownActionResponse,
) )
from ledgrab.config import get_config from ledgrab.config import get_config
from ledgrab.storage.database import Database from ledgrab.storage.database import Database
@@ -150,6 +153,55 @@ async def update_external_url(
return ExternalUrlResponse(external_url=url) return ExternalUrlResponse(external_url=url)
# ---------------------------------------------------------------------------
# Shutdown action setting
# ---------------------------------------------------------------------------
_VALID_SHUTDOWN_ACTIONS: tuple[str, ...] = ("stop_targets", "nothing")
_DEFAULT_SHUTDOWN_ACTION: ShutdownAction = "stop_targets"
def load_shutdown_action(db: Database | None = None) -> ShutdownAction:
"""Load the configured shutdown action. Returns the default if unset or corrupt."""
if db is None:
from ledgrab.api.dependencies import get_database
db = get_database()
data = db.get_setting("shutdown_action")
if not data:
return _DEFAULT_SHUTDOWN_ACTION
value = data.get("action")
if value in _VALID_SHUTDOWN_ACTIONS:
return value # type: ignore[return-value]
return _DEFAULT_SHUTDOWN_ACTION
@router.get(
"/api/v1/system/shutdown-action",
response_model=ShutdownActionResponse,
tags=["System"],
)
async def get_shutdown_action(_: AuthRequired, db: Database = Depends(get_database)):
"""Get the configured server shutdown action."""
return ShutdownActionResponse(action=load_shutdown_action(db))
@router.put(
"/api/v1/system/shutdown-action",
response_model=ShutdownActionResponse,
tags=["System"],
)
async def update_shutdown_action(
_: AuthRequired,
body: ShutdownActionRequest,
db: Database = Depends(get_database),
):
"""Set what happens to LED targets when the server shuts down."""
db.set_setting("shutdown_action", {"action": body.action})
logger.info("Shutdown action updated: %s", body.action)
return ShutdownActionResponse(action=body.action)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Live log viewer WebSocket # Live log viewer WebSocket
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+24 -43
View File
@@ -45,6 +45,21 @@ logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
def _template_to_response(t) -> TemplateResponse:
return TemplateResponse(
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
# ===== CAPTURE TEMPLATE ENDPOINTS ===== # ===== CAPTURE TEMPLATE ENDPOINTS =====
@@ -57,19 +72,7 @@ async def list_templates(
try: try:
templates = template_store.get_all_templates() templates = template_store.get_all_templates()
template_responses = [ template_responses = [_template_to_response(t) for t in templates]
TemplateResponse(
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
)
for t in templates
]
return TemplateListResponse( return TemplateListResponse(
templates=template_responses, templates=template_responses,
@@ -100,19 +103,12 @@ async def create_template(
engine_config=template_data.engine_config, engine_config=template_data.engine_config,
description=template_data.description, description=template_data.description,
tags=template_data.tags, tags=template_data.tags,
icon=template_data.icon,
icon_color=template_data.icon_color,
) )
fire_entity_event("capture_template", "created", template.id) fire_entity_event("capture_template", "created", template.id)
return TemplateResponse( return _template_to_response(template)
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -138,16 +134,7 @@ async def get_template(
except ValueError: except ValueError:
raise HTTPException(status_code=404, detail=f"Template {template_id} not found") raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
return TemplateResponse( return _template_to_response(template)
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
@router.put( @router.put(
@@ -168,19 +155,12 @@ async def update_template(
engine_config=update_data.engine_config, engine_config=update_data.engine_config,
description=update_data.description, description=update_data.description,
tags=update_data.tags, tags=update_data.tags,
icon=update_data.icon,
icon_color=update_data.icon_color,
) )
fire_entity_event("capture_template", "updated", template_id) fire_entity_event("capture_template", "updated", template_id)
return TemplateResponse( return _template_to_response(template)
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -255,6 +235,7 @@ async def list_engines(_auth: AuthRequired):
type=engine_type, type=engine_type,
name=engine_type.upper(), name=engine_type.upper(),
default_config=engine_class.get_default_config(), default_config=engine_class.get_default_config(),
config_choices=engine_class.get_config_choices(),
available=(engine_type in available_set), available=(engine_type in available_set),
has_own_displays=getattr(engine_class, "HAS_OWN_DISPLAYS", False), has_own_displays=getattr(engine_class, "HAS_OWN_DISPLAYS", False),
) )
@@ -64,6 +64,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
value=s.value, value=s.value,
@@ -73,6 +75,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
waveform=s.waveform, waveform=s.waveform,
@@ -85,6 +89,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
audio_source_id=s.audio_source_id, audio_source_id=s.audio_source_id,
@@ -100,11 +106,14 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
speed=s.speed, speed=s.speed,
use_real_time=s.use_real_time, use_real_time=s.use_real_time,
latitude=s.latitude, latitude=s.latitude,
longitude=s.longitude,
min_value=s.min_value, min_value=s.min_value,
max_value=s.max_value, max_value=s.max_value,
), ),
@@ -113,6 +122,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
color=list(s.color), color=list(s.color),
@@ -122,17 +133,22 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
colors=[list(c) for c in s.colors], colors=[list(c) for c in s.colors],
speed=s.speed, speed=s.speed,
easing=s.easing, easing=s.easing,
clock_id=s.clock_id,
), ),
AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse( AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse(
id=s.id, id=s.id,
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
schedule=s.schedule, schedule=s.schedule,
@@ -142,6 +158,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
ha_source_id=s.ha_source_id, ha_source_id=s.ha_source_id,
@@ -156,6 +174,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
value_source_id=s.value_source_id, value_source_id=s.value_source_id,
@@ -167,6 +187,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
color_strip_source_id=s.color_strip_source_id, color_strip_source_id=s.color_strip_source_id,
@@ -178,6 +200,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
metric=s.metric, metric=s.metric,
@@ -202,6 +226,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name, name=source.name,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
picture_source_id=source.picture_source_id, picture_source_id=source.picture_source_id,
@@ -216,6 +242,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name, name=source.name,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
schedule=source.schedule, schedule=source.schedule,
@@ -231,6 +259,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name, name=source.name,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
value=getattr(source, "value", 1.0), value=getattr(source, "value", 1.0),
@@ -39,6 +39,8 @@ def _to_response(source: WeatherSource) -> WeatherSourceResponse:
update_interval=d["update_interval"], update_interval=d["update_interval"],
description=d.get("description"), description=d.get("description"),
tags=d.get("tags", []), tags=d.get("tags", []),
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
) )
@@ -79,6 +81,8 @@ async def create_weather_source(
update_interval=data.update_interval, update_interval=data.update_interval,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -125,6 +129,8 @@ async def update_weather_source(
update_interval=data.update_interval, update_interval=data.update_interval,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except EntityNotFoundError: except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found") raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
+20
View File
@@ -12,6 +12,16 @@ class AssetUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name") name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name")
description: Optional[str] = Field(None, max_length=500, description="Optional description") description: Optional[str] = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags") tags: Optional[List[str]] = Field(None, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AssetResponse(BaseModel): class AssetResponse(BaseModel):
@@ -26,6 +36,16 @@ class AssetResponse(BaseModel):
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset") prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -17,6 +17,16 @@ class AudioProcessingTemplateCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioProcessingTemplateUpdate(BaseModel): class AudioProcessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class AudioProcessingTemplateUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioProcessingTemplateResponse(BaseModel): class AudioProcessingTemplateResponse(BaseModel):
@@ -42,6 +62,16 @@ class AudioProcessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioProcessingTemplateListResponse(BaseModel): class AudioProcessingTemplateListResponse(BaseModel):
@@ -19,6 +19,16 @@ class _AudioSourceResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class CaptureAudioSourceResponse(_AudioSourceResponseBase): class CaptureAudioSourceResponse(_AudioSourceResponseBase):
@@ -53,6 +63,16 @@ class _AudioSourceCreateBase(BaseModel):
name: str = Field(description="Source name", min_length=1, max_length=100) name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class CaptureAudioSourceCreate(_AudioSourceCreateBase): class CaptureAudioSourceCreate(_AudioSourceCreateBase):
@@ -87,6 +107,16 @@ class _AudioSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class CaptureAudioSourceUpdate(_AudioSourceUpdateBase): class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
@@ -16,6 +16,16 @@ class AudioTemplateCreate(BaseModel):
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration") engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioTemplateUpdate(BaseModel): class AudioTemplateUpdate(BaseModel):
@@ -26,6 +36,16 @@ class AudioTemplateUpdate(BaseModel):
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioTemplateResponse(BaseModel): class AudioTemplateResponse(BaseModel):
@@ -39,6 +59,16 @@ class AudioTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioTemplateListResponse(BaseModel): class AudioTemplateListResponse(BaseModel):
@@ -67,6 +67,16 @@ class AutomationCreate(BaseModel):
None, description="Scene preset for fallback deactivation" None, description="Scene preset for fallback deactivation"
) )
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AutomationUpdate(BaseModel): class AutomationUpdate(BaseModel):
@@ -84,6 +94,16 @@ class AutomationUpdate(BaseModel):
None, description="Scene preset for fallback deactivation" None, description="Scene preset for fallback deactivation"
) )
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AutomationResponse(BaseModel): class AutomationResponse(BaseModel):
@@ -108,6 +128,16 @@ class AutomationResponse(BaseModel):
last_deactivated_at: Optional[datetime] = Field( last_deactivated_at: Optional[datetime] = Field(
None, description="Last time this automation was deactivated" None, description="Last time this automation was deactivated"
) )
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -17,6 +17,16 @@ class ColorStripProcessingTemplateCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ColorStripProcessingTemplateUpdate(BaseModel): class ColorStripProcessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class ColorStripProcessingTemplateUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ColorStripProcessingTemplateResponse(BaseModel): class ColorStripProcessingTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class ColorStripProcessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ColorStripProcessingTemplateListResponse(BaseModel): class ColorStripProcessingTemplateListResponse(BaseModel):
@@ -28,7 +28,7 @@ class AnimationConfig(BaseModel):
"""Procedural animation configuration for static/gradient color strip sources.""" """Procedural animation configuration for static/gradient color strip sources."""
enabled: bool = True enabled: bool = True
type: str = "breathing" # breathing | color_cycle | gradient_shift | wave type: str = "breathing" # breathing | gradient_shift | wave
speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1-10.0)") speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1-10.0)")
@@ -95,6 +95,16 @@ class _CSSResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PictureCSSResponse(_CSSResponseBase): class PictureCSSResponse(_CSSResponseBase):
@@ -126,11 +136,6 @@ class GradientCSSResponse(_CSSResponseBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSResponse(_CSSResponseBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: List[List[int]] = Field(description="List of [R,G,B] colors to cycle")
class EffectCSSResponse(_CSSResponseBase): class EffectCSSResponse(_CSSResponseBase):
source_type: Literal["effect"] = "effect" source_type: Literal["effect"] = "effect"
effect_type: str = Field(description="Effect algorithm") effect_type: str = Field(description="Effect algorithm")
@@ -241,7 +246,6 @@ ColorStripSourceResponse = Annotated[
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")], Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
Annotated[StaticCSSResponse, Tag("static")], Annotated[StaticCSSResponse, Tag("static")],
Annotated[GradientCSSResponse, Tag("gradient")], Annotated[GradientCSSResponse, Tag("gradient")],
Annotated[ColorCycleCSSResponse, Tag("color_cycle")],
Annotated[EffectCSSResponse, Tag("effect")], Annotated[EffectCSSResponse, Tag("effect")],
Annotated[CompositeCSSResponse, Tag("composite")], Annotated[CompositeCSSResponse, Tag("composite")],
Annotated[MappedCSSResponse, Tag("mapped")], Annotated[MappedCSSResponse, Tag("mapped")],
@@ -272,6 +276,16 @@ class _CSSCreateBase(BaseModel):
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID") clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PictureCSSCreate(_CSSCreateBase): class PictureCSSCreate(_CSSCreateBase):
@@ -303,11 +317,6 @@ class GradientCSSCreate(_CSSCreateBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSCreate(_CSSCreateBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
class EffectCSSCreate(_CSSCreateBase): class EffectCSSCreate(_CSSCreateBase):
source_type: Literal["effect"] = "effect" source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm") effect_type: Optional[str] = Field(None, description="Effect algorithm")
@@ -431,7 +440,6 @@ ColorStripSourceCreate = Annotated[
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")], Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
Annotated[StaticCSSCreate, Tag("static")], Annotated[StaticCSSCreate, Tag("static")],
Annotated[GradientCSSCreate, Tag("gradient")], Annotated[GradientCSSCreate, Tag("gradient")],
Annotated[ColorCycleCSSCreate, Tag("color_cycle")],
Annotated[EffectCSSCreate, Tag("effect")], Annotated[EffectCSSCreate, Tag("effect")],
Annotated[CompositeCSSCreate, Tag("composite")], Annotated[CompositeCSSCreate, Tag("composite")],
Annotated[MappedCSSCreate, Tag("mapped")], Annotated[MappedCSSCreate, Tag("mapped")],
@@ -462,6 +470,16 @@ class _CSSUpdateBase(BaseModel):
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID") clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PictureCSSUpdate(_CSSUpdateBase): class PictureCSSUpdate(_CSSUpdateBase):
@@ -493,11 +511,6 @@ class GradientCSSUpdate(_CSSUpdateBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSUpdate(_CSSUpdateBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
class EffectCSSUpdate(_CSSUpdateBase): class EffectCSSUpdate(_CSSUpdateBase):
source_type: Literal["effect"] = "effect" source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm") effect_type: Optional[str] = Field(None, description="Effect algorithm")
@@ -619,7 +632,6 @@ ColorStripSourceUpdate = Annotated[
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")], Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
Annotated[StaticCSSUpdate, Tag("static")], Annotated[StaticCSSUpdate, Tag("static")],
Annotated[GradientCSSUpdate, Tag("gradient")], Annotated[GradientCSSUpdate, Tag("gradient")],
Annotated[ColorCycleCSSUpdate, Tag("color_cycle")],
Annotated[EffectCSSUpdate, Tag("effect")], Annotated[EffectCSSUpdate, Tag("effect")],
Annotated[CompositeCSSUpdate, Tag("composite")], Annotated[CompositeCSSUpdate, Tag("composite")],
Annotated[MappedCSSUpdate, Tag("mapped")], Annotated[MappedCSSUpdate, Tag("mapped")],
@@ -655,10 +667,22 @@ class ColorStripSourceListResponse(BaseModel):
class SegmentPayload(BaseModel): class SegmentPayload(BaseModel):
"""A single segment for segment-based LED color updates.""" """A single segment for segment-based LED color updates.
start: int = Field(ge=0, description="Starting LED index") ``start`` and ``length`` are optional: when omitted, the segment defaults
length: int = Field(ge=1, description="Number of LEDs in segment") to ``start=0`` and ``length=led_count - start`` (i.e. the rest of the
strip from ``start``). Sending a single segment with only ``mode`` and
``color`` therefore fills the entire strip.
"""
start: Optional[int] = Field(
None, ge=0, description="Starting LED index (default 0 = beginning of strip)"
)
length: Optional[int] = Field(
None,
ge=1,
description="Number of LEDs in segment (default = led_count - start)",
)
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode") mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]") color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field( colors: Optional[List[List[int]]] = Field(
+24
View File
@@ -86,6 +86,17 @@ class DeviceCreate(BaseModel):
None, None,
description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)", description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)",
) )
# Custom card icon (frontend display only)
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the card's channel accent.",
)
class DeviceUpdate(BaseModel): class DeviceUpdate(BaseModel):
@@ -140,6 +151,17 @@ class DeviceUpdate(BaseModel):
None, description="Ordered list of child device IDs (for group device type)" None, description="Ordered list of child device IDs (for group device type)"
) )
group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent") group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent")
# Custom card icon
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class CalibrationLineSchema(BaseModel): class CalibrationLineSchema(BaseModel):
@@ -295,6 +317,8 @@ class DeviceResponse(BaseModel):
default_factory=list, description="Ordered list of child device IDs (for group device type)" default_factory=list, description="Ordered list of child device IDs (for group device type)"
) )
group_mode: str = Field(default="sequence", description="Group mode: sequence or independent") group_mode: str = Field(default="sequence", description="Group mode: sequence or independent")
icon: str = Field(default="", description="Icon id from the curated icon library")
icon_color: str = Field(default="", description="Optional CSS color override for the icon")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -42,6 +42,16 @@ class GameIntegrationCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Integration description", max_length=500) description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class GameIntegrationUpdate(BaseModel): class GameIntegrationUpdate(BaseModel):
@@ -56,6 +66,16 @@ class GameIntegrationUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Integration description", max_length=500) description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: Optional[List[str]] = Field(None, description="User-defined tags") tags: Optional[List[str]] = Field(None, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class GameIntegrationResponse(BaseModel): class GameIntegrationResponse(BaseModel):
@@ -71,6 +91,16 @@ class GameIntegrationResponse(BaseModel):
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Integration description") description: Optional[str] = Field(None, description="Integration description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
class GameIntegrationListResponse(BaseModel): class GameIntegrationListResponse(BaseModel):
@@ -20,6 +20,16 @@ class GradientCreate(BaseModel):
stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2) stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class GradientUpdate(BaseModel): class GradientUpdate(BaseModel):
@@ -29,6 +39,16 @@ class GradientUpdate(BaseModel):
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2) stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class GradientResponse(BaseModel): class GradientResponse(BaseModel):
@@ -42,6 +62,16 @@ class GradientResponse(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class GradientListResponse(BaseModel): class GradientListResponse(BaseModel):
@@ -18,6 +18,16 @@ class HomeAssistantSourceCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class HomeAssistantSourceUpdate(BaseModel): class HomeAssistantSourceUpdate(BaseModel):
@@ -30,6 +40,16 @@ class HomeAssistantSourceUpdate(BaseModel):
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns") entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class HomeAssistantSourceResponse(BaseModel): class HomeAssistantSourceResponse(BaseModel):
@@ -44,6 +64,16 @@ class HomeAssistantSourceResponse(BaseModel):
entity_count: int = Field(default=0, description="Number of cached entities") entity_count: int = Field(default=0, description="Number of cached entities")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
token: Optional[str] = Field( token: Optional[str] = Field(
@@ -94,6 +124,7 @@ class HomeAssistantConnectionStatus(BaseModel):
name: str name: str
connected: bool connected: bool
entity_count: int entity_count: int
host: str = ""
class HomeAssistantStatusResponse(BaseModel): class HomeAssistantStatusResponse(BaseModel):
+30
View File
@@ -18,6 +18,16 @@ class MQTTSourceCreate(BaseModel):
base_topic: str = Field(default="ledgrab", description="Base topic prefix") base_topic: str = Field(default="ledgrab", description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class MQTTSourceUpdate(BaseModel): class MQTTSourceUpdate(BaseModel):
@@ -32,6 +42,16 @@ class MQTTSourceUpdate(BaseModel):
base_topic: Optional[str] = Field(None, description="Base topic prefix") base_topic: Optional[str] = Field(None, description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class MQTTSourceResponse(BaseModel): class MQTTSourceResponse(BaseModel):
@@ -48,6 +68,16 @@ class MQTTSourceResponse(BaseModel):
connected: bool = Field(default=False, description="Whether the broker connection is active") connected: bool = Field(default=False, description="Whether the broker connection is active")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -55,6 +55,8 @@ class _OutputTargetResponseBase(BaseModel):
name: str = Field(description="Target name") name: str = Field(description="Target name")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: str = Field(default="", description="Custom icon id from the curated icon library")
icon_color: str = Field(default="", description="Optional CSS color override for the icon")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -81,7 +83,19 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
class HALightOutputTargetResponse(_OutputTargetResponseBase): class HALightOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["ha_light"] = "ha_light" target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID") ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") source_kind: Literal["css", "color_vs"] = Field(
default="css",
description="Colour source kind: 'css' (per-mapping LED segments) or "
"'color_vs' (single colour value source applied to all entities).",
)
color_strip_source_id: str = Field(
default="", description="Color strip source ID (used when source_kind='css')"
)
color_value_source_id: str = Field(
default="",
description="Colour value source ID (used when source_kind='color_vs'); "
"must reference a value source whose return_type='color'.",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings" None, description="LED-to-light mappings"
@@ -98,6 +112,11 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)" default=0, description="Min brightness threshold (bindable, 0=disabled)"
) )
stop_action: Literal["none", "turn_off", "restore"] = Field(
default="none",
description="What to do with mapped lights when the target stops: "
"'none' (leave as-is), 'turn_off', or 'restore' (revert to state captured at start).",
)
OutputTargetResponse = Annotated[ OutputTargetResponse = Annotated[
@@ -119,6 +138,12 @@ class _OutputTargetCreateBase(BaseModel):
name: str = Field(description="Target name", min_length=1, max_length=100) name: str = Field(description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None, max_length=64, description="Custom icon id from the curated icon library"
)
icon_color: Optional[str] = Field(
None, max_length=32, description="Optional CSS color override for the icon"
)
class LedOutputTargetCreate(_OutputTargetCreateBase): class LedOutputTargetCreate(_OutputTargetCreateBase):
@@ -160,7 +185,18 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
class HALightOutputTargetCreate(_OutputTargetCreateBase): class HALightOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["ha_light"] = "ha_light" target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID") ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") source_kind: Literal["css", "color_vs"] = Field(
default="css",
description="Colour source kind: 'css' (per-mapping LED segments) or "
"'color_vs' (single colour value source applied to all entities).",
)
color_strip_source_id: str = Field(
default="", description="Color strip source ID (used when source_kind='css')"
)
color_value_source_id: str = Field(
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field( brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)" default=1.0, description="Brightness (bindable)"
) )
@@ -180,6 +216,10 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
default=0, default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off", description="Min brightness threshold (bindable, 0=disabled); below this -> off",
) )
stop_action: Literal["none", "turn_off", "restore"] = Field(
default="none",
description="Finalization on stop: 'none', 'turn_off', or 'restore'.",
)
OutputTargetCreate = Annotated[ OutputTargetCreate = Annotated[
@@ -201,6 +241,16 @@ class _OutputTargetUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100) name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Custom icon id; pass empty string to clear and inherit from device.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon; empty string clears.",
)
class LedOutputTargetUpdate(_OutputTargetUpdateBase): class LedOutputTargetUpdate(_OutputTargetUpdateBase):
@@ -229,7 +279,15 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase):
class HALightOutputTargetUpdate(_OutputTargetUpdateBase): class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["ha_light"] = "ha_light" target_type: Literal["ha_light"] = "ha_light"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID") ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
source_kind: Optional[Literal["css", "color_vs"]] = Field(
None,
description="Colour source kind: 'css' or 'color_vs'.",
)
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
color_value_source_id: Optional[str] = Field(
None,
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings" None, description="LED-to-light mappings"
@@ -246,6 +304,9 @@ class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: Optional[BindableFloatInput] = Field(
None, description="Min brightness threshold (bindable, 0=disabled)" None, description="Min brightness threshold (bindable, 0=disabled)"
) )
stop_action: Optional[Literal["none", "turn_off", "restore"]] = Field(
None, description="Finalization on stop: 'none', 'turn_off', or 'restore'."
)
OutputTargetUpdate = Annotated[ OutputTargetUpdate = Annotated[
@@ -280,6 +341,9 @@ class TargetProcessingState(BaseModel):
None, description="Potential FPS (processing speed without throttle)" None, description="Potential FPS (processing speed without throttle)"
) )
fps_target: Optional[int] = Field(None, description="Target FPS") fps_target: Optional[int] = Field(None, description="Target FPS")
fps_capture: Optional[int] = Field(
None, description="Configured capture-side FPS for the underlying color strip stream"
)
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)") frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: Optional[int] = Field( frames_keepalive: Optional[int] = Field(
None, description="Keepalive frames sent during standby" None, description="Keepalive frames sent during standby"
@@ -17,6 +17,16 @@ class PatternTemplateCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PatternTemplateUpdate(BaseModel): class PatternTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class PatternTemplateUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PatternTemplateResponse(BaseModel): class PatternTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class PatternTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PatternTemplateListResponse(BaseModel): class PatternTemplateListResponse(BaseModel):
@@ -19,6 +19,16 @@ class _PictureSourceResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class RawPictureSourceResponse(_PictureSourceResponseBase): class RawPictureSourceResponse(_PictureSourceResponseBase):
@@ -72,6 +82,16 @@ class _PictureSourceCreateBase(BaseModel):
name: str = Field(description="Stream name", min_length=1, max_length=100) name: str = Field(description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500) description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class RawPictureSourceCreate(_PictureSourceCreateBase): class RawPictureSourceCreate(_PictureSourceCreateBase):
@@ -127,6 +147,16 @@ class _PictureSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100) name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500) description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class RawPictureSourceUpdate(_PictureSourceUpdateBase): class RawPictureSourceUpdate(_PictureSourceUpdateBase):
@@ -17,6 +17,16 @@ class PostprocessingTemplateCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PostprocessingTemplateUpdate(BaseModel): class PostprocessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class PostprocessingTemplateUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PostprocessingTemplateResponse(BaseModel): class PostprocessingTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class PostprocessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PostprocessingTemplateListResponse(BaseModel): class PostprocessingTemplateListResponse(BaseModel):
@@ -0,0 +1,66 @@
"""User-preference schemas (notifications, future per-user settings)."""
from typing import Literal
from pydantic import BaseModel, Field
NotificationChannel = Literal["none", "snack", "os", "both"]
class NotificationChannelMatrix(BaseModel):
"""Channel selection per device-event type."""
device_online: NotificationChannel = Field(
default="snack",
description="Configured device transitioned from offline to online",
)
device_offline: NotificationChannel = Field(
default="both",
description="Configured device went offline (urgent — likely user wants OS toast)",
)
device_discovered: NotificationChannel = Field(
default="snack",
description="A new WLED/serial device appeared on the LAN/USB",
)
device_lost: NotificationChannel = Field(
default="none",
description=(
"Previously discovered (but never configured) device disappeared. "
"Default off — usually noise unless the user is actively pairing."
),
)
class NotificationPreferences(BaseModel):
"""User-level notification preferences."""
channels: NotificationChannelMatrix = Field(
default_factory=NotificationChannelMatrix,
description="Per-event-type channel selection",
)
background_discovery_enabled: bool = Field(
default=True,
description=(
"Run the continuous mDNS browser + serial-port poller while the server "
"is up. Required for device_discovered/device_lost notifications. "
"Disable to silence all discovery-driven events at the source."
),
)
startup_grace_sec: int = Field(
default=10,
ge=0,
le=300,
description=(
"Seconds after each event-WS connect during which device_offline "
"notifications are suppressed (devices boot at different speeds)."
),
)
flap_debounce_sec: int = Field(
default=5,
ge=0,
le=60,
description=(
"A device must hold a new state for at least this many seconds before "
"the corresponding notification is fired. Filters out single-packet drops."
),
)
@@ -23,6 +23,16 @@ class ScenePresetCreate(BaseModel):
None, description="Target IDs to capture (all if omitted)" None, description="Target IDs to capture (all if omitted)"
) )
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ScenePresetUpdate(BaseModel): class ScenePresetUpdate(BaseModel):
@@ -36,6 +46,16 @@ class ScenePresetUpdate(BaseModel):
description="Update target list: keep state for existing, capture fresh for new, drop removed", description="Update target list: keep state for existing, capture fresh for new, drop removed",
) )
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ScenePresetResponse(BaseModel): class ScenePresetResponse(BaseModel):
@@ -47,6 +67,16 @@ class ScenePresetResponse(BaseModel):
targets: List[TargetSnapshotSchema] targets: List[TargetSnapshotSchema]
order: int order: int
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -13,6 +13,16 @@ class SyncClockCreate(BaseModel):
speed: float = Field(default=1.0, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0) speed: float = Field(default=1.0, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class SyncClockUpdate(BaseModel): class SyncClockUpdate(BaseModel):
@@ -22,6 +32,16 @@ class SyncClockUpdate(BaseModel):
speed: Optional[float] = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0) speed: Optional[float] = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class SyncClockResponse(BaseModel): class SyncClockResponse(BaseModel):
@@ -32,6 +52,16 @@ class SyncClockResponse(BaseModel):
speed: float = Field(description="Speed multiplier") speed: float = Field(description="Speed multiplier")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
is_running: bool = Field(True, description="Whether clock is currently running") is_running: bool = Field(True, description="Whether clock is currently running")
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds") elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
+39
View File
@@ -26,6 +26,10 @@ class HealthResponse(BaseModel):
) )
repo_url: str = Field(default="", description="Source code repository URL") repo_url: str = Field(default="", description="Source code repository URL")
donate_url: str = Field(default="", description="Donation page URL") donate_url: str = Field(default="", description="Donation page URL")
uptime_seconds: float = Field(
default=0.0,
description="Process uptime in seconds since the server started.",
)
class VersionResponse(BaseModel): class VersionResponse(BaseModel):
@@ -98,6 +102,15 @@ class PerformanceResponse(BaseModel):
default=None, default=None,
description="Hottest CPU/SoC thermal zone in °C (null if unsupported)", description="Hottest CPU/SoC thermal zone in °C (null if unsupported)",
) )
cpu_temp_hint_key: str | None = Field(
default=None,
description=(
"i18n key for an explainer shown in the Temperature card when "
"cpu_temp_c is null and the platform has a known workaround "
"(e.g. install LibreHardwareMonitor on Windows). Null on "
"platforms where unavailable simply means 'not reported'."
),
)
timestamp: datetime = Field(description="Measurement timestamp") timestamp: datetime = Field(description="Measurement timestamp")
@@ -191,6 +204,32 @@ class ExternalUrlRequest(BaseModel):
external_url: str = Field(default="", description="External base URL. Empty string to clear.") external_url: str = Field(default="", description="External base URL. Empty string to clear.")
# ─── Shutdown action schemas ───────────────────────────────────
ShutdownAction = Literal["stop_targets", "nothing"]
class ShutdownActionResponse(BaseModel):
"""Current server shutdown action setting."""
action: ShutdownAction = Field(
description=(
"What happens to LED targets when the server shuts down. "
"`stop_targets` runs the normal stop sequence (per-device "
"auto_shutdown decides whether prior state is restored). "
"`nothing` skips device-touching teardown — lights freeze on "
"their last frame regardless of per-device auto_shutdown."
),
)
class ShutdownActionRequest(BaseModel):
"""Update the server shutdown action setting."""
action: ShutdownAction = Field(description="New shutdown action.")
# ─── Log level schemas ───────────────────────────────────────── # ─── Log level schemas ─────────────────────────────────────────
@@ -14,6 +14,16 @@ class TemplateCreate(BaseModel):
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration") engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class TemplateUpdate(BaseModel): class TemplateUpdate(BaseModel):
@@ -24,6 +34,16 @@ class TemplateUpdate(BaseModel):
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class TemplateResponse(BaseModel): class TemplateResponse(BaseModel):
@@ -37,6 +57,12 @@ class TemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None, max_length=64, description="Icon id from the curated icon library."
)
icon_color: Optional[str] = Field(
None, max_length=32, description="Optional CSS color override for the icon."
)
class TemplateListResponse(BaseModel): class TemplateListResponse(BaseModel):
@@ -52,6 +78,10 @@ class EngineInfo(BaseModel):
type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')") type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')")
name: str = Field(description="Human-readable engine name") name: str = Field(description="Human-readable engine name")
default_config: Dict = Field(description="Default configuration for this engine") default_config: Dict = Field(description="Default configuration for this engine")
config_choices: Dict[str, List[str]] = Field(
default_factory=dict,
description="Allowed values for enum-like config keys on this platform",
)
available: bool = Field(description="Whether engine is available on this system") available: bool = Field(description="Whether engine is available on this system")
has_own_displays: bool = Field( has_own_displays: bool = Field(
default=False, description="Engine has its own device list (not desktop monitors)" default=False, description="Engine has its own device list (not desktop monitors)"
+9
View File
@@ -3,6 +3,14 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class UpdateAssetInfo(BaseModel):
"""A downloadable asset attached to a release (e.g. an installer)."""
name: str
size: int
download_url: str
class UpdateReleaseInfo(BaseModel): class UpdateReleaseInfo(BaseModel):
version: str version: str
tag: str tag: str
@@ -10,6 +18,7 @@ class UpdateReleaseInfo(BaseModel):
body: str body: str
prerelease: bool prerelease: bool
published_at: str published_at: str
assets: list[UpdateAssetInfo] = Field(default_factory=list)
class UpdateStatusResponse(BaseModel): class UpdateStatusResponse(BaseModel):
@@ -17,6 +17,16 @@ class _ValueSourceResponseBase(BaseModel):
name: str = Field(description="Source name") name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -73,6 +83,7 @@ class DaylightValueSourceResponse(_ValueSourceResponseBase):
speed: float = Field(description="Simulation speed multiplier") speed: float = Field(description="Simulation speed multiplier")
use_real_time: bool = Field(description="Use wall-clock time") use_real_time: bool = Field(description="Use wall-clock time")
latitude: float = Field(description="Geographic latitude") latitude: float = Field(description="Geographic latitude")
longitude: float = Field(description="Geographic longitude")
min_value: float = Field(description="Minimum output") min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output") max_value: float = Field(description="Maximum output")
@@ -87,8 +98,11 @@ class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["animated_color"] = "animated_color" source_type: Literal["animated_color"] = "animated_color"
return_type: Literal["color"] = "color" return_type: Literal["color"] = "color"
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]") colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
speed: float = Field(description="Cycles per minute") speed: float = Field(description="Cycles per minute (ignored when clock_id is set)")
easing: str = Field(description="Color easing: linear|step") easing: str = Field(description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine")
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID for shared timing (overrides speed)"
)
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase): class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
@@ -167,6 +181,16 @@ class _ValueSourceCreateBase(BaseModel):
name: str = Field(description="Source name", min_length=1, max_length=100) name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class StaticValueSourceCreate(_ValueSourceCreateBase): class StaticValueSourceCreate(_ValueSourceCreateBase):
@@ -215,6 +239,9 @@ class DaylightValueSourceCreate(_ValueSourceCreateBase):
speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0) speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0)
use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation") use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation")
latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0) latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float = Field(
0.0, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0
)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0) min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0) max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
@@ -234,7 +261,12 @@ class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
description="Color list [[R,G,B], ...]", description="Color list [[R,G,B], ...]",
) )
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0) speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
easing: str = Field("linear", description="Color easing: linear|step") easing: str = Field(
"linear", description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
)
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID (overrides speed when set)"
)
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase): class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
@@ -308,6 +340,16 @@ class _ValueSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class StaticValueSourceUpdate(_ValueSourceUpdateBase): class StaticValueSourceUpdate(_ValueSourceUpdateBase):
@@ -356,6 +398,9 @@ class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0) speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time") use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0) latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
None, description="Geographic longitude", ge=-180.0, le=180.0
)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0) min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0) max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
@@ -369,7 +414,12 @@ class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated_color"] = "animated_color" source_type: Literal["animated_color"] = "animated_color"
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]") colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0) speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: Optional[str] = Field(None, description="Color easing: linear|step") easing: Optional[str] = Field(
None, description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
)
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID (empty string clears, null leaves unchanged)"
)
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase): class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
@@ -25,6 +25,16 @@ class WeatherSourceCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class WeatherSourceUpdate(BaseModel): class WeatherSourceUpdate(BaseModel):
@@ -44,6 +54,16 @@ class WeatherSourceUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class WeatherSourceResponse(BaseModel): class WeatherSourceResponse(BaseModel):
@@ -60,6 +80,16 @@ class WeatherSourceResponse(BaseModel):
update_interval: int = Field(description="API poll interval in seconds") update_interval: int = Field(description="API poll interval in seconds")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
+3 -3
View File
@@ -100,7 +100,7 @@ class MQTTConfig(BaseSettings):
base_topic: str = "ledgrab" base_topic: str = "ledgrab"
def resolve_mqtt_password(cfg: "Config | None" = None) -> str: def resolve_mqtt_password(config: "Config | None" = None) -> str:
"""Return the plaintext MQTT password. """Return the plaintext MQTT password.
Accepts either an ``ENC:v1:`` envelope or legacy plaintext. If Accepts either an ``ENC:v1:`` envelope or legacy plaintext. If
@@ -110,8 +110,8 @@ def resolve_mqtt_password(cfg: "Config | None" = None) -> str:
from ledgrab.utils import get_logger, secret_box from ledgrab.utils import get_logger, secret_box
log = get_logger(__name__) log = get_logger(__name__)
cfg = cfg or get_config() config = config or get_config()
pw = cfg.mqtt.password or "" pw = config.mqtt.password or ""
if not pw: if not pw:
return "" return ""
if secret_box.is_encrypted(pw): if secret_box.is_encrypted(pw):
+172 -63
View File
@@ -233,6 +233,90 @@ class CalibrationConfig:
return None return None
def _build_skip_buffers(mapper, calibration: CalibrationConfig, total_leds: int) -> None:
"""Pre-compute Phase 3 skip-LED resampling indices and scratch buffers.
Phase 3 takes the full ``total_leds`` strip and resamples it into
``active_count = total_leds - skip_start - skip_end`` LEDs using linear
interpolation. We precompute floor/ceil source indices and fractional
weights once so per-frame work becomes a couple of ``np.take`` +
in-place arithmetic ops with no allocations.
Attaches all skip-related state to ``mapper`` directly to keep the
storage layout consistent between PixelMapper and AdvancedPixelMapper.
"""
skip_start = calibration.skip_leds_start
skip_end = calibration.skip_leds_end
mapper._skip_start = skip_start
mapper._skip_end = skip_end
active_count = max(0, total_leds - skip_start - skip_end)
mapper._active_count = active_count
if not (0 < active_count < total_leds):
# No skip needed (full strip used) or no active LEDs.
mapper._skip_floor_idx = None
mapper._skip_ceil_idx = None
mapper._skip_frac = None
mapper._skip_left_u8 = None
mapper._skip_right_u8 = None
mapper._skip_blend_f32 = None
mapper._skip_resampled = None
return
# Floor/ceil source indices and fractional weights for each
# destination LED. ``t = src_x[k] = k * (total_leds - 1) / (active_count - 1)``
# — equivalent to ``np.linspace(0, total_leds - 1, active_count)``.
if active_count > 1:
t = np.arange(active_count, dtype=np.float64) * ((total_leds - 1) / (active_count - 1))
else:
t = np.zeros(active_count, dtype=np.float64)
floor_idx = np.floor(t).astype(np.int64)
np.clip(floor_idx, 0, total_leds - 1, out=floor_idx)
ceil_idx = np.minimum(floor_idx + 1, total_leds - 1)
frac = (t - floor_idx).astype(np.float32)[:, None] # (active_count, 1)
mapper._skip_floor_idx = floor_idx
mapper._skip_ceil_idx = ceil_idx
mapper._skip_frac = frac
# uint8 take destinations + float32 blend scratch — all reused per frame
mapper._skip_left_u8 = np.empty((active_count, 3), dtype=np.uint8)
mapper._skip_right_u8 = np.empty((active_count, 3), dtype=np.uint8)
mapper._skip_blend_f32 = np.empty((active_count, 3), dtype=np.float32)
mapper._skip_resampled = np.empty((active_count, 3), dtype=np.uint8)
def _apply_skip_resample(mapper, led_array: np.ndarray) -> None:
"""Phase 3 in-place resample of ``led_array`` (no allocations).
Applies linear interpolation precomputed in ``_build_skip_buffers`` and
writes the result back into ``led_array`` with the configured skip
leading/trailing zeros.
"""
floor_idx = mapper._skip_floor_idx
if floor_idx is None:
if mapper._active_count <= 0:
led_array[:] = 0
return
left_u8 = mapper._skip_left_u8
right_u8 = mapper._skip_right_u8
blend = mapper._skip_blend_f32
resampled = mapper._skip_resampled
np.take(led_array, floor_idx, axis=0, out=left_u8)
np.take(led_array, mapper._skip_ceil_idx, axis=0, out=right_u8)
np.copyto(blend, right_u8, casting="unsafe") # uint8 → float32
blend -= left_u8 # right - left
blend *= mapper._skip_frac # frac * (right - left)
blend += left_u8 # left + frac*(right - left)
np.clip(blend, 0, 255, out=blend)
np.copyto(resampled, blend, casting="unsafe") # float32 → uint8
led_array[:] = 0
end_idx = mapper._total_leds - mapper._skip_end
led_array[mapper._skip_start : end_idx] = resampled
class PixelMapper: class PixelMapper:
"""Maps screen border pixels to LED colors based on calibration.""" """Maps screen border pixels to LED colors based on calibration."""
@@ -280,19 +364,10 @@ class PixelMapper:
indices = (indices + offset) % total_leds indices = (indices + offset) % total_leds
self._segment_indices.append(indices) self._segment_indices.append(indices)
# Pre-compute Phase 3 skip arrays (static geometry) # Pre-compute Phase 3 skip — linear interpolation by precomputed
skip_start = calibration.skip_leds_start # floor/ceil indices and fractional weights. Per-frame work is
skip_end = calibration.skip_leds_end # entirely write-in-place into pre-allocated scratch buffers.
self._skip_start = skip_start _build_skip_buffers(self, calibration, total_leds)
self._skip_end = skip_end
self._active_count = max(0, total_leds - skip_start - skip_end)
if 0 < self._active_count < total_leds:
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
self._skip_x = np.arange(total_leds, dtype=np.float64)
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
else:
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
# Per-edge average computation cache (lazy-initialized on first frame) # Per-edge average computation cache (lazy-initialized on first frame)
self._edge_cache: Dict[str, tuple] = {} self._edge_cache: Dict[str, tuple] = {}
@@ -357,8 +432,9 @@ class PixelMapper:
) -> np.ndarray: ) -> np.ndarray:
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8. """Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8.
Uses pre-allocated cumsum/mean buffers (lazy-initialized per edge) to Uses pre-allocated cumsum/mean buffers AND pre-allocated output
avoid per-frame allocations that cause GC-induced timing spikes. buffers (lazy-initialized per edge). All per-frame numpy ops write
in-place zero allocations on the hot path.
""" """
if edge_name in ("top", "bottom"): if edge_name in ("top", "bottom"):
axis = 0 axis = 0
@@ -369,7 +445,7 @@ class PixelMapper:
# Lazy-init / resize per-edge scratch buffers # Lazy-init / resize per-edge scratch buffers
cache = self._edge_cache.get(edge_name) cache = self._edge_cache.get(edge_name)
if cache is None or cache[0] != edge_len: if cache is None or cache[0] != edge_len or cache[1] != led_count:
step = edge_len / led_count step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64) boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1) boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
@@ -379,20 +455,53 @@ class PixelMapper:
lengths = (ends - starts).reshape(-1, 1).astype(np.float64) lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64) cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64) edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf) sums_buf = np.empty((led_count, 3), dtype=np.float64)
starts_buf = np.empty((led_count, 3), dtype=np.float64)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
cache = (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
self._edge_cache[edge_name] = cache self._edge_cache[edge_name] = cache
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache (
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = cache
# Mean into pre-allocated buffer (no intermediate float64 array) # Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf) np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
# Cumsum into pre-allocated buffer # Cumsum into pre-allocated buffer (cumsum_buf[0] left at 0 from init)
cumsum_buf[0] = 0 cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:]) np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts] # segment_sums = cumsum_buf[ends] - cumsum_buf[starts] — but each
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8) # fancy-index expression allocates. np.take with ``out=`` writes
# directly into our pre-allocated scratch.
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray: def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors. """Map screen border pixels to LED colors.
@@ -423,18 +532,9 @@ class PixelMapper:
led_array[self._segment_indices[i]] = colors led_array[self._segment_indices[i]] = colors
# Phase 3: Physical skip — resample full perimeter to active LEDs # Phase 3: physical skip — resample full perimeter into active LEDs
if self._skip_src is not None: # using precomputed weights, all in-place.
np.copyto(self._skip_float, led_array, casting="unsafe") _apply_skip_resample(self, led_array)
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start : end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
return led_array return led_array
@@ -514,19 +614,8 @@ class AdvancedPixelMapper:
self._line_indices.append(indices) self._line_indices.append(indices)
led_start += line.led_count led_start += line.led_count
# Skip arrays (same logic as PixelMapper) # Skip arrays — share the same buffer layout as PixelMapper
skip_start = calibration.skip_leds_start _build_skip_buffers(self, calibration, total_leds)
skip_end = calibration.skip_leds_end
self._skip_start = skip_start
self._skip_end = skip_end
self._active_count = max(0, total_leds - skip_start - skip_end)
if 0 < self._active_count < total_leds:
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
self._skip_x = np.arange(total_leds, dtype=np.float64)
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
else:
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
# Per-line edge cache (keyed by line index to avoid collision) # Per-line edge cache (keyed by line index to avoid collision)
self._edge_cache: Dict[int, tuple] = {} self._edge_cache: Dict[int, tuple] = {}
@@ -586,7 +675,7 @@ class AdvancedPixelMapper:
edge_len = edge_pixels.shape[0] edge_len = edge_pixels.shape[0]
cache = self._edge_cache.get(cache_key) cache = self._edge_cache.get(cache_key)
if cache is None or cache[0] != edge_len: if cache is None or cache[0] != edge_len or cache[1] != led_count:
step = edge_len / led_count step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64) boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1) boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
@@ -596,15 +685,45 @@ class AdvancedPixelMapper:
lengths = (ends - starts).reshape(-1, 1).astype(np.float64) lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64) cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64) edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf) sums_buf = np.empty((led_count, 3), dtype=np.float64)
starts_buf = np.empty((led_count, 3), dtype=np.float64)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
cache = (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
self._edge_cache[cache_key] = cache self._edge_cache[cache_key] = cache
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache (
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = cache
np.mean(edge_pixels, axis=axis, out=edge_1d_buf) np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
cumsum_buf[0] = 0 cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:]) np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts] np.take(cumsum_buf, ends, axis=0, out=sums_buf)
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8) np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
def _map_edge_fallback( def _map_edge_fallback(
self, self,
@@ -672,18 +791,8 @@ class AdvancedPixelMapper:
led_array[self._line_indices[i]] = colors led_array[self._line_indices[i]] = colors
# Phase 3: Physical skip (same as PixelMapper) # Phase 3: physical skip same precomputed-weight resample as PixelMapper
if self._skip_src is not None: _apply_skip_resample(self, led_array)
np.copyto(self._skip_float, led_array, casting="unsafe")
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start : end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
return led_array return led_array
@@ -117,6 +117,16 @@ class CaptureEngine(ABC):
"""Get default configuration for this engine.""" """Get default configuration for this engine."""
pass pass
@classmethod
def get_config_choices(cls) -> Dict[str, List[str]]:
"""Return allowed values for enum-like config keys on this platform.
Keys returned here narrow the values the UI offers for the
corresponding config field. Engines that have no platform-specific
constraints can leave this empty (default).
"""
return {}
@classmethod @classmethod
@abstractmethod @abstractmethod
def get_available_displays(cls) -> List[DisplayInfo]: def get_available_displays(cls) -> List[DisplayInfo]:
@@ -8,12 +8,19 @@ Prerequisites (optional dependency):
pip install opencv-python-headless>=4.8.0 pip install opencv-python-headless>=4.8.0
""" """
import os
import platform import platform
import sys import sys
import threading import threading
import time import time
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set
# OpenCV's MSMF backend on Windows often fails to open the device
# ("cap.isOpened() == False" right after VideoCapture returns) when
# hardware MFTs are enabled. Disabling them is the documented mitigation.
# Set before any cv2 import so the MSMF backend picks it up on first use.
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
from ledgrab.core.capture_engines.base import ( from ledgrab.core.capture_engines.base import (
CaptureEngine, CaptureEngine,
@@ -27,6 +34,41 @@ logger = get_logger(__name__)
_MAX_CAMERA_INDEX = 10 # probe indices 0..9 _MAX_CAMERA_INDEX = 10 # probe indices 0..9
# Sentinel used to ask DShow/MSMF/V4L2 for the highest mode the device supports.
# OpenCV will clamp the requested width/height down to the nearest supported mode.
_PROBE_MAX_DIM = 9999
# Resolution presets shown in the UI. "auto" means: open at the camera's max
# (probed via _PROBE_MAX_DIM); the other entries are explicit overrides.
_RESOLUTION_CHOICES: List[str] = [
"auto",
"640x480",
"1280x720",
"1920x1080",
"2560x1440",
"3840x2160",
]
def _parse_resolution(value: Any) -> Optional[tuple[int, int]]:
"""Parse a 'WxH' string into (width, height). Returns None for 'auto' or invalid."""
if not isinstance(value, str):
return None
s = value.strip().lower()
if s in ("", "auto"):
return None
parts = s.replace("×", "x").split("x")
if len(parts) != 2:
return None
try:
w, h = int(parts[0]), int(parts[1])
except ValueError:
return None
if w <= 0 or h <= 0:
return None
return w, h
# Process-wide registry of cv2 camera indices currently held open. # Process-wide registry of cv2 camera indices currently held open.
# Prevents _enumerate_cameras from probing an in-use camera (which can # Prevents _enumerate_cameras from probing an in-use camera (which can
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams # crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
@@ -48,6 +90,85 @@ def _get_default_backend():
return "auto" return "auto"
# Maps our backend ids to the label cv2.getBuildInformation() prints in the
# Video I/O section. Entries missing or marked "NO" mean the installed
# opencv wheel was compiled without that backend — even if cv2's registry
# still lists it, attempts to open will fail with isOpened()==False.
_BUILDINFO_LABELS: Dict[str, str] = {
"dshow": "DirectShow",
"msmf": "Media Foundation",
"v4l2": "v4l/v4l2",
"avfoundation": "AVFoundation",
}
_compiled_backends_cache: Optional[Set[str]] = None
def _get_compiled_backends() -> Set[str]:
"""Return the set of backend ids the installed cv2 was compiled with.
Parses ``cv2.getBuildInformation()`` because cv2's videoio registry can
advertise backends that aren't actually functional (e.g. wheels that
omit Media Foundation still list MSMF in the registry).
"""
global _compiled_backends_cache
if _compiled_backends_cache is not None:
return _compiled_backends_cache
try:
import cv2
except ImportError:
_compiled_backends_cache = set()
return _compiled_backends_cache
info = cv2.getBuildInformation()
# Restrict the search to the "Video I/O" section so labels like
# "Media Foundation" don't pick up unrelated mentions elsewhere.
start = info.find("Video I/O:")
section = info[start:] if start != -1 else info
end_markers = ("Parallel framework", "Trace:", "Other third-party libraries")
for marker in end_markers:
idx = section.find(marker)
if idx != -1:
section = section[:idx]
break
found: Set[str] = set()
for backend, label in _BUILDINFO_LABELS.items():
# Match "<label>: <whitespace>YES" anywhere in the section.
needle = label + ":"
pos = section.find(needle)
if pos == -1:
continue
line_end = section.find("\n", pos)
line = section[pos : line_end if line_end != -1 else len(section)]
if "YES" in line.upper():
found.add(backend)
_compiled_backends_cache = found
return found
def _get_supported_backends() -> List[str]:
"""Return the list of cv2 backends that make sense on this platform.
Only advertises backends that are both (a) appropriate for the host OS
and (b) actually compiled into the installed opencv wheel. ``auto`` is
always offered as a safe default.
"""
if sys.platform == "win32":
candidates = ["dshow", "msmf"]
elif sys.platform.startswith("linux"):
candidates = ["v4l2"]
elif sys.platform == "darwin":
candidates = ["avfoundation"]
else:
candidates = []
compiled = _get_compiled_backends()
return ["auto", *(b for b in candidates if b in compiled)]
def _cv2_backend_id(backend_name: str) -> Optional[int]: def _cv2_backend_id(backend_name: str) -> Optional[int]:
"""Convert a backend name string to cv2 API preference constant.""" """Convert a backend name string to cv2 API preference constant."""
return _CV2_BACKENDS.get(backend_name) return _CV2_BACKENDS.get(backend_name)
@@ -256,8 +377,20 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
cap.release() cap.release()
continue continue
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) # Probe the camera's max supported mode by asking for an absurdly large
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) # frame size — DShow/MSMF/V4L2 clamp down to the highest available mode.
# If the probe is rejected (rare driver issue), fall back to the default
# mode that the camera reports immediately after open.
default_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
default_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
try:
cap.set(cv2.CAP_PROP_FRAME_WIDTH, _PROBE_MAX_DIM)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, _PROBE_MAX_DIM)
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or default_width
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or default_height
except Exception as e:
logger.debug(f"Camera {i} max-resolution probe failed: {e}")
width, height = default_width, default_height
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}") name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}")
@@ -328,16 +461,28 @@ class CameraCaptureStream(CaptureStream):
_active_cv2_indices.add(cv2_index) _active_cv2_indices.add(cv2_index)
try: try:
# Open the camera # Open the camera. MSMF's first open after a DShow session (or its
# very first cold open in the process) is timing-sensitive on
# Windows, so retry briefly before giving up.
backend_id = _cv2_backend_id(backend_name) backend_id = _cv2_backend_id(backend_name)
if backend_id is not None: attempts = 3 if backend_name == "msmf" else 1
self._cap = cv2.VideoCapture(cv2_index, backend_id) self._cap = None
else: for attempt in range(attempts):
self._cap = cv2.VideoCapture(cv2_index) if backend_id is not None:
cap = cv2.VideoCapture(cv2_index, backend_id)
else:
cap = cv2.VideoCapture(cv2_index)
if cap.isOpened():
self._cap = cap
break
cap.release()
if attempt + 1 < attempts:
time.sleep(0.5)
if not self._cap.isOpened(): if self._cap is None or not self._cap.isOpened():
raise RuntimeError( raise RuntimeError(
f"Failed to open camera {self.display_index} " f"(cv2 index {cv2_index})" f"Failed to open camera {self.display_index} "
f"(cv2 index {cv2_index}, backend={backend_name})"
) )
except Exception: except Exception:
with _camera_lock: with _camera_lock:
@@ -346,12 +491,28 @@ class CameraCaptureStream(CaptureStream):
self._cv2_index = cv2_index self._cv2_index = cv2_index
# Apply optional resolution override # Resolve effective resolution.
res_w = self.config.get("resolution_width", 0) # Priority: legacy `resolution_width`/`resolution_height` (if both > 0)
res_h = self.config.get("resolution_height", 0) # → new `resolution` enum string (e.g. "1920x1080" or "auto")
if res_w > 0 and res_h > 0: # → "auto" (open at the camera's max).
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, res_w) # On Windows DShow/MSMF the default opening mode is typically 640x480
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, res_h) # regardless of the camera's hardware ceiling, so when no explicit
# override is given we ask for the highest mode the device supports
# by setting an absurdly large frame size — drivers clamp down to the
# nearest supported mode.
legacy_w = self.config.get("resolution_width", 0) or 0
legacy_h = self.config.get("resolution_height", 0) or 0
if legacy_w > 0 and legacy_h > 0:
target_w, target_h = legacy_w, legacy_h
else:
parsed = _parse_resolution(self.config.get("resolution", "auto"))
if parsed is not None:
target_w, target_h = parsed
else:
target_w, target_h = _PROBE_MAX_DIM, _PROBE_MAX_DIM
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, target_w)
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, target_h)
# Test read # Test read
ret, frame = self._cap.read() ret, frame = self._cap.read()
@@ -434,10 +595,20 @@ class CameraEngine(CaptureEngine):
@classmethod @classmethod
def get_default_config(cls) -> Dict[str, Any]: def get_default_config(cls) -> Dict[str, Any]:
# `resolution` is the user-facing control. Legacy numeric overrides
# `resolution_width`/`resolution_height` are still honored if present
# in stored configs (see CameraCaptureStream.initialize), but are no
# longer surfaced in the default config — the dropdown replaces them.
return { return {
"camera_backend": _get_default_backend(), "camera_backend": _get_default_backend(),
"resolution_width": 0, "resolution": "auto",
"resolution_height": 0, }
@classmethod
def get_config_choices(cls) -> Dict[str, List[str]]:
return {
"camera_backend": _get_supported_backends(),
"resolution": list(_RESOLUTION_CHOICES),
} }
@classmethod @classmethod
-17
View File
@@ -32,7 +32,6 @@ _PS_IDS = {
_CSS_IDS = { _CSS_IDS = {
"gradient": "css_demo0001", "gradient": "css_demo0001",
"cycle": "css_demo0002",
"picture": "css_demo0003", "picture": "css_demo0003",
"audio": "css_demo0004", "audio": "css_demo0004",
} }
@@ -267,22 +266,6 @@ def _build_color_strip_sources() -> dict:
"created_at": _NOW, "created_at": _NOW,
"updated_at": _NOW, "updated_at": _NOW,
}, },
_CSS_IDS["cycle"]: {
"id": _CSS_IDS["cycle"],
"name": "Warm Color Cycle",
"source_type": "color_cycle",
"description": "Smoothly cycles through warm colors",
"clock_id": None,
"tags": ["demo"],
"colors": [
[255, 60, 0],
[255, 140, 0],
[255, 200, 50],
[255, 100, 20],
],
"created_at": _NOW,
"updated_at": _NOW,
},
_CSS_IDS["picture"]: { _CSS_IDS["picture"]: {
"id": _CSS_IDS["picture"], "id": _CSS_IDS["picture"],
"name": "Screen Capture — Main Display", "name": "Screen Capture — Main Display",
@@ -1,6 +1,7 @@
"""Adalight serial LED client — sends pixel data over serial using the Adalight protocol.""" """Adalight serial LED client — sends pixel data over serial using the Adalight protocol."""
import asyncio import asyncio
import concurrent.futures
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -56,15 +57,38 @@ class AdalightClient(LEDClient):
# Pre-compute Adalight header if led_count is known # Pre-compute Adalight header if led_count is known
self._header = _build_adalight_header(led_count) if led_count > 0 else b"" self._header = _build_adalight_header(led_count) if led_count > 0 else b""
self._header_len = len(self._header)
# Pre-allocate numpy buffer for brightness scaling # Pre-allocated wire buffer (header + RGB payload). Resized on the
self._pixel_buf = None # first frame and reused thereafter so the hot path performs no
# allocations — only a single memcpy of the pixel bytes.
self._frame_buf: Optional[bytearray] = None
self._frame_buf_n: int = 0
# Scratch uint8 array used to coerce non-uint8 / non-contiguous input
# without allocating a fresh array per frame.
self._u8_scratch: Optional[np.ndarray] = None
self._u8_scratch_n: int = 0
# Dedicated single-worker executor for serial writes. Using
# ``loop.run_in_executor`` against this avoids the per-call
# ``contextvars.copy_context()`` and ``functools.partial`` overhead
# that ``asyncio.to_thread`` incurs (~510 µs per call), and
# guarantees FIFO ordering of writes from this client even when
# other tasks are using the default executor.
self._tx_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
async def connect(self) -> bool: async def connect(self) -> bool:
"""Open serial port and wait for Arduino reset.""" """Open serial port and wait for Arduino reset."""
try: try:
self._serial = open_transport(self._port, baud_rate=self._baud_rate, timeout=1) self._serial = open_transport(self._port, baud_rate=self._baud_rate, timeout=1)
await asyncio.to_thread(self._serial.open) # Single-worker executor — created here so the thread is bound
# to this client's lifecycle (started on connect, shut down on
# close). ``thread_name_prefix`` makes it identifiable in
# diagnostics.
self._tx_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1,
thread_name_prefix=f"adalight-tx-{self._port}",
)
await asyncio.get_running_loop().run_in_executor(self._tx_executor, self._serial.open)
# Wait for Arduino to finish bootloader reset (non-blocking). # Wait for Arduino to finish bootloader reset (non-blocking).
# USB-to-TTL adapters without DTR don't reset, but the delay # USB-to-TTL adapters without DTR don't reset, but the delay
# is harmless on those — keeps the path uniform. # is harmless on those — keeps the path uniform.
@@ -77,11 +101,22 @@ class AdalightClient(LEDClient):
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to open serial port {self._port}: {e}") logger.error(f"Failed to open serial port {self._port}: {e}")
if self._tx_executor is not None:
self._tx_executor.shutdown(wait=False)
self._tx_executor = None
raise RuntimeError(f"Failed to open serial port {self._port}: {e}") raise RuntimeError(f"Failed to open serial port {self._port}: {e}")
async def close(self) -> None: async def close(self) -> None:
"""Send black frame and close the serial port.""" """Send black frame and close the serial port."""
if self._connected and self._serial and self._serial.is_open and self._led_count > 0: loop = asyncio.get_running_loop()
executor = self._tx_executor
if (
self._connected
and self._serial
and self._serial.is_open
and self._led_count > 0
and executor is not None
):
try: try:
black = np.zeros((self._led_count, 3), dtype=np.uint8) black = np.zeros((self._led_count, 3), dtype=np.uint8)
frame = self._build_frame(black, brightness=255) frame = self._build_frame(black, brightness=255)
@@ -89,8 +124,8 @@ class AdalightClient(LEDClient):
f"Adalight sending black frame: {self._port} " f"Adalight sending black frame: {self._port} "
f"({self._led_count} LEDs, {len(frame)} bytes)" f"({self._led_count} LEDs, {len(frame)} bytes)"
) )
await asyncio.to_thread(self._serial.write, frame) await loop.run_in_executor(executor, self._serial.write, frame)
await asyncio.to_thread(self._serial.flush) await loop.run_in_executor(executor, self._serial.flush)
logger.info(f"Adalight black frame sent and flushed: {self._port}") logger.info(f"Adalight black frame sent and flushed: {self._port}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to send black frame on close: {e}") logger.warning(f"Failed to send black frame on close: {e}")
@@ -108,6 +143,9 @@ class AdalightClient(LEDClient):
except Exception as e: except Exception as e:
logger.warning(f"Error closing serial port: {e}") logger.warning(f"Error closing serial port: {e}")
self._serial = None self._serial = None
if self._tx_executor is not None:
self._tx_executor.shutdown(wait=False)
self._tx_executor = None
logger.info(f"Adalight disconnected: {self._port}") logger.info(f"Adalight disconnected: {self._port}")
@property @property
@@ -125,12 +163,15 @@ class AdalightClient(LEDClient):
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
brightness: Global brightness (0-255) brightness: Global brightness (0-255)
""" """
if not self.is_connected: executor = self._tx_executor
if not self.is_connected or executor is None:
return False return False
try: try:
frame = self._build_frame(pixels, brightness) frame = self._build_frame(pixels, brightness)
await asyncio.to_thread(self._serial.write, frame) # ``run_in_executor`` skips the per-call ``contextvars.copy_context``
# / ``functools.partial`` overhead that ``asyncio.to_thread`` does.
await asyncio.get_running_loop().run_in_executor(executor, self._serial.write, frame)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Adalight send_pixels error: {e}") logger.error(f"Adalight send_pixels error: {e}")
@@ -141,17 +182,63 @@ class AdalightClient(LEDClient):
# Serial write is blocking — use async send_pixels path instead # Serial write is blocking — use async send_pixels path instead
return False return False
def _build_frame(self, pixels, brightness: int) -> bytes: def _ensure_frame_buf(self, n_leds: int) -> None:
"""Build a complete Adalight frame: header + brightness-scaled RGB data.""" """Lazily allocate / resize the wire-format frame buffer.
if isinstance(pixels, np.ndarray):
arr = pixels.astype(np.uint16)
else:
arr = np.array(pixels, dtype=np.uint16)
# Note: brightness already applied by processor loop (_cached_brightness) Header bytes are written once at the front; subsequent calls only
np.clip(arr, 0, 255, out=arr) memcpy the pixel payload into the trailing slot.
rgb_bytes = arr.astype(np.uint8).tobytes() """
return self._header + rgb_bytes needed = self._header_len + n_leds * 3
if self._frame_buf is None or len(self._frame_buf) != needed:
buf = bytearray(needed)
buf[: self._header_len] = self._header
self._frame_buf = buf
self._frame_buf_n = n_leds
def _ensure_u8_scratch(self, n_leds: int) -> np.ndarray:
"""Pre-allocated (N, 3) uint8 scratch for non-conforming inputs."""
if self._u8_scratch is None or self._u8_scratch_n != n_leds:
self._u8_scratch = np.empty((n_leds, 3), dtype=np.uint8)
self._u8_scratch_n = n_leds
return self._u8_scratch
def _build_frame(self, pixels, brightness: int) -> bytes:
"""Build a complete Adalight frame in the pre-allocated wire buffer.
The processor loop hands us a contiguous (N, 3) uint8 array with
brightness already applied (see ``_cached_brightness``), so the hot
path is one memcpy from the pixel buffer into the trailing slot of
``_frame_buf``. All other input shapes (lists of tuples, wrong
dtype, non-contiguous views) coerce into a pre-allocated uint8
scratch before the memcpy still allocation-free in steady state.
"""
if isinstance(pixels, np.ndarray):
n_leds = pixels.shape[0]
if pixels.dtype == np.uint8 and pixels.flags["C_CONTIGUOUS"]:
# Hot path: input matches wire format exactly.
arr = pixels
else:
# Slow path: dtype mismatch or non-contiguous view. Coerce
# into a pre-allocated uint8 scratch. Wider integer dtypes
# are clamped to [0, 255] to match historical behaviour.
arr = self._ensure_u8_scratch(n_leds)
if pixels.dtype != np.uint8:
# Clamp wider integer dtypes to [0, 255] before the
# uint8 narrowing copy. This is the rare slow path —
# one extra allocation here is fine.
np.copyto(arr, np.clip(pixels, 0, 255), casting="unsafe")
else:
np.copyto(arr, pixels)
else:
# List/tuple input — rare path, only hit by tests/legacy callers.
arr = np.array(pixels, dtype=np.uint8)
n_leds = arr.shape[0]
self._ensure_frame_buf(n_leds)
# memcpy pixel bytes into the trailing slot of the pre-built buffer
view = memoryview(self._frame_buf)
view[self._header_len :] = memoryview(arr).cast("B")
return self._frame_buf
@classmethod @classmethod
async def check_health( async def check_health(
+86 -114
View File
@@ -39,6 +39,9 @@ class DDPClient:
DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame) DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame)
DDP_TYPE_RGB = 0x01 DDP_TYPE_RGB = 0x01
# Pre-built struct.Struct for the 10-byte DDP header (avoids per-call format parsing)
_HEADER_STRUCT = struct.Struct("!BBB B I H")
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False): def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
"""Initialize DDP client. """Initialize DDP client.
@@ -57,6 +60,10 @@ class DDPClient:
# Pre-allocated RGBW buffer (resized on demand) # Pre-allocated RGBW buffer (resized on demand)
self._rgbw_buf: Optional[np.ndarray] = None self._rgbw_buf: Optional[np.ndarray] = None
self._rgbw_buf_n: int = 0 self._rgbw_buf_n: int = 0
# Pre-allocated send buffer (header + payload). Sized lazily on first
# send so we never allocate fresh bytes per frame on the hot path.
self._send_buf: Optional[bytearray] = None
self._send_view: Optional[memoryview] = None
async def connect(self): async def connect(self):
"""Establish UDP connection.""" """Establish UDP connection."""
@@ -93,52 +100,52 @@ class DDPClient:
f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})" f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})"
) )
def _build_ddp_packet( def _ensure_send_buf(self, capacity: int) -> None:
self, """Lazily allocate / grow the per-instance send buffer.
rgb_data: bytes,
offset: int = 0,
sequence: int = 1,
push: bool = False,
) -> bytes:
"""Build a DDP packet.
DDP packet format (10-byte header + data): ``capacity`` is the largest packet we may emit (header + payload).
- Byte 0: Flags (VER1 | PUSH on last packet) Once sized, the buffer is reused for every subsequent send so the
- Byte 1: Sequence number hot path stays allocation-free.
- Byte 2: Data type (0x01 = RGB)
- Byte 3: Source/Destination ID
- Bytes 4-7: Data offset (4 bytes, big-endian)
- Bytes 8-9: Data length (2 bytes, big-endian)
- Bytes 10+: Pixel data
Args:
rgb_data: RGB pixel data as bytes
offset: Byte offset (pixel_index * 3)
sequence: Sequence number (0-255)
push: True for the last packet of a frame
Returns:
Complete DDP packet as bytes
""" """
flags = self.DDP_FLAGS_VER1 buf = self._send_buf
if push: if buf is None or len(buf) < capacity:
flags |= self.DDP_FLAGS_PUSH self._send_buf = bytearray(capacity)
data_type = self.DDP_TYPE_RGB self._send_view = memoryview(self._send_buf)
source_id = 0x01
data_len = len(rgb_data)
# Build header (10 bytes) def _emit_packet(
header = struct.pack( self,
"!BBB B I H", # Network byte order (big-endian) payload: memoryview,
flags, # Flags offset: int,
sequence, # Sequence sequence: int,
data_type, # Data type push: bool,
source_id, # Source/Destination ) -> None:
offset, # Data offset (4 bytes) """Pack header + payload into the pre-allocated send buffer and emit.
data_len, # Data length (2 bytes)
DDP packet layout (10-byte header):
[0] Flags (VER1 | PUSH on last)
[1] Sequence
[2] Data type (0x01 = RGB)
[3] Source/Destination ID
[4-7] Data offset (big-endian)
[8-9] Data length (big-endian)
[10+] Pixel data
"""
flags = self.DDP_FLAGS_VER1 | (self.DDP_FLAGS_PUSH if push else 0)
data_len = len(payload)
self._ensure_send_buf(10 + data_len)
buf = self._send_buf
view = self._send_view
# Fill header into pre-allocated buffer (no allocation)
self._HEADER_STRUCT.pack_into(
buf, 0, flags, sequence, self.DDP_TYPE_RGB, 0x01, offset, data_len
) )
# Copy payload bytes into buffer (single memcpy)
return header + rgb_data view[10 : 10 + data_len] = payload
# asyncio's selector_datagram_transport.sendto fast-path calls
# socket.sendto(data) which accepts a buffer-like; it only copies to
# bytes when the OS send buffer is full and the datagram must be
# queued. So passing a memoryview is safe and avoids `bytes(...)`.
self._transport.sendto(view[: 10 + data_len])
def _reorder_pixels_numpy(self, pixel_array: np.ndarray) -> np.ndarray: def _reorder_pixels_numpy(self, pixel_array: np.ndarray) -> np.ndarray:
"""Apply per-bus color order reordering using numpy fancy indexing. """Apply per-bus color order reordering using numpy fancy indexing.
@@ -168,13 +175,39 @@ class DDPClient:
return result return result
def _send_buffer(self, payload_view: memoryview, bpp: int, max_packet_size: int) -> int:
"""Chunk and emit a contiguous payload via DDP. Returns packet count.
``payload_view`` is a 1-D bytes-like view; the caller guarantees its
length is a multiple of ``bpp``. Each emitted packet is sized to a
whole number of pixels so RGB channels never split across packets.
"""
total_bytes = len(payload_view)
max_payload = max_packet_size - 10 # 10-byte header
bytes_per_packet = (max_payload // bpp) * bpp
if bytes_per_packet <= 0:
bytes_per_packet = bpp # degenerate guard
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
for i in range(num_packets):
start = i * bytes_per_packet
end = total_bytes if (i == num_packets - 1) else (start + bytes_per_packet)
self._sequence = (self._sequence + 1) % 256
self._emit_packet(
payload_view[start:end],
offset=start,
sequence=self._sequence,
push=(i == num_packets - 1),
)
return num_packets
async def send_pixels( async def send_pixels(
self, pixels: List[Tuple[int, int, int]], max_packet_size: int = 1400 self, pixels: List[Tuple[int, int, int]], max_packet_size: int = 1400
) -> bool: ) -> bool:
"""Send pixel data via DDP. """Send pixel data via DDP.
Args: Args:
pixels: List of (R, G, B) tuples pixels: List of (R, G, B) tuples or an (N, 3) uint8 numpy array
max_packet_size: Maximum UDP packet size (default 1400 bytes for safety) max_packet_size: Maximum UDP packet size (default 1400 bytes for safety)
Returns: Returns:
@@ -187,65 +220,17 @@ class DDPClient:
raise RuntimeError("DDP client not connected") raise RuntimeError("DDP client not connected")
try: try:
# Send plain RGB — WLED handles per-bus color order conversion
# internally when outputting to hardware.
# Accept numpy arrays directly to avoid per-pixel Python loop # Accept numpy arrays directly to avoid per-pixel Python loop
bpp = 4 if self.rgbw else 3 # bytes per pixel
if isinstance(pixels, np.ndarray): if isinstance(pixels, np.ndarray):
pixel_array = pixels pixel_array = pixels
else: else:
pixel_array = np.array(pixels, dtype=np.uint8) pixel_array = np.array(pixels, dtype=np.uint8)
if self.rgbw:
n = pixel_array.shape[0]
if n != self._rgbw_buf_n:
self._rgbw_buf = np.zeros((n, 4), dtype=np.uint8)
self._rgbw_buf_n = n
self._rgbw_buf[:, :3] = pixel_array
pixel_array = self._rgbw_buf
pixel_bytes = pixel_array.tobytes()
total_bytes = len(pixel_bytes)
# Align payload to full pixels (multiple of bpp) to avoid splitting
# a pixel's channels across packets
max_payload = max_packet_size - 10 # 10-byte header
bytes_per_packet = (max_payload // bpp) * bpp
# Split into multiple packets if needed
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
logger.debug(
f"DDP: Sending {len(pixels)} pixels ({total_bytes} bytes) "
f"in {num_packets} packet(s) to {self.host}:{self.port}"
)
for i in range(num_packets):
start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes)
chunk = pixel_bytes[start:end]
is_last = i == num_packets - 1
# Increment sequence number
self._sequence = (self._sequence + 1) % 256
# Set PUSH flag on the last packet to signal frame completion
packet = self._build_ddp_packet(
chunk,
offset=start,
sequence=self._sequence,
push=is_last,
)
self._transport.sendto(packet)
logger.debug(
f"Sent DDP packet {i+1}/{num_packets}: "
f"{len(chunk)} bytes at offset {start}"
f"{' [PUSH]' if is_last else ''}"
)
self.send_pixels_numpy(pixel_array, max_packet_size=max_packet_size)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to send DDP pixels: {e}") logger.error("Failed to send DDP pixels: %s", e)
raise RuntimeError(f"DDP send failed: {e}") raise RuntimeError(f"DDP send failed: {e}")
def send_pixels_numpy(self, pixel_array: np.ndarray, max_packet_size: int = 1400) -> bool: def send_pixels_numpy(self, pixel_array: np.ndarray, max_packet_size: int = 1400) -> bool:
@@ -270,28 +255,15 @@ class DDPClient:
self._rgbw_buf[:, :3] = pixel_array self._rgbw_buf[:, :3] = pixel_array
pixel_array = self._rgbw_buf pixel_array = self._rgbw_buf
pixel_bytes = pixel_array.tobytes()
bpp = 4 if self.rgbw else 3 bpp = 4 if self.rgbw else 3
total_bytes = len(pixel_bytes) # Get a 1-D bytes view of the pixel buffer with no allocation when
max_payload = max_packet_size - 10 # 10-byte header # the array is already C-contiguous (the common case).
bytes_per_packet = (max_payload // bpp) * bpp if not pixel_array.flags["C_CONTIGUOUS"]:
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet pixel_array = np.ascontiguousarray(pixel_array)
# ``cast('B')`` on a memoryview of a numpy array returns a 1-D byte
for i in range(num_packets): # view; total length == nbytes.
start = i * bytes_per_packet payload_view = memoryview(pixel_array).cast("B")
end = min(start + bytes_per_packet, total_bytes) self._send_buffer(payload_view, bpp, max_packet_size)
chunk = pixel_bytes[start:end]
is_last = i == num_packets - 1
self._sequence = (self._sequence + 1) % 256
packet = self._build_ddp_packet(
chunk,
offset=start,
sequence=self._sequence,
push=is_last,
)
self._transport.sendto(packet)
return True return True
async def __aenter__(self): async def __aenter__(self):
@@ -0,0 +1,273 @@
"""Background discovery watcher — long-running mDNS browser + serial port poller.
Existing per-target health monitoring (``DeviceHealthMixin``) already fires
``device_health_changed`` events when *configured* devices flip online/offline.
This module is the complementary half: it watches for *new* devices appearing
on the LAN/USB (and old discovered-but-never-configured ones disappearing) and
emits ``device_discovered`` / ``device_lost`` events on the same event bus.
Design notes
------------
- The mDNS browser is kept alive for the process lifetime (one ``AsyncZeroconf``
+ ``AsyncServiceBrowser``), which is the entire point of "background discovery".
The on-demand scan in ``WLEDDeviceProvider.discover`` is unchanged that one
spins up its own short-lived browser for the Add Device modal.
- Serial-port hotplug has no equivalent of mDNS push, so we poll
:func:`list_serial_ports` every 10 s. Cheap on desktop (one Windows API call),
no-op on Android (handled separately by Kotlin USB receivers).
- Already-configured devices (matched by URL or MAC against ``device_store``)
are intentionally suppressed from the discovery stream those are covered by
the health-monitor's online/offline events and would otherwise notify twice
per device on startup.
The watcher is purely an event source; pref-driven snack/OS-toast routing
happens client-side in ``features/notifications-watcher.ts``.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Dict, Optional
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
from ledgrab.core.devices.serial_transport import list_serial_ports
from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
if TYPE_CHECKING:
from ledgrab.storage.device_store import DeviceStore
logger = get_logger(__name__)
# Poll interval for serial-port enumeration. Cheap on desktop; skipped on Android.
_SERIAL_POLL_INTERVAL_SEC = 10.0
@dataclass(frozen=True)
class _DiscoveredEntry:
"""A device the watcher has seen at least once.
Two snapshots are compared each cycle (mDNS by service name, serial by
device path) to detect appearances vs. disappearances; the URL is what
we cross-reference against ``device_store`` to know if the device is
already configured.
"""
key: str
url: str
name: str
device_type: str # "wled" | "serial"
FireEvent = Callable[[dict], None]
class DiscoveryWatcher:
"""Continuously scan for new WLED/serial devices and emit events."""
def __init__(self, device_store: "DeviceStore", fire_event: FireEvent) -> None:
self._device_store = device_store
self._fire_event = fire_event
self._aiozc: Optional[AsyncZeroconf] = None
self._browser: Optional[AsyncServiceBrowser] = None
self._serial_task: Optional[asyncio.Task] = None
self._running = False
self._started_at: float = 0.0
# service-name -> entry. mDNS state-change callback runs on the
# asyncio thread so no lock is needed; Python attr writes are atomic.
self._wled_seen: Dict[str, _DiscoveredEntry] = {}
# device-path -> entry. Only the serial poller mutates this.
self._serial_seen: Dict[str, _DiscoveredEntry] = {}
# --- lifecycle --------------------------------------------------------
async def start(self) -> None:
if self._running:
return
self._running = True
self._started_at = time.monotonic()
# mDNS browser — kept alive for the whole process. The handler is sync
# (zeroconf calls it via call_soon on our loop), but resolves run in a
# short-lived task to avoid blocking the dispatcher.
try:
self._aiozc = AsyncZeroconf()
self._browser = AsyncServiceBrowser(
self._aiozc.zeroconf,
WLED_MDNS_TYPE,
handlers=[self._on_wled_state_change],
)
logger.info("Discovery watcher: mDNS browser started for %s", WLED_MDNS_TYPE)
except Exception as e:
# Don't let a zeroconf failure (firewall, multiple-host, etc.)
# prevent the rest of the server from coming up.
logger.error("Discovery watcher: failed to start mDNS browser: %s", e)
self._aiozc = None
self._browser = None
# Serial poller — only on desktop. On Android, USB hotplug is delivered
# through Kotlin receivers, not by polling pyserial.
if not is_android():
self._serial_task = asyncio.create_task(self._serial_poll_loop())
async def stop(self) -> None:
self._running = False
if self._serial_task is not None:
self._serial_task.cancel()
try:
await self._serial_task
except (asyncio.CancelledError, Exception):
pass
self._serial_task = None
if self._browser is not None:
try:
await self._browser.async_cancel()
except Exception as e:
logger.debug("Discovery watcher: browser cancel error: %s", e)
self._browser = None
if self._aiozc is not None:
try:
await self._aiozc.async_close()
except Exception as e:
logger.debug("Discovery watcher: zeroconf close error: %s", e)
self._aiozc = None
logger.info("Discovery watcher stopped")
# --- mDNS -------------------------------------------------------------
def _on_wled_state_change(self, **kwargs) -> None:
"""zeroconf state-change handler. Runs on the asyncio thread."""
state_change = kwargs.get("state_change")
service_type = kwargs.get("service_type", "")
name = kwargs.get("name", "")
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
# Resolve in a task — async_request blocks the handler if awaited
# synchronously and we don't want to stall mDNS dispatch.
asyncio.create_task(self._resolve_wled(service_type, name))
elif state_change == ServiceStateChange.Removed:
entry = self._wled_seen.pop(name, None)
if entry is not None and not self._is_configured(entry.url):
self._emit("device_lost", entry)
async def _resolve_wled(self, service_type: str, name: str) -> None:
if self._aiozc is None:
return
info = AsyncServiceInfo(service_type, name)
try:
await info.async_request(self._aiozc.zeroconf, timeout=2000)
except Exception as e:
logger.debug("Discovery watcher: resolve failed for %s: %s", name, e)
return
addrs = info.parsed_addresses()
if not addrs:
return
ip = addrs[0]
port = info.port or 80
url = f"http://{ip}" if port == 80 else f"http://{ip}:{port}"
service_name = name.replace(f".{service_type}", "")
entry = _DiscoveredEntry(
key=name,
url=url,
name=service_name,
device_type="wled",
)
first_sight = name not in self._wled_seen
self._wled_seen[name] = entry
if first_sight and not self._is_configured(url):
self._emit("device_discovered", entry)
# --- serial -----------------------------------------------------------
async def _serial_poll_loop(self) -> None:
"""Detect serial-port appearances/disappearances on a fixed interval."""
try:
# Seed without notifying — ports already plugged in when the server
# starts shouldn't generate "new device" toasts on every boot.
for port in list_serial_ports():
url = port.device
self._serial_seen[url] = _DiscoveredEntry(
key=url,
url=url,
name=port.description,
device_type="serial",
)
while self._running:
await asyncio.sleep(_SERIAL_POLL_INTERVAL_SEC)
if not self._running:
break
self._poll_serial_once()
except asyncio.CancelledError:
pass
except Exception as e:
logger.error("Discovery watcher: serial loop crashed: %s", e)
def _poll_serial_once(self) -> None:
try:
current = {p.device: p for p in list_serial_ports()}
except Exception as e:
logger.debug("Discovery watcher: serial enumeration failed: %s", e)
return
# Appeared
for device, port in current.items():
if device in self._serial_seen:
continue
entry = _DiscoveredEntry(
key=device,
url=device,
name=port.description,
device_type="serial",
)
self._serial_seen[device] = entry
if not self._is_configured(device):
self._emit("device_discovered", entry)
# Disappeared
for device in list(self._serial_seen.keys()):
if device in current:
continue
entry = self._serial_seen.pop(device)
if not self._is_configured(entry.url):
self._emit("device_lost", entry)
# --- helpers ----------------------------------------------------------
def _is_configured(self, url: str) -> bool:
"""True when the URL matches a device already in the user's store."""
try:
for device in self._device_store.get_all_devices():
if device.url and device.url.rstrip("/") == url.rstrip("/"):
return True
except Exception as e:
logger.debug("Discovery watcher: device store lookup failed: %s", e)
return False
def _emit(self, event_type: str, entry: _DiscoveredEntry) -> None:
try:
self._fire_event(
{
"type": event_type,
"device_type": entry.device_type,
"url": entry.url,
"name": entry.name,
}
)
except Exception as e:
logger.debug("Discovery watcher: fire_event failed: %s", e)
@@ -148,23 +148,37 @@ class ApiInputColorStripStream(ColorStripStream):
"""Apply segment-based color updates to the buffer. """Apply segment-based color updates to the buffer.
Each segment defines a range and fill mode. Segments are applied in Each segment defines a range and fill mode. Segments are applied in
order (last wins on overlap). The buffer is auto-grown if needed. order (last wins on overlap).
``start`` and ``length`` are optional: ``start`` defaults to 0,
``length`` defaults to ``led_count - start`` (i.e. the remainder of
the strip). The buffer is only auto-grown for segments that supply
an explicit ``length`` extending past the current end implicit
"to the end" segments adapt to whatever the current strip size is.
Args: Args:
segments: list of dicts with keys: segments: list of dicts with keys:
start (int) starting LED index start (int, optional) starting LED index (default 0)
length (int) number of LEDs in segment length (int, optional) number of LEDs in segment
mode (str) "solid" | "per_pixel" | "gradient" (default = led_count - start)
color (list) [R,G,B] for solid mode mode (str) "solid" | "per_pixel" | "gradient"
colors (list) [[R,G,B], ...] for 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 # Compute required buffer size from segments that supply an explicit
max_index = max(seg["start"] + seg["length"] for seg in segments) # length. Segments without a length take the strip as-is and so do
# not trigger growth.
explicit_max = 0
for seg in segments:
seg_start = seg.get("start") or 0
seg_len = seg.get("length")
if seg_len is not None:
explicit_max = max(explicit_max, seg_start + seg_len)
with self._lock: with self._lock:
# Auto-grow buffer if needed # Auto-grow buffer if any explicit segment extends past current end
if max_index > self._led_count: if explicit_max > self._led_count:
self._ensure_capacity(max_index) self._ensure_capacity(explicit_max)
# Start from current buffer (or fallback if timed out) # Start from current buffer (or fallback if timed out)
if self._timed_out: if self._timed_out:
@@ -173,8 +187,12 @@ class ApiInputColorStripStream(ColorStripStream):
buf = self._colors.copy() buf = self._colors.copy()
for seg in segments: for seg in segments:
start = seg["start"] seg_start = seg.get("start")
length = seg["length"] start = 0 if seg_start is None else seg_start
seg_len = seg.get("length")
length = max(0, self._led_count - start) if seg_len is None else seg_len
if length <= 0:
continue
mode = seg["mode"] mode = seg["mode"]
end = start + length end = start + length
@@ -6,7 +6,6 @@ via the shim module ``color_strip_stream.py``.
""" """
from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise
from .cycle import ColorCycleColorStripStream
from .gradient import GradientColorStripStream from .gradient import GradientColorStripStream
from .helpers import _compute_gradient_colors from .helpers import _compute_gradient_colors
from .picture import PictureColorStripStream from .picture import PictureColorStripStream
@@ -16,7 +15,6 @@ __all__ = [
"ColorStripStream", "ColorStripStream",
"PictureColorStripStream", "PictureColorStripStream",
"StaticColorStripStream", "StaticColorStripStream",
"ColorCycleColorStripStream",
"GradientColorStripStream", "GradientColorStripStream",
"_compute_gradient_colors", "_compute_gradient_colors",
"_SimpleNoise1D", "_SimpleNoise1D",
@@ -45,6 +45,19 @@ class ColorStripStream(ABC):
def target_fps(self) -> int: def target_fps(self) -> int:
"""Target processing rate.""" """Target processing rate."""
@property
def actual_fps(self) -> Optional[float]:
"""Measured rate of *new* frames the stream is delivering, or ``None``.
Only streams backed by an external capture (screen, audio device, API
push) implement this the value answers "is the upstream actually
keeping up?". Synthetic streams (gradient/static/cycle/effect/...)
always tick at their `target_fps` by construction, so reporting an
actual rate would just duplicate `target_fps` without diagnostic
value; they keep the default ``None``.
"""
return None
@property @property
@abstractmethod @abstractmethod
def led_count(self) -> int: def led_count(self) -> int:
@@ -1,185 +0,0 @@
"""Color cycle stream — smoothly cycles through user-defined colors."""
import threading
import time
from typing import Optional
import numpy as np
from ledgrab.utils import get_logger
from ledgrab.utils.frame_limiter import FrameLimiter
from ledgrab.utils.timer import high_resolution_timer
from .base import ColorStripStream
logger = get_logger(__name__)
class ColorCycleColorStripStream(ColorStripStream):
"""Color strip stream that smoothly cycles through a user-defined color list.
All LEDs receive the same solid color at any moment, continuously interpolating
between the configured colors in a loop.
LED count auto-sizes from the connected device when led_count == 0 in
the source config; configure(device_led_count) is called by
WledTargetProcessor on start.
"""
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 # optional SyncClockRuntime
self._update_from_source(source)
def _update_from_source(self, source) -> None:
raw = source.colors if isinstance(source.colors, list) else []
default = [
[255, 0, 0],
[255, 255, 0],
[0, 255, 0],
[0, 255, 255],
[0, 0, 255],
[255, 0, 255],
]
self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
self._led_count = _lc if _lc > 0 else 1
self._rebuild_colors()
def _rebuild_colors(self) -> None:
pixel = np.array(self._color_list[0], dtype=np.uint8)
colors = np.tile(pixel, (self._led_count, 1))
with self._colors_lock:
self._colors = colors
def configure(self, device_led_count: int) -> None:
"""Size to device LED count when led_count was 0 (auto-size)."""
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
self._led_count = device_led_count
self._rebuild_colors()
logger.debug(f"ColorCycleColorStripStream auto-sized to {device_led_count} LEDs")
@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:
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
fps = max(1, min(90, fps))
self._fps = fps
self._frame_time = 1.0 / fps
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name="css-color-cycle",
daemon=True,
)
self._thread.start()
logger.info(
f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})"
)
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning(
"ColorCycleColorStripStream animate thread did not terminate within 5s"
)
self._thread = None
logger.info("ColorCycleColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from ledgrab.storage.color_strip_source import ColorCycleColorStripSource
if isinstance(source, ColorCycleColorStripSource):
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
self._rebuild_colors()
logger.info("ColorCycleColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock
def _animate_loop(self) -> None:
"""Background thread: interpolate between colors at target fps.
Uses double-buffered output arrays to avoid per-frame allocations.
"""
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
limiter = FrameLimiter(self._fps)
try:
with high_resolution_timer():
while self._running:
limiter.begin()
wall_start = time.perf_counter()
frame_time = self._frame_time
try:
color_list = self._color_list
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
speed = clock.speed
t = clock.get_time()
else:
speed = 1.0
t = wall_start
n = self._led_count
num = len(color_list)
if num >= 2:
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
# 0.05 factor → one full cycle every 20s at speed=1.0
cycle_pos = (speed * t * 0.05) % 1.0
seg = cycle_pos * num
idx = int(seg) % num
t_i = seg - int(seg)
c1 = color_list[idx]
c2 = color_list[(idx + 1) % num]
buf[:] = (
min(255, int(c1[0] + (c2[0] - c1[0]) * t_i)),
min(255, int(c1[1] + (c2[1] - c1[1]) * t_i)),
min(255, int(c1[2] + (c2[2] - c1[2]) * t_i)),
)
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"ColorCycleColorStripStream animation error: {e}")
limiter.wait(frame_time)
except Exception as e:
logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True)
finally:
self._running = False
@@ -2,6 +2,7 @@
import threading import threading
import time import time
from collections import deque
from typing import Optional from typing import Optional
import numpy as np import numpy as np
@@ -72,6 +73,15 @@ class PictureColorStripStream(ColorStripStream):
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
self._last_timing: dict = {} self._last_timing: dict = {}
# Rolling 1s window of timestamps for *new* frames received from
# the live stream. `len(...)` is the per-second frame rate the
# picture pipeline is actually consuming — diverges from
# `target_fps` when the underlying screen capture stalls (heavy
# GPU load, occluded window, DXGI desktop switch, etc.). Reads
# from another thread see a stale length at worst; deque ops are
# atomic under the GIL so no lock is needed.
self._new_frame_timestamps: deque[float] = deque(maxlen=180)
@property @property
def live_stream(self): def live_stream(self):
"""Public accessor for the underlying LiveStream (used by preview WebSocket).""" """Public accessor for the underlying LiveStream (used by preview WebSocket)."""
@@ -81,6 +91,31 @@ class PictureColorStripStream(ColorStripStream):
def target_fps(self) -> int: def target_fps(self) -> int:
return self._fps return self._fps
@property
def actual_fps(self) -> Optional[float]:
"""Measured new-frame rate over the last 1 second.
Returns the count of distinct frames the picture loop accepted in
the trailing 1s window. ``None`` until the loop has run (no
meaningful number to report yet).
"""
ts_dq = self._new_frame_timestamps
if not ts_dq:
return None
# Stale-tolerant read: producer may pop while we iterate, but we
# only look at the snapshot length and the leftmost timestamp.
now = time.perf_counter()
# If the stream has gone idle (no new frames for >1s) the deque
# still holds samples until the loop next ticks; report 0 so the
# spark drops to the floor instead of pinning at the last rate.
try:
oldest = ts_dq[0]
except IndexError:
return None
if now - oldest > 1.5:
return 0.0
return float(len(ts_dq))
@property @property
def led_count(self) -> int: def led_count(self) -> int:
return self._led_count return self._led_count
@@ -116,6 +151,7 @@ class PictureColorStripStream(ColorStripStream):
self._thread = None self._thread = None
self._latest_colors = None self._latest_colors = None
self._previous_colors = None self._previous_colors = None
self._new_frame_timestamps.clear()
logger.info("PictureColorStripStream stopped") logger.info("PictureColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]: def get_latest_colors(self) -> Optional[np.ndarray]:
@@ -206,6 +242,14 @@ class PictureColorStripStream(ColorStripStream):
cached_frame = frame cached_frame = frame
t0 = time.perf_counter() t0 = time.perf_counter()
# Record the new frame in the rolling 1s window
# used by `actual_fps`. Pop entries older than
# 1s so `len()` reads as frames-per-second.
ts_dq = self._new_frame_timestamps
ts_dq.append(t0)
cutoff = t0 - 1.0
while ts_dq and ts_dq[0] < cutoff:
ts_dq.popleft()
calibration = self._calibration calibration = self._calibration
mapper = self._pixel_mapper mapper = self._pixel_mapper
@@ -73,7 +73,14 @@ class StaticColorStripStream(ColorStripStream):
@property @property
def is_animated(self) -> bool: def is_animated(self) -> bool:
anim = self._animation anim = self._animation
return bool(anim and anim.get("enabled")) if anim and anim.get("enabled"):
return True
return self._is_color_bound()
def _is_color_bound(self) -> bool:
"""True when the `color` property is driven by a ValueStream."""
vs = self._value_streams
return bool(vs and vs.get("color"))
@property @property
def led_count(self) -> int: def led_count(self) -> int:
@@ -243,10 +250,28 @@ class StaticColorStripStream(ColorStripStream):
if colors is not None: if colors is not None:
with self._colors_lock: with self._colors_lock:
self._colors = colors self._colors = colors
elif self._is_color_bound():
# No animation, but color is driven by a ValueStream —
# poll and forward live color updates so the bound
# source is honoured (otherwise LEDs stay stuck on
# the static fallback).
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
buf[:] = self.resolve_color("color", self._source_color)
with self._colors_lock:
self._colors = buf
except Exception as e: except Exception as e:
logger.error(f"StaticColorStripStream animation error: {e}") logger.error(f"StaticColorStripStream animation error: {e}")
sleep_target = frame_time if anim and anim.get("enabled") else 0.25 if (anim and anim.get("enabled")) or self._is_color_bound():
sleep_target = frame_time
else:
sleep_target = 0.25
limiter.wait(sleep_target) limiter.wait(sleep_target)
except Exception as e: except Exception as e:
logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True) logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
@@ -6,7 +6,6 @@ import path ``from ledgrab.core.processing.color_strip_stream import X``.
""" """
from ledgrab.core.processing.color_strip import ( # noqa: F401 from ledgrab.core.processing.color_strip import ( # noqa: F401
ColorCycleColorStripStream,
ColorStripStream, ColorStripStream,
GradientColorStripStream, GradientColorStripStream,
PictureColorStripStream, PictureColorStripStream,
@@ -20,7 +19,6 @@ __all__ = [
"ColorStripStream", "ColorStripStream",
"PictureColorStripStream", "PictureColorStripStream",
"StaticColorStripStream", "StaticColorStripStream",
"ColorCycleColorStripStream",
"GradientColorStripStream", "GradientColorStripStream",
"_compute_gradient_colors", "_compute_gradient_colors",
"_SimpleNoise1D", "_SimpleNoise1D",
@@ -3,7 +3,7 @@
PictureColorStripStreams (expensive screen capture) are shared across multiple PictureColorStripStreams (expensive screen capture) are shared across multiple
consumers via reference counting processing runs once, not once per target. consumers via reference counting processing runs once, not once per target.
Count-dependent streams (static, gradient, color cycle, effect) are NOT shared. Count-dependent streams (static, gradient, effect) are NOT shared.
Each consumer gets its own instance so it can configure an independent LED count Each consumer gets its own instance so it can configure an independent LED count
without interfering with other targets. without interfering with other targets.
""" """
@@ -12,7 +12,6 @@ from dataclasses import dataclass
from typing import Dict, Optional from typing import Dict, Optional
from ledgrab.core.processing.color_strip_stream import ( from ledgrab.core.processing.color_strip_stream import (
ColorCycleColorStripStream,
ColorStripStream, ColorStripStream,
GradientColorStripStream, GradientColorStripStream,
PictureColorStripStream, PictureColorStripStream,
@@ -34,7 +33,6 @@ logger = get_logger(__name__)
_SIMPLE_STREAM_MAP = { _SIMPLE_STREAM_MAP = {
"static": StaticColorStripStream, "static": StaticColorStripStream,
"gradient": GradientColorStripStream, "gradient": GradientColorStripStream,
"color_cycle": ColorCycleColorStripStream,
"effect": EffectColorStripStream, "effect": EffectColorStripStream,
"api_input": ApiInputColorStripStream, "api_input": ApiInputColorStripStream,
"notification": NotificationColorStripStream, "notification": NotificationColorStripStream,
@@ -97,6 +97,30 @@ class CompositeColorStripStream(ColorStripStream):
def target_fps(self) -> int: def target_fps(self) -> int:
return self._fps return self._fps
@property
def actual_fps(self) -> Optional[float]:
"""Aggregate measured capture rate across capture-backed sub-streams.
Sums `actual_fps` from each sub-stream that reports one (i.e.
capture-backed layers like screen/audio captures). Returns
``None`` when no sub-stream measures capture keeps synthetic-
only composites out of the "Total Capture FPS" cell instead of
contributing a 0.
"""
with self._sub_lock:
subs = list(self._sub_streams.values())
total = 0.0
any_reporting = False
for _src_id, _consumer_id, stream in subs:
try:
v = getattr(stream, "actual_fps", None)
except Exception:
v = None
if isinstance(v, (int, float)):
total += float(v)
any_reporting = True
return total if any_reporting else None
def set_capture_fps(self, fps: int) -> None: def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps)) self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps self._frame_time = 1.0 / self._fps
@@ -0,0 +1,87 @@
"""Global daylight cycle preferences.
A single timezone applies to every daylight value source / color strip
source on the server, so it lives in the key/value settings table rather
than on each entity. The daylight streams read it on every wall-clock
sample (cheap dict lookup with a short cache window) so changing it in
the UI takes effect within ~1 second.
"""
from __future__ import annotations
import threading
import time
from typing import Optional
from ledgrab.utils import get_logger
logger = get_logger(__name__)
DAYLIGHT_TIMEZONE_KEY = "daylight_timezone"
_CACHE_TTL_SECONDS = 1.0
_lock = threading.Lock()
_cached_tz: str = ""
_cached_at: float = 0.0
def _read_from_db() -> str:
"""Read the persisted timezone from the settings table.
Returns an empty string when unset, the table is unavailable, or
the stored value is corrupt empty means "use system local time".
"""
try:
from ledgrab.api.dependencies import get_database
raw = get_database().get_setting(DAYLIGHT_TIMEZONE_KEY)
except Exception as e: # pragma: no cover — DB not initialised yet, e.g. in tests
logger.debug("daylight timezone DB read failed: %s", e)
return ""
if not isinstance(raw, dict):
return ""
value = raw.get("value")
return str(value) if isinstance(value, str) else ""
def get_daylight_timezone() -> str:
"""Return the configured global daylight timezone (cached briefly)."""
global _cached_tz, _cached_at
now = time.monotonic()
with _lock:
if now - _cached_at < _CACHE_TTL_SECONDS:
return _cached_tz
fresh = _read_from_db()
with _lock:
_cached_tz = fresh
_cached_at = now
return fresh
def set_daylight_timezone(tz: Optional[str]) -> str:
"""Persist the global daylight timezone and refresh the cache.
Returns the canonicalised stored value (empty string for None / blank).
"""
canonical = str(tz or "").strip()
try:
from ledgrab.api.dependencies import get_database
get_database().set_setting(DAYLIGHT_TIMEZONE_KEY, {"value": canonical})
except Exception as e:
logger.warning("Failed to persist daylight timezone: %s", e)
global _cached_tz, _cached_at
with _lock:
_cached_tz = canonical
_cached_at = time.monotonic()
return canonical
def invalidate_cache() -> None:
"""Force the next ``get_daylight_timezone`` call to re-read from DB."""
global _cached_at
with _lock:
_cached_at = 0.0
@@ -22,8 +22,33 @@ from ledgrab.utils import get_logger
from ledgrab.utils.frame_limiter import FrameLimiter from ledgrab.utils.frame_limiter import FrameLimiter
from ledgrab.utils.timer import high_resolution_timer from ledgrab.utils.timer import high_resolution_timer
try:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
except ImportError: # pragma: no cover — pre-3.9 fallback, not expected in target envs
ZoneInfo = None # type: ignore[assignment]
class ZoneInfoNotFoundError(Exception): # type: ignore[no-redef]
pass
logger = get_logger(__name__) logger = get_logger(__name__)
def _now_in_tz(tz_name: str) -> datetime.datetime:
"""Return current wall-clock time in the named IANA timezone.
Empty string means "use system local time". Unknown timezones fall
back to local time and log a warning once per unknown name.
"""
if not tz_name or ZoneInfo is None:
return datetime.datetime.now()
try:
return datetime.datetime.now(ZoneInfo(tz_name))
except ZoneInfoNotFoundError:
logger.warning(f"Unknown daylight timezone '{tz_name}' — falling back to system local")
return datetime.datetime.now()
# ── Daylight color table ──────────────────────────────────────────────── # ── Daylight color table ────────────────────────────────────────────────
# #
# Canonical hour control points (024) → RGB. Designed for a default # Canonical hour control points (024) → RGB. Designed for a default
@@ -62,13 +87,19 @@ _daylight_lut: Optional[np.ndarray] = None
# ── Solar position helpers ────────────────────────────────────────────── # ── Solar position helpers ──────────────────────────────────────────────
def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) -> tuple: def _compute_solar_times(
"""Return (sunrise_hour, sunset_hour) in local solar time. latitude: float,
longitude: float,
day_of_year: int,
utc_offset_hours: float = 0.0,
) -> tuple:
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time.
Uses simplified NOAA solar equations: Uses simplified NOAA solar equations:
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365) - declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
- hour angle: cos(ha) = -tan(lat) * tan(decl) - hour angle: cos(ha) = -tan(lat) * tan(decl)
- sunrise/sunset: 12 ha/15, shifted by longitude - solar noon (UTC): 12 - longitude/15
- wall-clock sunrise/sunset: solar_noon_utc + utc_offset ha/15
Polar day and polar night are clamped to visible ranges. Polar day and polar night are clamped to visible ranges.
""" """
@@ -79,28 +110,48 @@ def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) ->
lat_rad = latitude * deg2rad lat_rad = latitude * deg2rad
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad) cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
solar_noon_utc = 12.0 - longitude / 15.0
solar_noon_local = solar_noon_utc + utc_offset_hours
if cos_ha <= -1.0: if cos_ha <= -1.0:
# Polar day — sun never sets # Polar day — sun never sets; fake a long visible window
sunrise = 3.0 sunrise = solar_noon_local - 9.0
sunset = 21.0 sunset = solar_noon_local + 9.0
elif cos_ha >= 1.0: elif cos_ha >= 1.0:
# Polar night — sun never rises # Polar night — sun never rises; collapse to noon
sunrise = 12.0 sunrise = solar_noon_local
sunset = 12.0 sunset = solar_noon_local
else: else:
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0) ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
lon_offset = longitude / 15.0 sunrise = solar_noon_local - ha_hours
solar_noon = 12.0 - lon_offset sunset = solar_noon_local + ha_hours
sunrise = solar_noon - ha_hours
sunset = solar_noon + ha_hours
# Clamp to sane ranges # Clamp to a safe range the LUT builder can render. With reasonable
sunrise = max(3.0, min(10.0, sunrise)) # tz/longitude pairs sunrise lands in (3..10) and sunset in (14..21);
sunset = max(14.0, min(21.0, sunset)) # we widen the clamp so weird tz/lon combinations still produce a
# usable curve instead of dividing by zero.
sunrise = max(0.5, min(11.5, sunrise))
sunset = max(12.5, min(23.5, sunset))
return sunrise, sunset return sunrise, sunset
def _utc_offset_hours_for(tz_name: str, when: Optional[datetime.datetime] = None) -> float:
"""Return the UTC offset (in hours) for the given IANA timezone.
Empty/unknown tz falls back to the system local offset for ``when``.
"""
when = when or datetime.datetime.now()
if tz_name and ZoneInfo is not None:
try:
offset = when.replace(tzinfo=None).astimezone(ZoneInfo(tz_name)).utcoffset()
if offset is not None:
return offset.total_seconds() / 3600.0
except ZoneInfoNotFoundError:
pass
local_offset = when.astimezone().utcoffset()
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray: def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
"""Build a 1440-entry uint8 RGB LUT scaled to the given sunrise/sunset hours. """Build a 1440-entry uint8 RGB LUT scaled to the given sunrise/sunset hours.
@@ -198,9 +249,11 @@ class DaylightColorStripStream(ColorStripStream):
with self._colors_lock: with self._colors_lock:
self._colors: Optional[np.ndarray] = None self._colors: Optional[np.ndarray] = None
def _get_lut_for_day(self, day_of_year: int) -> np.ndarray: def _get_lut_for_day(self, day_of_year: int, utc_offset_hours: float = 0.0) -> np.ndarray:
"""Return a solar-time-aware LUT for the given day (cached).""" """Return a solar-time-aware LUT for the given day (cached)."""
sunrise, sunset = _compute_solar_times(self._latitude, self._longitude, day_of_year) sunrise, sunset = _compute_solar_times(
self._latitude, self._longitude, day_of_year, utc_offset_hours
)
sr_key = int(round(sunrise * 60)) sr_key = int(round(sunrise * 60))
ss_key = int(round(sunset * 60)) ss_key = int(round(sunset * 60))
cache_key = (sr_key, ss_key) cache_key = (sr_key, ss_key)
@@ -304,10 +357,16 @@ class DaylightColorStripStream(ColorStripStream):
buf = _buf_a if _use_a else _buf_b buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a _use_a = not _use_a
from ledgrab.core.processing.daylight_settings import (
get_daylight_timezone,
)
tz_name = get_daylight_timezone()
if self._use_real_time: if self._use_real_time:
now = datetime.datetime.now() now = _now_in_tz(tz_name)
day_of_year = now.timetuple().tm_yday day_of_year = now.timetuple().tm_yday
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0 minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
utc_offset_hours = _utc_offset_hours_for(tz_name, now)
else: else:
# Simulated: speed=1.0 → full 24h in 240s. # Simulated: speed=1.0 → full 24h in 240s.
# Use summer solstice (day 172) for maximum day length. # Use summer solstice (day 172) for maximum day length.
@@ -315,8 +374,9 @@ class DaylightColorStripStream(ColorStripStream):
cycle_seconds = 240.0 / max(speed, 0.01) cycle_seconds = 240.0 / max(speed, 0.01)
phase = (t % cycle_seconds) / cycle_seconds phase = (t % cycle_seconds) / cycle_seconds
minute_of_day = phase * 1440.0 minute_of_day = phase * 1440.0
utc_offset_hours = _utc_offset_hours_for(tz_name)
lut = self._get_lut_for_day(day_of_year) lut = self._get_lut_for_day(day_of_year, utc_offset_hours)
idx = int(minute_of_day) % 1440 idx = int(minute_of_day) % 1440
color = lut[idx] color = lut[idx]
buf[:] = color buf[:] = color
@@ -26,7 +26,9 @@ class HALightTargetProcessor(TargetProcessor):
self, self,
target_id: str, target_id: str,
ha_source_id: str, ha_source_id: str,
source_kind: str = "css",
color_strip_source_id: str = "", color_strip_source_id: str = "",
color_value_source_id: str = "",
brightness=None, brightness=None,
# legacy compat # legacy compat
brightness_value_source_id: str = "", brightness_value_source_id: str = "",
@@ -35,13 +37,16 @@ class HALightTargetProcessor(TargetProcessor):
transition=None, transition=None,
min_brightness_threshold: int = 0, min_brightness_threshold: int = 0,
color_tolerance: int = 5, color_tolerance: int = 5,
stop_action: str = "none",
ctx: Optional[TargetContext] = None, ctx: Optional[TargetContext] = None,
): ):
from ledgrab.storage.bindable import BindableFloat, bfloat from ledgrab.storage.bindable import BindableFloat, bfloat
super().__init__(target_id, ctx) super().__init__(target_id, ctx)
self._ha_source_id = ha_source_id self._ha_source_id = ha_source_id
self._source_kind = source_kind if source_kind in ("css", "color_vs") else "css"
self._css_id = color_strip_source_id self._css_id = color_strip_source_id
self._color_vs_id = color_value_source_id
# Accept BindableFloat or legacy string # Accept BindableFloat or legacy string
if brightness is not None and isinstance(brightness, BindableFloat): if brightness is not None and isinstance(brightness, BindableFloat):
self._brightness = brightness self._brightness = brightness
@@ -56,14 +61,20 @@ class HALightTargetProcessor(TargetProcessor):
self._update_rate = max(0.5, min(5.0, bfloat(update_rate, 2.0))) self._update_rate = max(0.5, min(5.0, bfloat(update_rate, 2.0)))
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0)) self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
self._color_tolerance = int(bfloat(color_tolerance, 5.0)) self._color_tolerance = int(bfloat(color_tolerance, 5.0))
self._stop_action = (
stop_action if stop_action in ("none", "turn_off", "restore") else "none"
)
# Runtime state # Runtime state
self._css_stream = None self._css_stream = None
self._color_stream = None # color-returning ValueStream (source_kind="color_vs")
self._ha_runtime = None self._ha_runtime = None
self._value_stream = None # brightness value source stream self._value_stream = None # brightness value source stream
self._previous_colors: Dict[str, Tuple[int, int, int]] = {} self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
self._previous_on: Dict[str, bool] = {} # track on/off state per entity self._previous_on: Dict[str, bool] = {} # track on/off state per entity
self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {} self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {}
# Snapshot of entity states captured at start() — used by "restore" stop action
self._captured_states: Dict[str, Any] = {}
self._ws_clients: List[Any] = [] self._ws_clients: List[Any] = []
self._start_time: Optional[float] = None self._start_time: Optional[float] = None
@@ -75,14 +86,23 @@ class HALightTargetProcessor(TargetProcessor):
if self._is_running: if self._is_running:
return return
# Acquire CSS stream # Acquire colour source — CSS stream OR colour value stream depending on mode.
if self._css_id and self._ctx.color_strip_stream_manager: if self._source_kind == "color_vs":
try: if self._color_vs_id and self._ctx.value_stream_manager:
self._css_stream = self._ctx.color_strip_stream_manager.acquire( try:
self._css_id, self._target_id self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
) except Exception as e:
except Exception as e: logger.warning(
logger.warning(f"HA light {self._target_id}: failed to acquire CSS stream: {e}") f"HA light {self._target_id}: failed to acquire color VS stream: {e}"
)
else:
if self._css_id and self._ctx.color_strip_stream_manager:
try:
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
self._css_id, self._target_id
)
except Exception as e:
logger.warning(f"HA light {self._target_id}: failed to acquire CSS stream: {e}")
# Acquire HA runtime # Acquire HA runtime
try: try:
@@ -104,6 +124,10 @@ class HALightTargetProcessor(TargetProcessor):
logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}") logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}")
self._value_stream = None self._value_stream = None
# Capture initial entity states for "restore" stop action.
# We always capture (cheap) so changing stop_action while running still works.
self._captured_states = self._snapshot_mapped_entity_states()
self._is_running = True self._is_running = True
self._start_time = time.monotonic() self._start_time = time.monotonic()
self._task = asyncio.create_task(self._processing_loop()) self._task = asyncio.create_task(self._processing_loop())
@@ -119,6 +143,14 @@ class HALightTargetProcessor(TargetProcessor):
pass pass
self._task = None self._task = None
# Run finalization (turn_off / restore) before releasing the HA runtime.
try:
await self._apply_stop_action()
except Exception as e:
logger.warning(
f"HA light {self._target_id}: stop_action '{self._stop_action}' failed: {e}"
)
# Release CSS stream # Release CSS stream
if self._css_stream and self._ctx.color_strip_stream_manager: if self._css_stream and self._ctx.color_strip_stream_manager:
try: try:
@@ -127,6 +159,14 @@ class HALightTargetProcessor(TargetProcessor):
pass pass
self._css_stream = None self._css_stream = None
# Release colour value stream (color_vs mode)
if self._color_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._color_vs_id)
except Exception:
pass
self._color_stream = None
# Release brightness value stream # Release brightness value stream
if self._value_stream is not None and self._ctx.value_stream_manager: if self._value_stream is not None and self._ctx.value_stream_manager:
try: try:
@@ -148,6 +188,7 @@ class HALightTargetProcessor(TargetProcessor):
self._previous_colors.clear() self._previous_colors.clear()
self._previous_on.clear() self._previous_on.clear()
self._latest_entity_colors.clear() self._latest_entity_colors.clear()
self._captured_states.clear()
self._ws_clients.clear() self._ws_clients.clear()
logger.info(f"HA light target stopped: {self._target_id}") logger.info(f"HA light target stopped: {self._target_id}")
@@ -177,13 +218,30 @@ class HALightTargetProcessor(TargetProcessor):
self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0)) self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0))
if "light_mappings" in settings: if "light_mappings" in settings:
self._light_mappings = settings["light_mappings"] self._light_mappings = settings["light_mappings"]
if "stop_action" in settings:
sa = settings["stop_action"]
if sa in ("none", "turn_off", "restore"):
self._stop_action = sa
# source_kind / color_value_source_id swap is handled here so that
# toggling modes (or repointing the colour VS) takes effect without
# restarting the target. CSS swaps continue to flow through
# update_css_source().
new_kind = settings.get("source_kind")
new_color_vs = settings.get("color_value_source_id")
kind_changed = new_kind in ("css", "color_vs") and new_kind != self._source_kind
color_vs_changed = new_color_vs is not None and new_color_vs != self._color_vs_id
if kind_changed or color_vs_changed:
self._swap_color_source(
new_kind if kind_changed else self._source_kind,
new_color_vs if new_color_vs is not None else self._color_vs_id,
)
def update_css_source(self, color_strip_source_id: str) -> None: def update_css_source(self, color_strip_source_id: str) -> None:
"""Hot-swap the CSS stream.""" """Hot-swap the CSS stream (only meaningful when source_kind='css')."""
old_id = self._css_id old_id = self._css_id
self._css_id = color_strip_source_id self._css_id = color_strip_source_id
if self._is_running and self._ctx.color_strip_stream_manager: if self._source_kind == "css" and self._is_running and self._ctx.color_strip_stream_manager:
try: try:
new_stream = self._ctx.color_strip_stream_manager.acquire( new_stream = self._ctx.color_strip_stream_manager.acquire(
color_strip_source_id, self._target_id color_strip_source_id, self._target_id
@@ -195,6 +253,52 @@ class HALightTargetProcessor(TargetProcessor):
except Exception as e: except Exception as e:
logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}") logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}")
def _swap_color_source(self, new_kind: str, new_color_vs_id: str) -> None:
"""Release the previous colour stream and acquire the new one."""
# Tear down previous stream first to keep ref-counts honest.
if self._is_running:
if self._css_stream and self._ctx.color_strip_stream_manager:
try:
self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
except Exception:
pass
self._css_stream = None
if self._color_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._color_vs_id)
except Exception:
pass
self._color_stream = None
self._source_kind = new_kind
self._color_vs_id = new_color_vs_id
# Reset per-entity history so the new source isn't gated by stale values.
self._previous_colors.clear()
self._previous_on.clear()
if not self._is_running:
return
if self._source_kind == "color_vs":
if self._color_vs_id and self._ctx.value_stream_manager:
try:
self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
except Exception as e:
logger.warning(
f"HA light {self._target_id}: failed to acquire color VS stream: {e}"
)
else:
if self._css_id and self._ctx.color_strip_stream_manager:
try:
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
self._css_id, self._target_id
)
except Exception as e:
logger.warning(
f"HA light {self._target_id}: failed to re-acquire CSS stream: {e}"
)
# ── WebSocket clients ── # ── WebSocket clients ──
def add_ws_client(self, ws: Any) -> None: def add_ws_client(self, ws: Any) -> None:
@@ -217,13 +321,16 @@ class HALightTargetProcessor(TargetProcessor):
"target_id": self._target_id, "target_id": self._target_id,
"processing": self._is_running, "processing": self._is_running,
"ha_source_id": self._ha_source_id, "ha_source_id": self._ha_source_id,
"source_kind": self._source_kind,
"css_id": self._css_id, "css_id": self._css_id,
"color_value_source_id": self._color_vs_id,
"is_running": self._is_running, "is_running": self._is_running,
"ha_connected": self._ha_runtime.is_connected if self._ha_runtime else False, "ha_connected": self._ha_runtime.is_connected if self._ha_runtime else False,
"light_count": len(self._light_mappings), "light_count": len(self._light_mappings),
"update_rate": self._update_rate, "update_rate": self._update_rate,
"fps_actual": self._update_rate if self._is_running else None, "fps_actual": self._update_rate if self._is_running else None,
"fps_target": self._update_rate, "fps_target": self._update_rate,
"fps_capture": self._update_rate if self._is_running else None,
"uptime_seconds": uptime, "uptime_seconds": uptime,
"entity_colors": entity_colors, "entity_colors": entity_colors,
} }
@@ -243,17 +350,28 @@ class HALightTargetProcessor(TargetProcessor):
} }
async def _processing_loop(self) -> None: async def _processing_loop(self) -> None:
"""Main loop: read CSS colors, average per mapping, send to HA lights.""" """Main loop: read source colour(s) and send to HA lights."""
interval = 1.0 / self._update_rate interval = 1.0 / self._update_rate
while self._is_running: while self._is_running:
try: try:
loop_start = time.monotonic() loop_start = time.monotonic()
if self._css_stream and self._ha_runtime and self._ha_runtime.is_connected: ha_ready = self._ha_runtime and self._ha_runtime.is_connected
colors = self._css_stream.get_latest_colors() if ha_ready:
if colors is not None and len(colors) > 0: if self._source_kind == "color_vs" and self._color_stream is not None:
await self._update_lights(colors) try:
color = self._color_stream.get_color()
except Exception:
color = None
if isinstance(color, (list, tuple)) and len(color) >= 3:
await self._update_lights_single_color(
int(color[0]), int(color[1]), int(color[2])
)
elif self._css_stream is not None:
colors = self._css_stream.get_latest_colors()
if colors is not None and len(colors) > 0:
await self._update_lights(colors)
# Sleep for remaining frame time # Sleep for remaining frame time
elapsed = time.monotonic() - loop_start elapsed = time.monotonic() - loop_start
@@ -266,99 +384,110 @@ class HALightTargetProcessor(TargetProcessor):
logger.error(f"HA light {self._target_id} loop error: {e}") logger.error(f"HA light {self._target_id} loop error: {e}")
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
async def _update_lights(self, colors: np.ndarray) -> None: def _read_brightness_multiplier(self) -> float:
"""Average LED segments and call HA services for changed lights.""" if self._value_stream is None:
led_count = len(colors) return 1.0
try:
return float(self._value_stream.get_value())
except Exception:
return 1.0
# Get brightness multiplier from value source (1.0 if not configured) async def _send_entity_color(
vs_multiplier = 1.0 self, mapping: HALightMapping, r: int, g: int, b: int, vs_multiplier: float
if self._value_stream is not None: ) -> None:
try: """Apply tolerance/threshold gates and push one entity update."""
vs_multiplier = self._value_stream.get_value() entity_id = mapping.entity_id
except Exception: # Cache for WS preview (always, even if HA call is skipped)
vs_multiplier = 1.0 self._latest_entity_colors[entity_id] = (r, g, b)
# Calculate brightness (0-255) from max channel
brightness = max(r, g, b)
bs = (
mapping.brightness_scale.value
if hasattr(mapping.brightness_scale, "value")
else mapping.brightness_scale
)
eff_scale = bs * vs_multiplier
if eff_scale < 1.0:
brightness = int(brightness * eff_scale)
should_be_on = (
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
)
prev_color = self._previous_colors.get(entity_id)
was_on = self._previous_on.get(entity_id, True)
if should_be_on:
new_color = (r, g, b)
if prev_color is not None and was_on:
dr = abs(r - prev_color[0])
dg = abs(g - prev_color[1])
db = abs(b - prev_color[2])
if max(dr, dg, db) < self._color_tolerance:
return # skip — colour hasn't changed enough
service_data = {
"rgb_color": [r, g, b],
"brightness": min(255, int(brightness * bs)),
}
transition_val = self._transition.value
if transition_val > 0:
service_data["transition"] = transition_val
await self._ha_runtime.call_service(
domain="light",
service="turn_on",
service_data=service_data,
target={"entity_id": entity_id},
)
self._previous_colors[entity_id] = new_color
self._previous_on[entity_id] = True
elif was_on:
await self._ha_runtime.call_service(
domain="light",
service="turn_off",
service_data={},
target={"entity_id": entity_id},
)
self._previous_on[entity_id] = False
self._previous_colors.pop(entity_id, None)
async def _update_lights(self, colors: np.ndarray) -> None:
"""CSS mode: average each mapping's LED segment and dispatch."""
led_count = len(colors)
vs_multiplier = self._read_brightness_multiplier()
for mapping in self._light_mappings: for mapping in self._light_mappings:
if not mapping.entity_id: if not mapping.entity_id:
continue continue
# Resolve LED range
start = max(0, mapping.led_start) start = max(0, mapping.led_start)
end = mapping.led_end if mapping.led_end >= 0 else led_count end = mapping.led_end if mapping.led_end >= 0 else led_count
end = min(end, led_count) end = min(end, led_count)
if start >= end: if start >= end:
continue continue
# Average the LED segment
segment = colors[start:end] segment = colors[start:end]
avg = segment.mean(axis=0).astype(int) avg = segment.mean(axis=0).astype(int)
r, g, b = int(avg[0]), int(avg[1]), int(avg[2]) await self._send_entity_color(
mapping, int(avg[0]), int(avg[1]), int(avg[2]), vs_multiplier
# Cache for WS preview (always, even if HA call is skipped)
self._latest_entity_colors[mapping.entity_id] = (r, g, b)
# Calculate brightness (0-255) from max channel
brightness = max(r, g, b)
# Apply brightness scale and value source multiplier
bs = (
mapping.brightness_scale.value
if hasattr(mapping.brightness_scale, "value")
else mapping.brightness_scale
)
eff_scale = bs * vs_multiplier
if eff_scale < 1.0:
brightness = int(brightness * eff_scale)
# Check brightness threshold
should_be_on = (
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
) )
entity_id = mapping.entity_id if self._ws_clients and self._latest_entity_colors:
prev_color = self._previous_colors.get(entity_id) await self._broadcast_entity_colors()
was_on = self._previous_on.get(entity_id, True)
if should_be_on: async def _update_lights_single_color(self, r: int, g: int, b: int) -> None:
# Check if color changed beyond tolerance """color_vs mode: push the same RGB triple to every mapping."""
new_color = (r, g, b) vs_multiplier = self._read_brightness_multiplier()
if prev_color is not None and was_on:
dr = abs(r - prev_color[0])
dg = abs(g - prev_color[1])
db = abs(b - prev_color[2])
if max(dr, dg, db) < self._color_tolerance:
continue # skip — color hasn't changed enough
# Call light.turn_on for mapping in self._light_mappings:
service_data = { if not mapping.entity_id:
"rgb_color": [r, g, b], continue
"brightness": min(255, int(brightness * bs)), await self._send_entity_color(mapping, r, g, b, vs_multiplier)
}
transition_val = self._transition.value
if transition_val > 0:
service_data["transition"] = transition_val
await self._ha_runtime.call_service(
domain="light",
service="turn_on",
service_data=service_data,
target={"entity_id": entity_id},
)
self._previous_colors[entity_id] = new_color
self._previous_on[entity_id] = True
elif was_on:
# Brightness dropped below threshold — turn off
await self._ha_runtime.call_service(
domain="light",
service="turn_off",
service_data={},
target={"entity_id": entity_id},
)
self._previous_on[entity_id] = False
self._previous_colors.pop(entity_id, None)
# Broadcast colors to WS clients
if self._ws_clients and self._latest_entity_colors: if self._ws_clients and self._latest_entity_colors:
await self._broadcast_entity_colors() await self._broadcast_entity_colors()
@@ -377,3 +506,103 @@ class HALightTargetProcessor(TargetProcessor):
dead.append(ws) dead.append(ws)
for ws in dead: for ws in dead:
self._ws_clients.remove(ws) self._ws_clients.remove(ws)
# ── Stop-action finalization ──
def _snapshot_mapped_entity_states(self) -> Dict[str, Any]:
"""Capture current state of every mapped entity from the HA cache."""
if not self._ha_runtime:
return {}
snap: Dict[str, Any] = {}
for mapping in self._light_mappings:
eid = mapping.entity_id
if not eid:
continue
state = self._ha_runtime.get_state(eid)
if state is not None:
snap[eid] = state
return snap
async def _apply_stop_action(self) -> None:
"""Run the configured finalization on stop."""
if self._stop_action == "none":
return
if not self._ha_runtime or not self._ha_runtime.is_connected:
logger.info(
f"HA light {self._target_id}: skipping stop_action "
f"'{self._stop_action}' — HA not connected"
)
return
# Unique entity ids (a target may map the same entity twice in theory)
entity_ids = []
seen = set()
for mapping in self._light_mappings:
eid = mapping.entity_id
if eid and eid not in seen:
seen.add(eid)
entity_ids.append(eid)
if not entity_ids:
return
if self._stop_action == "turn_off":
for eid in entity_ids:
await self._ha_runtime.call_service(
domain="light",
service="turn_off",
service_data={},
target={"entity_id": eid},
)
return
if self._stop_action == "restore":
for eid in entity_ids:
state = self._captured_states.get(eid)
if state is None:
continue
await self._restore_entity(eid, state)
async def _restore_entity(self, entity_id: str, state: Any) -> None:
"""Restore one light entity to a captured HAEntityState."""
if state.state == "off":
await self._ha_runtime.call_service(
domain="light",
service="turn_off",
service_data={},
target={"entity_id": entity_id},
)
return
if state.state != "on":
# unknown / unavailable — best effort: do nothing
return
attrs = state.attributes or {}
service_data: Dict[str, Any] = {}
# Color: prefer rgb_color, then hs_color, then color_temp, then nothing
rgb = attrs.get("rgb_color")
if isinstance(rgb, (list, tuple)) and len(rgb) >= 3:
service_data["rgb_color"] = [int(rgb[0]), int(rgb[1]), int(rgb[2])]
else:
hs = attrs.get("hs_color")
color_temp = attrs.get("color_temp")
color_temp_kelvin = attrs.get("color_temp_kelvin")
if isinstance(hs, (list, tuple)) and len(hs) >= 2:
service_data["hs_color"] = [float(hs[0]), float(hs[1])]
elif color_temp_kelvin is not None:
service_data["color_temp_kelvin"] = int(color_temp_kelvin)
elif color_temp is not None:
service_data["color_temp"] = int(color_temp)
brightness = attrs.get("brightness")
if brightness is not None:
service_data["brightness"] = int(brightness)
await self._ha_runtime.call_service(
domain="light",
service="turn_on",
service_data=service_data,
target={"entity_id": entity_id},
)
@@ -75,6 +75,16 @@ class MetricsHistory:
self._system: deque = deque(maxlen=MAX_SAMPLES) self._system: deque = deque(maxlen=MAX_SAMPLES)
self._targets: Dict[str, deque] = {} self._targets: Dict[str, deque] = {}
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
# Baselines for converting cumulative `errors_count` /
# `frames_skipped` into per-second rates inside the system ring
# buffer. None until the first sample arrives so we don't
# synthesize a fake initial spike from "0 → live count".
self._prev_total_errors: Optional[int] = None
self._prev_total_skipped: Optional[int] = None
# Same shape, but for the network throughput counter. Reset to
# None when the cumulative sum drops (target stopped, counter
# reset) so we never emit a negative rate.
self._prev_total_bytes_sent: Optional[int] = None
async def start(self): async def start(self):
"""Start the background sampling loop.""" """Start the background sampling loop."""
@@ -110,7 +120,6 @@ class MetricsHistory:
"""Collect one snapshot of system and target metrics.""" """Collect one snapshot of system and target metrics."""
# System metrics (blocking psutil/nvml calls in thread pool) # System metrics (blocking psutil/nvml calls in thread pool)
sys_snap = await asyncio.to_thread(_collect_system_snapshot) sys_snap = await asyncio.to_thread(_collect_system_snapshot)
self._system.append(sys_snap)
# Per-target metrics from processor states # Per-target metrics from processor states
try: try:
@@ -121,22 +130,151 @@ class MetricsHistory:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
active_ids = set() active_ids = set()
# Aggregates across running targets — mirrors the dashboard's
# frontend computation so the FPS / Capture FPS / Errors cells
# can seed their sparklines from this ring buffer and survive
# a page reload, the same way CPU / RAM already do.
total_fps = 0.0
total_capture_fps = 0.0
total_capture_fps_actual = 0.0
capture_actual_count = 0
total_fps_target = 0.0
total_errors_count = 0
total_frames_skipped = 0
running_count = 0
# Network / send-timing aggregates across running targets.
# `send_timing_*` reads "is the LED transport keeping up?" — a
# leading indicator of network congestion that fires before
# frames actually start dropping.
total_bytes_sent = 0
send_timing_max_ms = 0.0
send_timing_sum_ms = 0.0
send_timing_count = 0
for target_id, state in all_states.items(): for target_id, state in all_states.items():
active_ids.add(target_id) active_ids.add(target_id)
if target_id not in self._targets: if target_id not in self._targets:
self._targets[target_id] = deque(maxlen=MAX_SAMPLES) self._targets[target_id] = deque(maxlen=MAX_SAMPLES)
if state.get("processing"): if state.get("processing"):
running_count += 1
fps_actual = state.get("fps_actual")
if isinstance(fps_actual, (int, float)) and fps_actual > 0:
total_fps += float(fps_actual)
fps_capture = state.get("fps_capture")
if isinstance(fps_capture, (int, float)) and fps_capture > 0:
total_capture_fps += float(fps_capture)
fps_capture_actual = state.get("fps_capture_actual")
# `None` means the stream type doesn't measure capture
# (synthetic streams). Counted separately so the cell
# can read "0 of 0" vs "0 of N stalled".
if isinstance(fps_capture_actual, (int, float)):
total_capture_fps_actual += float(fps_capture_actual)
capture_actual_count += 1
fps_target = state.get("fps_target")
if isinstance(fps_target, (int, float)) and fps_target > 0:
total_fps_target += float(fps_target)
errors_count = state.get("errors_count")
if isinstance(errors_count, (int, float)) and errors_count > 0:
total_errors_count += int(errors_count)
frames_skipped = state.get("frames_skipped")
if isinstance(frames_skipped, (int, float)) and frames_skipped > 0:
total_frames_skipped += int(frames_skipped)
bytes_sent = state.get("bytes_sent")
if isinstance(bytes_sent, (int, float)) and bytes_sent > 0:
total_bytes_sent += int(bytes_sent)
send_timing = state.get("timing_send_ms")
if isinstance(send_timing, (int, float)) and send_timing >= 0:
send_timing_sum_ms += float(send_timing)
send_timing_count += 1
if send_timing > send_timing_max_ms:
send_timing_max_ms = float(send_timing)
self._targets[target_id].append( self._targets[target_id].append(
{ {
"t": now, "t": now,
"fps": state.get("fps_actual"), "fps": fps_actual,
"fps_current": state.get("fps_current"), "fps_current": state.get("fps_current"),
"fps_target": state.get("fps_target"), "fps_target": fps_target,
"timing": state.get("timing_total_ms"), "timing": state.get("timing_total_ms"),
"errors": state.get("errors_count", 0), "errors": state.get("errors_count", 0),
} }
) )
# Convert the cumulative error/skipped totals into per-second
# rates. Guard against the first sample (no previous baseline)
# and against counter resets when a target stops or restarts
# (delta < 0 → treat as 0).
errors_per_sec = 0.0
skipped_per_sec = 0.0
bytes_per_sec = 0.0
if self._prev_total_errors is not None:
delta = max(0, total_errors_count - self._prev_total_errors)
errors_per_sec = delta / SAMPLE_INTERVAL
if self._prev_total_skipped is not None:
delta = max(0, total_frames_skipped - self._prev_total_skipped)
skipped_per_sec = delta / SAMPLE_INTERVAL
if self._prev_total_bytes_sent is not None:
delta_b = max(0, total_bytes_sent - self._prev_total_bytes_sent)
bytes_per_sec = delta_b / SAMPLE_INTERVAL
self._prev_total_errors = total_errors_count
self._prev_total_skipped = total_frames_skipped
self._prev_total_bytes_sent = total_bytes_sent
# Device latency aggregates — pulled from the manager's
# device-health view rather than re-deriving from per-target
# state, so devices that are shared by multiple targets only
# count once.
device_latency_avg_ms: Optional[float] = None
device_latency_max_ms: Optional[float] = None
device_online_count = 0
device_total_count = 0
try:
health_dicts = self._manager.get_all_device_health_dicts()
except Exception as e:
logger.error("Failed to get device health: %s", e)
health_dicts = {}
latency_sum = 0.0
latency_n = 0
latency_max = 0.0
for _did, h in health_dicts.items():
device_total_count += 1
if h.get("device_online"):
device_online_count += 1
lat = h.get("device_latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
latency_sum += float(lat)
latency_n += 1
if lat > latency_max:
latency_max = float(lat)
if latency_n > 0:
device_latency_avg_ms = round(latency_sum / latency_n, 1)
device_latency_max_ms = round(latency_max, 1)
sys_snap["total_fps"] = round(total_fps, 1)
sys_snap["total_capture_fps"] = round(total_capture_fps, 1)
sys_snap["total_capture_fps_actual"] = round(total_capture_fps_actual, 1)
sys_snap["capture_actual_count"] = capture_actual_count
sys_snap["total_fps_target"] = round(total_fps_target, 1)
sys_snap["total_errors_count"] = total_errors_count
sys_snap["total_frames_skipped"] = total_frames_skipped
sys_snap["errors_per_sec"] = round(errors_per_sec, 3)
sys_snap["skipped_per_sec"] = round(skipped_per_sec, 3)
sys_snap["running_count"] = running_count
sys_snap["total_bytes_sent"] = total_bytes_sent
sys_snap["bytes_per_sec"] = round(bytes_per_sec, 1)
sys_snap["send_timing_avg_ms"] = (
round(send_timing_sum_ms / send_timing_count, 2) if send_timing_count > 0 else 0.0
)
sys_snap["send_timing_max_ms"] = round(send_timing_max_ms, 2)
sys_snap["send_timing_count"] = send_timing_count
sys_snap["device_latency_avg_ms"] = device_latency_avg_ms
sys_snap["device_latency_max_ms"] = device_latency_max_ms
sys_snap["device_online_count"] = device_online_count
sys_snap["device_total_count"] = device_total_count
self._system.append(sys_snap)
# Prune deques for targets no longer registered # Prune deques for targets no longer registered
for tid in list(self._targets.keys()): for tid in list(self._targets.keys()):
if tid not in active_ids: if tid not in active_ids:
@@ -167,6 +167,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
gradient_store=deps.gradient_store, gradient_store=deps.gradient_store,
event_bus=deps.game_event_bus, event_bus=deps.game_event_bus,
audio_processing_template_store=deps.audio_processing_template_store, audio_processing_template_store=deps.audio_processing_template_store,
sync_clock_manager=deps.sync_clock_manager,
) )
if deps.value_source_store if deps.value_source_store
else None else None
@@ -427,7 +428,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
self, self,
target_id: str, target_id: str,
ha_source_id: str, ha_source_id: str,
source_kind: str = "css",
color_strip_source_id: str = "", color_strip_source_id: str = "",
color_value_source_id: str = "",
brightness=None, brightness=None,
# legacy compat # legacy compat
brightness_value_source_id: str = "", brightness_value_source_id: str = "",
@@ -436,6 +439,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
transition=None, transition=None,
min_brightness_threshold: int = 0, min_brightness_threshold: int = 0,
color_tolerance: int = 5, color_tolerance: int = 5,
stop_action: str = "none",
) -> None: ) -> None:
"""Register a Home Assistant light target processor.""" """Register a Home Assistant light target processor."""
if target_id in self._processors: if target_id in self._processors:
@@ -446,13 +450,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
proc = HALightTargetProcessor( proc = HALightTargetProcessor(
target_id=target_id, target_id=target_id,
ha_source_id=ha_source_id, ha_source_id=ha_source_id,
source_kind=source_kind,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=brightness, brightness=brightness,
light_mappings=light_mappings or [], light_mappings=light_mappings or [],
update_rate=update_rate, update_rate=update_rate,
transition=transition, transition=transition,
min_brightness_threshold=min_brightness_threshold, min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance, color_tolerance=color_tolerance,
stop_action=stop_action,
ctx=self._build_context(), ctx=self._build_context(),
) )
self._processors[target_id] = proc self._processors[target_id] = proc
@@ -770,8 +777,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# ===== LIFECYCLE ===== # ===== LIFECYCLE =====
async def stop_all(self): async def stop_all(self, restore_devices: bool = True):
"""Stop processing and health monitoring for all targets and devices.""" """Stop processing and health monitoring for all targets and devices.
When ``restore_devices`` is False, processor tasks are cancelled
directly instead of going through ``proc.stop()`` (which sends
per-device auto_shutdown restore frames), and the global
idle-state restore loop is skipped. Used by the "Nothing"
shutdown action so lights freeze on their last frame regardless
of per-device auto_shutdown.
"""
await self._metrics_history.stop() await self._metrics_history.stop()
await self.stop_health_monitoring() await self.stop_health_monitoring()
@@ -781,18 +796,35 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
if rs.restart_task and not rs.restart_task.done(): if rs.restart_task and not rs.restart_task.done():
rs.restart_task.cancel() rs.restart_task.cancel()
# Stop all processors if restore_devices:
for target_id, proc in list(self._processors.items()): # Stop all processors (per-device auto_shutdown decides whether
if proc.is_running: # the prior device state is restored).
try: for target_id, proc in list(self._processors.items()):
await proc.stop() if proc.is_running:
except Exception as e: try:
logger.error(f"Error stopping target {target_id}: {e}") await proc.stop()
except Exception as e:
logger.error(f"Error stopping target {target_id}: {e}")
# Restore idle state for devices that have auto-restore enabled # Restore idle state for devices that have auto-restore enabled
# (serial devices already dark from processor close; WLED restored by snapshot) # (serial devices already dark from processor close; WLED restored by snapshot)
for device_id in self._devices: for device_id in self._devices:
await self._restore_device_idle_state(device_id) await self._restore_device_idle_state(device_id)
else:
# "Nothing" mode: cancel processor capture tasks without sending
# restore frames so the LEDs keep displaying the last frame.
# ``cancel_task`` (defined on ``TargetProcessor``) awaits the
# cancellation so the loop's current iteration completes — no
# half-written frame on the wire when the process exits.
for target_id, proc in list(self._processors.items()):
try:
await proc.cancel_task()
except Exception as e:
logger.error(f"Error cancelling task for target {target_id}: {e}")
logger.info(
"Shutdown action 'nothing': skipped device restore for %d target(s)",
len(self._processors),
)
# Close any cached idle LED clients (WLED only; serial has no cached clients) # Close any cached idle LED clients (WLED only; serial has no cached clients)
for did in list(self._idle_clients): for did in list(self._idle_clients):
@@ -16,6 +16,10 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
from ledgrab.utils import get_logger
logger = get_logger(__name__)
if TYPE_CHECKING: if TYPE_CHECKING:
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
from ledgrab.core.processing.live_stream_manager import LiveStreamManager from ledgrab.core.processing.live_stream_manager import LiveStreamManager
@@ -65,6 +69,13 @@ class ProcessingMetrics:
# Streaming liveness (HTTP probe during DDP) # Streaming liveness (HTTP probe during DDP)
device_streaming_reachable: Optional[bool] = None device_streaming_reachable: Optional[bool] = None
fps_effective: int = 0 fps_effective: int = 0
# Cumulative LED-payload bytes sent to the device. Aggregated across
# all running targets in MetricsHistory to derive a per-second
# network throughput sparkline. Counts the color-array payload only;
# protocol overhead (DDP/UDP/IP headers) is sub-5 % for any
# non-trivial LED count and is intentionally ignored to keep the
# counter cheap (`np.ndarray.nbytes`, no per-frame allocation).
bytes_sent: int = 0
@dataclass @dataclass
@@ -145,6 +156,32 @@ class TargetProcessor(ABC):
""" """
... ...
async def cancel_task(self) -> None:
"""Cancel the processing task without restoring device state.
Used by ``ProcessorManager.stop_all(restore_devices=False)`` at
server shutdown when the user has chosen "Nothing" LEDs should
keep displaying their last frame, so we skip the per-device
``stop()`` path that sends restore frames. We still flip
``_is_running`` and await the cancellation so the loop's current
iteration completes (no half-written frame on the wire).
Subclasses with extra non-device cleanup (e.g. live-stream
release) may override this; the default just stops the task.
"""
self._is_running = False
task = self._task
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception:
# Log but don't propagate — caller is shutting down.
logger.debug("Task raised during cancel_task", exc_info=True)
self._task = None
# ----- Settings ----- # ----- Settings -----
@abstractmethod @abstractmethod
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
from ledgrab.core.processing.live_stream_manager import LiveStreamManager from ledgrab.core.processing.live_stream_manager import LiveStreamManager
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.storage.audio_source_store import AudioSourceStore from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.storage.value_source import ValueSource from ledgrab.storage.value_source import ValueSource
from ledgrab.storage.value_source_store import ValueSourceStore from ledgrab.storage.value_source_store import ValueSourceStore
@@ -599,31 +600,61 @@ class DaylightValueStream(ValueStream):
speed: float = 1.0, speed: float = 1.0,
use_real_time: bool = False, use_real_time: bool = False,
latitude: float = 50.0, latitude: float = 50.0,
longitude: float = 0.0,
min_value: float = 0.0, min_value: float = 0.0,
max_value: float = 1.0, max_value: float = 1.0,
): ):
from ledgrab.core.processing.daylight_stream import _get_daylight_lut from ledgrab.core.processing.daylight_stream import _get_daylight_lut
self._lut = _get_daylight_lut() self._default_lut = _get_daylight_lut()
self._speed = speed self._speed = speed
self._use_real_time = use_real_time self._use_real_time = use_real_time
self._latitude = latitude self._latitude = latitude
self._longitude = longitude
self._min = min_value self._min = min_value
self._max = max_value self._max = max_value
self._start_time = time.perf_counter() self._start_time = time.perf_counter()
# Cache: (sr_min, ss_min) → LUT, mirroring DaylightColorStripStream
self._lut_cache: Dict[Tuple[int, int], np.ndarray] = {}
def _resolve_lut(self, day_of_year: Optional[int], utc_offset_hours: float) -> np.ndarray:
if day_of_year is None:
return self._default_lut
from ledgrab.core.processing.daylight_stream import (
_build_lut_for_solar_times,
_compute_solar_times,
)
sr, ss = _compute_solar_times(
self._latitude, self._longitude, day_of_year, utc_offset_hours
)
key = (int(round(sr * 60)), int(round(ss * 60)))
lut = self._lut_cache.get(key)
if lut is None:
lut = _build_lut_for_solar_times(sr, ss)
if len(self._lut_cache) > 8:
self._lut_cache.clear()
self._lut_cache[key] = lut
return lut
def get_value(self) -> float: def get_value(self) -> float:
from ledgrab.core.processing.daylight_settings import get_daylight_timezone
from ledgrab.core.processing.daylight_stream import _now_in_tz, _utc_offset_hours_for
tz_name = get_daylight_timezone()
if self._use_real_time: if self._use_real_time:
now = datetime.now() now = _now_in_tz(tz_name)
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0 minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
lut = self._resolve_lut(now.timetuple().tm_yday, _utc_offset_hours_for(tz_name, now))
else: else:
t_elapsed = time.perf_counter() - self._start_time t_elapsed = time.perf_counter() - self._start_time
cycle_seconds = 240.0 / max(self._speed, 0.01) cycle_seconds = 240.0 / max(self._speed, 0.01)
phase = (t_elapsed % cycle_seconds) / cycle_seconds phase = (t_elapsed % cycle_seconds) / cycle_seconds
minute_of_day = phase * 1440.0 minute_of_day = phase * 1440.0
lut = self._default_lut
idx = int(minute_of_day) % 1440 idx = int(minute_of_day) % 1440
r, g, b = self._lut[idx] r, g, b = lut[idx]
# BT.601 luminance → 0..1 # BT.601 luminance → 0..1
luminance = (0.299 * float(r) + 0.587 * float(g) + 0.114 * float(b)) / 255.0 luminance = (0.299 * float(r) + 0.587 * float(g) + 0.114 * float(b)) / 255.0
@@ -637,8 +668,10 @@ class DaylightValueStream(ValueStream):
self._speed = source.speed self._speed = source.speed
self._use_real_time = source.use_real_time self._use_real_time = source.use_real_time
self._latitude = source.latitude self._latitude = source.latitude
self._longitude = source.longitude
self._min = source.min_value self._min = source.min_value
self._max = source.max_value self._max = source.max_value
self._lut_cache.clear()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -669,10 +702,34 @@ class StaticColorValueStream(ValueStream):
) )
class AnimatedColorValueStream(ValueStream): def _ease_color_frac(t: float, easing: str) -> float:
"""Cycles through a list of colors over time.""" """Remap a 0..1 segment fraction through a named easing curve.
def __init__(self, colors, speed=10.0, easing="linear"): Unknown names fall back to linear so older configs and forward-compat
payloads keep working.
"""
if easing == "ease_in":
return t * t * t
if easing == "ease_out":
u = 1.0 - t
return 1.0 - u * u * u
if easing == "ease_in_out":
return t * t * (3.0 - 2.0 * t)
if easing == "sine":
return 0.5 - 0.5 * math.cos(math.pi * t)
return t
class AnimatedColorValueStream(ValueStream):
"""Cycles through a list of colors over time.
When a ``clock`` runtime is provided, animation is driven by the
clock's pause-aware elapsed time and speed multiplier so multiple
streams sharing the same clock stay in lockstep. When no clock is
set, falls back to wall-clock time scaled by ``speed`` (cycles/min).
"""
def __init__(self, colors, speed=10.0, easing="linear", clock=None):
self._colors = [ self._colors = [
(int(c[0]), int(c[1]), int(c[2])) (int(c[0]), int(c[1]), int(c[2]))
for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]]) for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
@@ -681,24 +738,47 @@ class AnimatedColorValueStream(ValueStream):
self._speed = max(0.01, float(speed)) self._speed = max(0.01, float(speed))
self._easing = easing self._easing = easing
self._start_time = 0.0 self._start_time = 0.0
self._clock = clock
# Last frame state — held while the clock is paused so get_color()
# returns a stable color instead of jumping.
self._last_phase = 0.0
def start(self) -> None: def start(self) -> None:
self._start_time = time.monotonic() self._start_time = time.monotonic()
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (atomic ref swap)."""
self._clock = clock
def get_value(self) -> float: def get_value(self) -> float:
r, g, b = self.get_color() r, g, b = self.get_color()
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
def get_color(self) -> tuple: def get_color(self) -> tuple:
elapsed = time.monotonic() - self._start_time clock = self._clock
cycle_time = 60.0 / self._speed
n = len(self._colors) n = len(self._colors)
if clock is not None:
# Clock provides real elapsed seconds (pause-aware) and a speed
# multiplier. We treat self._speed as the base cpm and apply the
# clock's speed on top, matching the convention used by CSS
# animation streams.
cycle_time = 60.0 / max(0.01, self._speed * float(clock.speed))
if not clock.is_running:
phase = self._last_phase
else:
elapsed = clock.get_time()
phase = (elapsed / cycle_time * n) % n
self._last_phase = phase
else:
elapsed = time.monotonic() - self._start_time
cycle_time = 60.0 / self._speed
phase = (elapsed / cycle_time * n) % n
self._last_phase = phase
if self._easing == "step": if self._easing == "step":
idx = int((elapsed / cycle_time * n) % n) return self._colors[int(phase) % n]
return self._colors[idx]
phase = (elapsed / cycle_time * n) % n
idx = int(phase) idx = int(phase)
frac = phase - idx frac = _ease_color_frac(phase - idx, self._easing)
c1 = self._colors[idx % n] c1 = self._colors[idx % n]
c2 = self._colors[(idx + 1) % n] c2 = self._colors[(idx + 1) % n]
return ( return (
@@ -1466,6 +1546,7 @@ class ValueStreamManager:
gradient_store: Optional[Any] = None, gradient_store: Optional[Any] = None,
event_bus: Optional["GameEventBus"] = None, event_bus: Optional["GameEventBus"] = None,
audio_processing_template_store=None, audio_processing_template_store=None,
sync_clock_manager: Optional["SyncClockManager"] = None,
): ):
self._value_source_store = value_source_store self._value_source_store = value_source_store
self._audio_capture_manager = audio_capture_manager self._audio_capture_manager = audio_capture_manager
@@ -1477,8 +1558,12 @@ class ValueStreamManager:
self._gradient_store = gradient_store self._gradient_store = gradient_store
self._event_bus = event_bus self._event_bus = event_bus
self._audio_processing_template_store = audio_processing_template_store self._audio_processing_template_store = audio_processing_template_store
self._sync_clock_manager = sync_clock_manager
self._streams: Dict[str, ValueStream] = {} # vs_id → stream self._streams: Dict[str, ValueStream] = {} # vs_id → stream
self._ref_counts: Dict[str, int] = {} # vs_id → ref count self._ref_counts: Dict[str, int] = {} # vs_id → ref count
# Tracks which clock_id (if any) was acquired for each stream so we
# can release/swap it without re-querying the store at teardown time.
self._stream_clock_ids: Dict[str, str] = {} # vs_id → clock_id
def acquire(self, vs_id: str) -> ValueStream: def acquire(self, vs_id: str) -> ValueStream:
"""Get or create a shared ValueStream for the given ValueSource. """Get or create a shared ValueStream for the given ValueSource.
@@ -1492,7 +1577,7 @@ class ValueStreamManager:
return self._streams[vs_id] return self._streams[vs_id]
source = self._value_source_store.get_source(vs_id) source = self._value_source_store.get_source(vs_id)
stream = self._create_stream(source) stream = self._create_stream(source, vs_id)
stream.start() stream.start()
self._streams[vs_id] = stream self._streams[vs_id] = stream
self._ref_counts[vs_id] = 1 self._ref_counts[vs_id] = 1
@@ -1512,6 +1597,7 @@ class ValueStreamManager:
if stream: if stream:
stream.stop() stream.stop()
del self._ref_counts[vs_id] del self._ref_counts[vs_id]
self._release_clock_for(vs_id)
logger.info(f"Released value stream {vs_id} (last ref)") logger.info(f"Released value stream {vs_id} (last ref)")
else: else:
logger.info(f"Released ref for value stream {vs_id} (refs={refs})") logger.info(f"Released ref for value stream {vs_id} (refs={refs})")
@@ -1527,8 +1613,53 @@ class ValueStreamManager:
stream = self._streams.get(vs_id) stream = self._streams.get(vs_id)
if stream: if stream:
stream.update_source(source) stream.update_source(source)
self._sync_clock_binding(vs_id, source, stream)
logger.debug(f"Updated value stream {vs_id}") logger.debug(f"Updated value stream {vs_id}")
def _sync_clock_binding(self, vs_id: str, source: "ValueSource", stream: ValueStream) -> None:
"""Hot-swap the sync-clock runtime attached to *stream* if needed."""
if not self._sync_clock_manager or not hasattr(stream, "set_clock"):
return
new_clock_id = getattr(source, "clock_id", None) or None
old_clock_id = self._stream_clock_ids.get(vs_id)
if new_clock_id == old_clock_id:
return
new_runtime = None
if new_clock_id:
try:
new_runtime = self._sync_clock_manager.acquire(new_clock_id)
except Exception as e:
logger.warning(
"Could not acquire sync clock %s for value stream %s: %s",
new_clock_id,
vs_id,
e,
)
new_runtime = None
new_clock_id = None
try:
stream.set_clock(new_runtime)
except Exception as e:
logger.warning("set_clock failed on value stream %s: %s", vs_id, e)
if new_clock_id:
self._stream_clock_ids[vs_id] = new_clock_id
else:
self._stream_clock_ids.pop(vs_id, None)
if old_clock_id:
try:
self._sync_clock_manager.release(old_clock_id)
except Exception as e:
logger.debug("Sync clock release for %s: %s", old_clock_id, e)
def _release_clock_for(self, vs_id: str) -> None:
"""Release the sync clock acquired for *vs_id* (if any)."""
clock_id = self._stream_clock_ids.pop(vs_id, None)
if clock_id and self._sync_clock_manager:
try:
self._sync_clock_manager.release(clock_id)
except Exception as e:
logger.debug("Sync clock release for %s: %s", clock_id, e)
def refresh_audio_filter_pipelines(self, template_id: str) -> None: def refresh_audio_filter_pipelines(self, template_id: str) -> None:
"""Rebuild audio filter pipelines for any running AudioValueStream """Rebuild audio filter pipelines for any running AudioValueStream
that references the given audio processing template ID. that references the given audio processing template ID.
@@ -1555,11 +1686,19 @@ class ValueStreamManager:
stream.stop() stream.stop()
except Exception as e: except Exception as e:
logger.error(f"Error stopping value stream {vs_id}: {e}") logger.error(f"Error stopping value stream {vs_id}: {e}")
# Release any sync clocks held by streams.
if self._sync_clock_manager:
for vs_id, clock_id in self._stream_clock_ids.items():
try:
self._sync_clock_manager.release(clock_id)
except Exception as e:
logger.debug("Sync clock release for %s during shutdown: %s", clock_id, e)
self._stream_clock_ids.clear()
self._streams.clear() self._streams.clear()
self._ref_counts.clear() self._ref_counts.clear()
logger.info("Released all value streams") logger.info("Released all value streams")
def _create_stream(self, source: "ValueSource") -> ValueStream: def _create_stream(self, source: "ValueSource", vs_id: Optional[str] = None) -> ValueStream:
"""Factory: create the appropriate ValueStream for a ValueSource.""" """Factory: create the appropriate ValueStream for a ValueSource."""
from ledgrab.storage.value_source import ( from ledgrab.storage.value_source import (
AdaptiveValueSource, AdaptiveValueSource,
@@ -1608,6 +1747,7 @@ class ValueStreamManager:
speed=source.speed, speed=source.speed,
use_real_time=source.use_real_time, use_real_time=source.use_real_time,
latitude=source.latitude, latitude=source.latitude,
longitude=source.longitude,
min_value=source.min_value, min_value=source.min_value,
max_value=source.max_value, max_value=source.max_value,
) )
@@ -1634,10 +1774,24 @@ class ValueStreamManager:
return StaticColorValueStream(color=source.color) return StaticColorValueStream(color=source.color)
if isinstance(source, AnimatedColorValueSource): if isinstance(source, AnimatedColorValueSource):
clock_runtime = None
if source.clock_id and self._sync_clock_manager:
try:
clock_runtime = self._sync_clock_manager.acquire(source.clock_id)
if vs_id is not None:
self._stream_clock_ids[vs_id] = source.clock_id
except Exception as e:
logger.warning(
"Could not acquire sync clock %s for value source %s: %s",
source.clock_id,
source.id,
e,
)
return AnimatedColorValueStream( return AnimatedColorValueStream(
colors=source.colors, colors=source.colors,
speed=source.speed, speed=source.speed,
easing=source.easing, easing=source.easing,
clock=clock_runtime,
) )
if isinstance(source, AdaptiveTimeColorValueSource): if isinstance(source, AdaptiveTimeColorValueSource):
@@ -82,10 +82,17 @@ class WledTargetProcessor(TargetProcessor):
self._resolved_display_index: Optional[int] = None self._resolved_display_index: Optional[int] = None
self._device_config = None # populated on start(), typed DeviceConfig self._device_config = None # populated on start(), typed DeviceConfig
# Fit-to-device linspace cache (per-instance to avoid cross-target thrash) # Fit-to-device cache (per-instance to avoid cross-target thrash).
# Holds precomputed floor/ceil source indices, fractional weights,
# and reusable scratch buffers so the per-frame interpolation runs
# entirely with in-place numpy ops — no allocations.
self._fit_cache_key: tuple = (0, 0) self._fit_cache_key: tuple = (0, 0)
self._fit_cache_src: Optional[np.ndarray] = None self._fit_floor_idx: Optional[np.ndarray] = None
self._fit_cache_dst: Optional[np.ndarray] = None self._fit_ceil_idx: Optional[np.ndarray] = None
self._fit_frac: Optional[np.ndarray] = None
self._fit_left_u8: Optional[np.ndarray] = None
self._fit_right_u8: Optional[np.ndarray] = None
self._fit_blend_f32: Optional[np.ndarray] = None
self._fit_result_buf: Optional[np.ndarray] = None self._fit_result_buf: Optional[np.ndarray] = None
# LED preview WebSocket clients # LED preview WebSocket clients
@@ -384,6 +391,69 @@ class WledTargetProcessor(TargetProcessor):
logger.debug("Device probe failed for %s: %s", device_url, e) logger.debug("Device probe failed for %s: %s", device_url, e)
return False return False
async def _run_liveness_probe_loop(self, device_url: str, probe_interval: float = 10.0) -> None:
"""Background loop that probes the device and updates adaptive state.
Runs independently from the per-frame processing loop so the hot
path doesn't pay for `_probe_task.done()` / scheduling checks every
iteration. Updates ``self._device_reachable``,
``self._metrics.device_streaming_reachable`` and (when adaptive FPS
is enabled) ``self._effective_fps`` directly.
"""
async with httpx.AsyncClient(timeout=httpx.Timeout(2.0)) as client:
while self._is_running:
try:
reachable = await self._probe_device(device_url, client)
except asyncio.CancelledError:
raise
except Exception:
reachable = False
prev_reachable = self._device_reachable
self._device_reachable = reachable
self._metrics.device_streaming_reachable = reachable
if self._adaptive_fps:
target_fps = self._target_fps if self._target_fps > 0 else 30
if not reachable:
old_eff = self._effective_fps
new_eff = max(1, self._effective_fps // 2)
if old_eff != new_eff:
self._effective_fps = new_eff
logger.warning(
"[ADAPTIVE] %s device unreachable, FPS %d%d",
self._target_id,
old_eff,
new_eff,
)
elif self._effective_fps < target_fps:
step = max(1, target_fps // 8)
old_eff = self._effective_fps
new_eff = min(target_fps, self._effective_fps + step)
if old_eff != new_eff:
self._effective_fps = new_eff
logger.info(
"[ADAPTIVE] %s device reachable, FPS %d%d",
self._target_id,
old_eff,
new_eff,
)
if prev_reachable != reachable:
logger.info(
"[PROBE] %s device %s",
self._target_id,
"reachable" if reachable else "UNREACHABLE",
)
# Cooperative sleep that promptly notices stop().
# Sleep in 0.5s chunks so cancellation latency stays < 0.5s.
slept = 0.0
while slept < probe_interval and self._is_running:
chunk = min(0.5, probe_interval - slept)
await asyncio.sleep(chunk)
slept += chunk
def get_display_index(self) -> Optional[int]: def get_display_index(self) -> Optional[int]:
"""Display index being captured, from the active stream.""" """Display index being captured, from the active stream."""
if self._resolved_display_index is not None: if self._resolved_display_index is not None:
@@ -399,8 +469,14 @@ class WledTargetProcessor(TargetProcessor):
fps_target = self._target_fps fps_target = self._target_fps
css_timing: dict = {} css_timing: dict = {}
css_capture_fps: Optional[int] = None
css_capture_fps_actual: Optional[float] = None
if self._is_running and self._css_stream is not None: if self._is_running and self._css_stream is not None:
css_timing = self._css_stream.get_last_timing() css_timing = self._css_stream.get_last_timing()
css_capture_fps = getattr(self._css_stream, "target_fps", None)
# `actual_fps` is None for synthetic streams (gradient/static/...)
# — only picture/audio/api-input style streams measure it.
css_capture_fps_actual = getattr(self._css_stream, "actual_fps", None)
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
# Picture source timing # Picture source timing
@@ -444,6 +520,9 @@ class WledTargetProcessor(TargetProcessor):
"fps_actual": metrics.fps_actual if self._is_running else None, "fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None, "fps_potential": metrics.fps_potential if self._is_running else None,
"fps_target": fps_target, "fps_target": fps_target,
"fps_capture": css_capture_fps,
"fps_capture_actual": css_capture_fps_actual,
"bytes_sent": metrics.bytes_sent if self._is_running else None,
"frames_skipped": metrics.frames_skipped if self._is_running else None, "frames_skipped": metrics.frames_skipped if self._is_running else None,
"frames_keepalive": metrics.frames_keepalive if self._is_running else None, "frames_keepalive": metrics.frames_keepalive if self._is_running else None,
"fps_current": metrics.fps_current if self._is_running else None, "fps_current": metrics.fps_current if self._is_running else None,
@@ -637,24 +716,57 @@ class WledTargetProcessor(TargetProcessor):
# ----- Private: processing loop ----- # ----- Private: processing loop -----
def _fit_to_device(self, colors: np.ndarray, device_led_count: int) -> np.ndarray: def _fit_to_device(self, colors: np.ndarray, device_led_count: int) -> np.ndarray:
"""Resample colors to match the target LED count.""" """Resample colors to match the target LED count.
Linear interpolation using floor/ceil source indices and fractional
weights all precomputed when ``(n, device_led_count)`` changes.
Per-frame work is two ``np.take`` calls and a few in-place ops on
pre-allocated scratch buffers. No per-frame allocations.
"""
n = len(colors) n = len(colors)
if n == device_led_count or device_led_count <= 0: if n == device_led_count or device_led_count <= 0:
return colors return colors
key = (n, device_led_count) key = (n, device_led_count)
if self._fit_cache_key != key: if self._fit_cache_key != key:
self._fit_cache_src = np.linspace(0, 1, n) if device_led_count > 1 and n > 1:
self._fit_cache_dst = np.linspace(0, 1, device_led_count) t = np.arange(device_led_count, dtype=np.float64) * (
self._fit_cache_key = key (n - 1) / (device_led_count - 1)
)
else:
t = np.zeros(device_led_count, dtype=np.float64)
floor_idx = np.floor(t).astype(np.int64)
np.clip(floor_idx, 0, n - 1, out=floor_idx)
ceil_idx = np.minimum(floor_idx + 1, n - 1)
frac = (t - floor_idx).astype(np.float32)[:, None] # (M, 1) for channel broadcast
self._fit_floor_idx = floor_idx
self._fit_ceil_idx = ceil_idx
self._fit_frac = frac
self._fit_left_u8 = np.empty((device_led_count, 3), dtype=np.uint8)
self._fit_right_u8 = np.empty((device_led_count, 3), dtype=np.uint8)
self._fit_blend_f32 = np.empty((device_led_count, 3), dtype=np.float32)
self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8) self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
buf = self._fit_result_buf self._fit_cache_key = key
for ch in range(min(colors.shape[1], 3)):
np.copyto( # Source slice: ColorStripStreams produce (N, 3); guard against (N, 4) RGBA.
buf[:, ch], rgb = colors[:, :3] if colors.ndim == 2 and colors.shape[1] > 3 else colors
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]),
casting="unsafe", left_u8 = self._fit_left_u8
) right_u8 = self._fit_right_u8
return buf blend = self._fit_blend_f32
out = self._fit_result_buf
# uint8 → uint8 take with `out=` — no allocation
np.take(rgb, self._fit_floor_idx, axis=0, out=left_u8)
np.take(rgb, self._fit_ceil_idx, axis=0, out=right_u8)
# Promote right to float32 in pre-allocated scratch
np.copyto(blend, right_u8, casting="unsafe") # blend = right (float32)
blend -= left_u8 # blend = right - left
blend *= self._fit_frac # blend = frac * (right - left)
blend += left_u8 # blend = left + frac * (right - left)
np.clip(blend, 0, 255, out=blend)
np.copyto(out, blend, casting="unsafe") # float32 → uint8
return out
async def _send_to_device(self, send_colors: np.ndarray) -> float: async def _send_to_device(self, send_colors: np.ndarray) -> float:
"""Send colors to LED device and return send time in ms.""" """Send colors to LED device and return send time in ms."""
@@ -663,6 +775,8 @@ class WledTargetProcessor(TargetProcessor):
self._led_client.send_pixels_fast(send_colors) self._led_client.send_pixels_fast(send_colors)
else: else:
await self._led_client.send_pixels(send_colors) await self._led_client.send_pixels(send_colors)
# Approximate network throughput counter (LED-payload bytes only).
self._metrics.bytes_sent += int(send_colors.nbytes)
return (time.perf_counter() - t_start) * 1000 return (time.perf_counter() - t_start) * 1000
@staticmethod @staticmethod
@@ -774,14 +888,16 @@ class WledTargetProcessor(TargetProcessor):
_diag_slow_iters: collections.deque = collections.deque(maxlen=50) _diag_slow_iters: collections.deque = collections.deque(maxlen=50)
_diag_iter_times: collections.deque = collections.deque(maxlen=300) _diag_iter_times: collections.deque = collections.deque(maxlen=300)
# --- Liveness probe + adaptive FPS --- # --- Liveness probe + adaptive FPS ---
# The probe runs as an independent task so the hot loop doesn't
# pay for per-iteration probe-state checks.
_device_url = self._device_config.device_url if self._device_config else "" _device_url = self._device_config.device_url if self._device_config else ""
_probe_enabled = _device_url.startswith("http") _probe_enabled = _device_url.startswith("http")
_probe_interval = 10.0 # seconds between probes
_last_probe_time = 0.0 # force first probe soon (after 10s)
_probe_task: Optional[asyncio.Task] = None _probe_task: Optional[asyncio.Task] = None
_probe_client: Optional[httpx.AsyncClient] = None
if _probe_enabled: if _probe_enabled:
_probe_client = httpx.AsyncClient(timeout=httpx.Timeout(2.0)) _probe_task = asyncio.create_task(
self._run_liveness_probe_loop(_device_url),
name=f"liveness-probe-{self._target_id}",
)
self._effective_fps = self._target_fps self._effective_fps = self._target_fps
self._device_reachable = None self._device_reachable = None
@@ -805,63 +921,8 @@ class WledTargetProcessor(TargetProcessor):
loop_start = now = time.perf_counter() loop_start = now = time.perf_counter()
target_fps = self._target_fps if self._target_fps > 0 else 30 target_fps = self._target_fps if self._target_fps > 0 else 30
# --- Liveness probe --- # Use effective FPS for frame timing. ``self._effective_fps``
# Collect result as soon as it's done (every iteration) # is mutated by the liveness probe task — read once.
if _probe_task is not None and _probe_task.done():
try:
reachable = _probe_task.result()
except Exception:
reachable = False
prev_reachable = self._device_reachable
self._device_reachable = reachable
self._metrics.device_streaming_reachable = reachable
_probe_task = None
if self._adaptive_fps:
if not reachable:
# Backoff: halve effective FPS
old_eff = self._effective_fps
self._effective_fps = max(1, self._effective_fps // 2)
if old_eff != self._effective_fps:
logger.warning(
f"[ADAPTIVE] {self._target_id} device unreachable, "
f"FPS {old_eff}{self._effective_fps}"
)
next_frame_time = time.perf_counter()
else:
# Recovery: gradually increase
if self._effective_fps < target_fps:
step = max(1, target_fps // 8)
old_eff = self._effective_fps
self._effective_fps = min(
target_fps, self._effective_fps + step
)
if old_eff != self._effective_fps:
logger.info(
f"[ADAPTIVE] {self._target_id} device reachable, "
f"FPS {old_eff}{self._effective_fps}"
)
next_frame_time = time.perf_counter()
if prev_reachable != reachable:
logger.info(
f"[PROBE] {self._target_id} device "
f"{'reachable' if reachable else 'UNREACHABLE'}"
)
# Fire new probe every _probe_interval seconds
if (
_probe_enabled
and _probe_task is None
and (now - _last_probe_time) >= _probe_interval
):
if _probe_client is not None:
_last_probe_time = now
_probe_task = asyncio.create_task(
self._probe_device(_device_url, _probe_client)
)
# Use effective FPS for frame timing
effective_fps = self._effective_fps if self._adaptive_fps else target_fps effective_fps = self._effective_fps if self._adaptive_fps else target_fps
self._metrics.fps_effective = effective_fps self._metrics.fps_effective = effective_fps
frame_time = 1.0 / effective_fps frame_time = 1.0 / effective_fps
@@ -981,8 +1042,8 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness) await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now _last_preview_broadcast = now
self._metrics.frames_skipped += 1 self._metrics.frames_skipped += 1
self._metrics.fps_current = _fps_current_from_timestamps()
await asyncio.sleep(SKIP_REPOLL) await asyncio.sleep(SKIP_REPOLL)
self._metrics.fps_current = _fps_current_from_timestamps()
continue continue
# Force-send preview when a new client just connected # Force-send preview when a new client just connected
@@ -1024,10 +1085,10 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness) await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now _last_preview_broadcast = now
self._metrics.frames_skipped += 1 self._metrics.frames_skipped += 1
self._metrics.fps_current = _fps_current_from_timestamps()
is_animated = stream.is_animated is_animated = stream.is_animated
repoll = SKIP_REPOLL if is_animated else frame_time repoll = SKIP_REPOLL if is_animated else frame_time
await asyncio.sleep(repoll) await asyncio.sleep(repoll)
self._metrics.fps_current = _fps_current_from_timestamps()
continue continue
prev_frame_ref = frame prev_frame_ref = frame
@@ -1150,9 +1211,9 @@ class WledTargetProcessor(TargetProcessor):
) )
raise raise
finally: finally:
# Clean up probe client # Stop the liveness probe task. ``_run_liveness_probe_loop``
if _probe_client is not None: # owns its own httpx.AsyncClient via ``async with`` so cancelling
await _probe_client.aclose() # the task closes the client cleanly.
if _probe_task is not None and not _probe_task.done(): if _probe_task is not None and not _probe_task.done():
_probe_task.cancel() _probe_task.cancel()
try: try:
@@ -588,6 +588,14 @@ class UpdateService:
"body": rel.body, "body": rel.body,
"prerelease": rel.prerelease, "prerelease": rel.prerelease,
"published_at": rel.published_at, "published_at": rel.published_at,
"assets": [
{
"name": a.name,
"size": a.size,
"download_url": a.download_url,
}
for a in rel.assets
],
} }
if rel if rel
else None else None

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