Compare commits

...

106 Commits

Author SHA1 Message Date
alexei.dolgolyov b83a72e63f chore: release v0.8.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 8s
Build Release / build-linux (push) Successful in 3m8s
Build Release / build-docker (push) Successful in 4m11s
Build Release / build-windows (push) Successful in 4m55s
Build Release / publish-release (push) Successful in 1s
2026-05-28 17:48:06 +03:00
alexei.dolgolyov 0d840adfca fix(ctypes): share wintypes.MSG with platform_detector to avoid argtype races
WindowsShutdownGuard was binding user32.GetMessageW.argtypes with
POINTER(_MSG) (project-local struct), while PlatformDetector's display-
power monitor binds it with POINTER(wintypes.MSG). argtypes is a
mutable global on the cached WinDLL handle, so whichever module
imported last won, and the other module's byref() then tripped
Python 3.13's strict argtype check with
"expected LP_MSG instance instead of pointer to _MSG".

The two structs are byte-identical (same field types in the same
order, just pt vs pt_x/pt_y naming) and we never touch the pt field,
so aliasing _MSG to wintypes.MSG eliminates the conflict — both
modules now bind the same POINTER class, the writes become idempotent,
and the full test suite passes regardless of import order.

CI runs on Linux so this never fired in release builds, but it broke
the local Windows test run.
2026-05-28 17:36:19 +03:00
alexei.dolgolyov 1f959932c1 fix(notification): allow clearing the sound on per-app overrides and main row
Previously, both the per-app override sound dropdown and the main
notification sound row only attached an EntitySelect when at least
one sound asset was registered — and never with allowNone. That left
users with no way to pick "no sound" once an entry existed, and made
the override dropdown silently inert before any assets were added.

Always construct the EntitySelect (so an empty assets list still
renders a usable, searchable input) and pass allowNone with the
localized none label so "no sound" is a first-class choice in both
the override list and the main row.
2026-05-28 17:28:34 +03:00
alexei.dolgolyov 10eb24b2ce docs: dashboard innerHTML reconciliation review notes
Design doc capturing the architectural pattern behind the perf-card
flicker (every innerHTML rewrite tears down the subtree, restarting
CSS animations / dropping focus / wasting layout) and the two
drivers — poll timers and server:* push events. Inventories every
remaining latent site in perf-charts.ts / dashboard.ts /
entity-events.ts / game-integration.ts, walks the decision ladder
from a setInnerHtmlIfChanged helper through hand-rolled cell
components to a Lit migration, and lands on Lit for polling-heavy
modules with entity-events.ts tab reconciliation sequenced ahead of
the dashboard cards because of its higher blast radius.

Planning artifact only — no implementation here.
2026-05-28 17:26:56 +03:00
alexei.dolgolyov 66b85b0175 fix(css-editor): persist notification_sound + notification_volume
The CSS editor modal collected every other notification field on
save but silently dropped notification_sound and notification_volume,
so toggling them in the modal had no effect on the saved strip.
Include both in the save payload alongside the existing notification
fields.
2026-05-28 17:26:44 +03:00
alexei.dolgolyov bc42604045 ci(release): publish release only after every build job uploads assets
create-release now creates the release as a draft so users never see
a release page that's missing artifacts (or, worse, missing the
sha256 sidecars that the in-app updater requires). A new
publish-release job runs after create-release, build-windows,
build-linux, and build-docker all succeed, and PATCHes the release
to draft=false in one step. If any build fails, the draft stays
hidden and can be deleted manually.
2026-05-28 17:26:28 +03:00
alexei.dolgolyov 3645216669 feat(icons): spectrum aperture icon set + dedicated tray variant
Regenerate the LedGrab icon family from a single Pillow script
(build/generate_icon.py): a rounded-square aperture traced by a
continuous RGB color-wheel stroke over a vignette canvas with a soft
chromatic bloom. 4x supersampled then downsampled per output for
crispness. Outputs 192/512 standard, 512 maskable (safe-area padded
for PWA round-crops), and a new 256 transparent-background tray
variant so the taskbar icon reads cleanly against light themes
instead of showing a dark tile.

icon.ico now embeds 16/24/32/48/64/128/256 frames sourced from the
transparent tray master, fixing the dark-square halo around the
file/taskbar icon on light Windows themes.

__main__ picks icon-tray.png for the tray and falls back to
icon-192.png when the tray asset isn't present (older bundles /
forks).
2026-05-28 17:26:18 +03:00
alexei.dolgolyov 85da2e538d feat(backup): bundle assets in ZIP + partial-write hardening + restart log
Auto-backups now produce a ZIP containing ledgrab.db plus every file
in the assets dir under assets/ — matching the manual
GET /api/v1/system/backup format, so restore accepts either output
interchangeably. Legacy .db backups remain listable, restorable, and
prunable; both extensions count toward max_backups.

Writes stage to <name>.partial then os.replace into place — a crash
mid-ZIP never leaves a half-written backup that masquerades as valid.
Stale .partials from prior crashes are swept on the next run.
Symlinks inside the assets dir are skipped so a hostile link can't
slurp a target outside the dir into every backup. Backups larger than
500 MB log a warning so operators notice unbounded asset growth before
disk fills up.

restart.py: redirect the spawned restart script's stdout/stderr to
restart.log and bail out early if the script is missing — silent
failures (PowerShell off PATH, restart.ps1 erroring) used to vanish
into a detached child with no diagnostic trail.

Tests cover happy path, asset bytes round-trip, partial cleanup,
None/missing assets_dir, failure rollback, stale-partial sweep,
symlink rejection, mixed legacy+new listing, and cross-format prune.
2026-05-28 17:25:55 +03:00
alexei.dolgolyov e4d24a02da fix(ctypes): pin LPMSG across MSG-pump prototypes for Python 3.13
Python 3.13's ctypes tightened argtypes checks and now rejects
mismatched POINTER(MSG) cache entries — each call to POINTER(MSG)
can return a class identity that doesn't match what byref() of an
instance produces, raising "expected LP_MSG instance instead of
pointer to _MSG" inside GetMessageW/TranslateMessage/DispatchMessageW.

Capture POINTER(MSG) once into LPMSG and reuse the same class object
across all three prototypes in both the WindowsShutdownGuard pump and
the PlatformDetector display-power monitor. Restores the 4 failing
win_shutdown tests.
2026-05-28 17:25:37 +03:00
alexei.dolgolyov bb3a316e35 refactor(frontend): shared API client + automations registry (audit M7, H8)
H8 — automations.ts rule-type registry
  Convert the two hand-rolled RuleType dispatch ladders into per-type
  registries (RULE_FIELD_RENDERERS + RULE_COLLECTORS) keyed by RuleType,
  joining the existing RULE_CHIP_RENDERERS. All three are typed
  Record<RuleType, ...> for compile-time exhaustiveness; an import-time
  _assertRuleHandlerCoverage() check logs loudly if any registry drifts
  from RULE_TYPE_KEYS — mirrors the backend's _RULE_HANDLERS shape, the
  one intentional divergence being that the frontend logs rather than
  throws (a thrown error at module import would brick the whole bundle,
  not just the editor).

M7 — shared API client + 35 file migrations
  New core/api-client.ts wrapping fetchWithAuth with typed apiGet /
  apiPost / apiPut / apiPatch / apiDelete. Auth, 401-relogin, retry,
  timeout, and the offline toast all stay owned by fetchWithAuth; the
  client just collapses the
  if (!resp.ok) { detail || HTTP <status> } ... resp.json()
  dance into one typed call. The detail unwrap is hardened to join
  FastAPI validation arrays instead of stringifying to [object Object].

  35 feature/core files migrated to it across many batches, reviewer-
  approved for behaviour parity in three passes covering the riskier
  divergences (bulk Promise.allSettled deletes, inline-error saves,
  array-detail joins, silent-failure GETs, immutable clones).

  9 files remain on fetchWithAuth — the big god-modules tied to the
  pending C8/C9/C10 splits (streams, settings, targets, dashboard,
  color-strips/index, graph-editor, assets, value-sources) plus
  pairing-flow which by design stays on raw fetch (branches on raw
  Response.status codes).

i18n — 14 new locale keys (en / ru / zh)
  Added save/load/delete error keys across automations, pattern,
  audio_processing, audio_template, templates, gradient, target,
  device namespaces, plus backfilled gradient.error.delete_failed into
  ru/zh. Scan confirms no hardcoded English errorMessage strings
  remain in the migrated diff.

AUDIT_REMAINING.md updated to reflect H6, H8, and M7 status.

Verified: tsc --noEmit clean + npm run build clean after every batch.
2026-05-28 14:58:08 +03:00
alexei.dolgolyov 49c35a2ea0 refactor(frontend): split types.ts into 18 per-entity files (audit H6)
Convert the 1140-LOC types.ts into a pure re-export barrel backed by
focused per-entity files under types/, joining the existing
bindable.ts. Every import { ... } from '../types.ts' resolves
unchanged; reviewer-confirmed all 102 type exports preserved.
2026-05-28 14:57:25 +03:00
alexei.dolgolyov ef1f9eade2 feat(android): production-readiness pass — security, perf, compat, UI/UX
Multi-axis lift to ship-quality after a full review:

Security
- ApiKeyManager: per-install random API key, persisted via SharedPreferences
  with synchronous first-write; threaded into uvicorn via the
  LEDGRAB_AUTH__API_KEYS env var; embedded in QR as a URL fragment (#k=)
  so it never appears in HTTP requests or server logs; frontend reads
  location.hash on first visit and strips it via history.replaceState
- Root.runAsRoot(argv: Array<String>) overload with POSIX shell-quoting to
  eliminate the shell-injection footgun (= excluded from unquoted-safe set)
- UsbSerialBridge: ContextCompat.RECEIVER_NOT_EXPORTED + intent.package
  check in the broadcast receiver for defence-in-depth across API levels
- Release builds refuse to silently fall back to debug keystore; require
  ANDROID_KEYSTORE_* env vars or explicit
  ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1
- Crash log retention capped at 10 entries
- Fatal-error stack trace hidden behind a toggle on the error screen

Performance
- ScreenCapture / RootScreenrecord reuse a single RGBA ByteArray per
  pipeline instead of allocating per frame — eliminates ~15 MB/s GC churn
  at 30 fps on low-end TV boxes
- Frame pacer switched from System.currentTimeMillis() + integer division
  (~30.3 fps drift) to SystemClock.elapsedRealtimeNanos with a catch-up
  accumulator
- ScreenCapture computes capture dimensions from source aspect ratio so
  non-16:9 displays don't get squashed
- RootScreenrecord input pump backs off 5 ms when MediaCodec is starved,
  ending a tight spin that burned a CPU core on decoder stalls
- QR cached by URL — onResume from background no longer rebuilds the
  560×560 bitmap each time
- ApiKey commit() pre-warmed off Main on app startup

Compatibility
- compileSdk / targetSdk bumped to 35 (Play Store requirement)
- armeabi-v7a build path added to build script + conditionally included
  in gradle splits when the matching wheel is present in android/wheels/
- Foreground service type declared as mediaProjection|specialUse with
  PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale; promotion via
  ServiceCompat.startForeground with the correct type per mode
- NetworkUtils picks Ethernet > Wi-Fi > VPN > cellular instead of just
  activeNetwork — fixes wrong-URL on TV boxes with both Ethernet + Wi-Fi
- enableOnBackInvokedCallback=true for Android 15 predictive-back
- Splash screen API via androidx.core:core-splashscreen — hides Chaquopy
  stdlib unpack delay on cold first launch

UI / UX
- All previously hardcoded English strings (root prompt, permission
  denial, fatal-error screen, notification text) now localised across
  en/ru/zh
- Monochrome notification icon (was a colored launcher → gray blob in
  status bar)
- 320×180 TV banner (was the square launcher → squashed on Leanback row)
- ViewStub-based running panel (deferred inflation)
- ObjectAnimator pulse on the Running status dot for liveness feedback
- "Starting…" button state while root is being probed
- Autostart checkbox hidden entirely on unrooted devices
- "No network" status when getLocalIpAddress returns null
- QR fallback hint text
- Animator cancelled in onStop to avoid leaking view hierarchy

Lifecycle hardening (from review)
- RootScreenrecord: processLock serialises EOF respawn vs concurrent
  stop() to prevent orphaned screenrecord processes
- CaptureService.restartRootPipeline: publish-before-start under
  @Synchronized to close the orphan window during watchdog restarts
- ScreenCapture.MediaProjection.Callback.onStop just flips
  running=false instead of calling stop() (which self-joined
  captureThread and hung 500 ms)
- updateUI early-returns when lateinit not initialised (fatal-error path)
- Watchdog give-up bound fixed (>= instead of >, was allowing 4 attempts)

server/android_entry.py accepts an optional api_key, sets
LEDGRAB_AUTH__API_KEYS={"android":<key>} as JSON before any LedGrab
import, logs a clear error if pydantic-settings parsing doesn't land
the value back in config (defensive guard against future settings
behaviour drift).

server/static/js/app.ts: bootstrap reads #k= from location.hash,
persists to localStorage, then strips via history.replaceState.

Two independent code-review passes; 147 relevant server tests still
pass; TypeScript and ruff clean.
2026-05-26 12:52:14 +03:00
alexei.dolgolyov 8bdcc17799 chore: release v0.7.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 11s
Build Release / build-linux (push) Successful in 2m54s
Build Release / build-docker (push) Successful in 3m50s
Build Release / build-windows (push) Successful in 4m36s
2026-05-26 00:35:38 +03:00
alexei.dolgolyov f591e258f7 fix(storage/database): reopen connection on lifespan restart
Database opened its sqlite3 connection eagerly in __init__ and closed it
in close(); the lifespan called close() on shutdown. In production this
is fine — the lifespan runs once per process. Under pytest the module-
level ``db`` singleton survives across every TestClient session, so the
second test file's lifespan startup hit
``sqlite3.ProgrammingError: Cannot operate on a closed database`` at
fixture-setup time (AutoBackupEngine.__init__ → db.get_setting("…")
was the first reader). 65 spurious "errors" on a full Windows pytest run.

- Database: extract _open() from __init__, add ensure_open() that
  reopens iff _conn is None, and have close() null _conn after the
  TRUNCATE checkpoint so re-close is idempotent.
- main.py lifespan startup: call db.ensure_open() before any setting
  read, so subsequent TestClient sessions get a live connection.
- tests/storage/test_database_reopen.py: pin the four invariants —
  close→ensure_open round-trips data, ensure_open is a no-op when
  open, close is idempotent, and using the DB after close without
  ensure_open raises (callers must opt in).

Full backend suite: 1551 pass / 1 skip / 0 errors. Ruff clean.
2026-05-26 00:26:36 +03:00
alexei.dolgolyov f6486f9b34 perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings
- dashboard: only destroy/recreate FPS charts for added/removed/detached
  targets; skip the history fetch when local samples already exist.
  Drops sync-clock `is_running` from the structure signature so toggles
  don't trigger a full rebuild; route clock/automation refresh through
  the in-place path.
- perf-charts: cache SVG skeleton per spark host and mutate node
  attributes instead of rewriting `innerHTML` every poll. Memoize
  patches/devices rendering by content signature so unchanged ticks
  no longer restart CSS animations. Skip render for env-hidden cards.
- perf-charts: switch `/system/performance` poll to `fetchWithAuth`,
  re-read `dashboardPollInterval` per tooltip move, and route the
  remaining hardcoded English strings ("no captures", "{n} total",
  "{rate} skipped/s", tooltip age, metric labels) through `t()`.
- locales: add `perf.no_captures`, `perf.captures_count`,
  `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`,
  `perf.tip.now`, `perf.tip.ago` in en/ru/zh.
2026-05-26 00:12:29 +03:00
alexei.dolgolyov 48dbdb90e9 docs(review-todo): check off items addressed in 2026-05-23 autonomous pass
Mark devices.py PATCH fix, WLED route-level test, IPv6 regression
test, IconSelect XSS audit, PEP-604 sweep, magic-number constants,
api/auth except specificity, and the (window as any) static-access
cleanup as done. Defer items are unchanged: performance items keep
their "profile first" caveat, Hue cert pinning + CSP keep the design-
sensitive note, architecture refactors keep the multi-day banner,
and i18n parity is now annotated with the exact missing-key counts
(328 ru / 325 zh) so the next translator pass has a clear scope.
2026-05-23 01:22:41 +03:00
alexei.dolgolyov 003517247f refactor(types): migrate (window as any) statics to typed window globals
59 sites across 19 feature modules switched from
`(window as any).foo` to the typed `window.foo` form against
global-types.d.ts. The 7 remaining sites use dynamic string indexing
(`window[fnName]`) where a typed access is impossible — those keep
the narrow cast and are documented as the legitimate exception in
the typedef file's header.

global-types.d.ts grows entries for `loadIntegrations`,
`loadUpdateSettings`, `loadUpdateStatus`, `initUpdateSettingsPanel`,
`onTestDisplaySelected`, `openSettingsModal`, `renderAboutPanel`,
`switchSettingsTab`. The `applyAccentColor` signature is widened to
accept the (accent, persist) call shape observed at the appearance
preset call site so tsc validates the real contract.
2026-05-23 01:22:29 +03:00
alexei.dolgolyov 888f8fd16e refactor(types): PEP-604 union sweep + UP007/UP045 enforcement
ruff --select UP007,UP045 --fix converted ~1760 sites across the
backend: `Optional[T]` → `T | None`, `Union[X, Y]` → `X | Y`. The
remaining module-level alias targets that ruff conservatively skips
(BindableFloatInput, ColorList, DeviceConfig) were converted by hand
earlier in the pass. black -formatted the result so the wider unions
fit cleanly under the 100-char line budget.

pyproject.toml now sets [tool.ruff.lint] extend-select = ["UP007",
"UP045"] so future legacy imports fire CI on every push. The
pre-commit ruff hook was bumped from v0.8.0 -> v0.15.12 to recognise
UP045 (split off from UP007 in v0.13).
2026-05-23 01:21:44 +03:00
alexei.dolgolyov ea7ee88490 refactor(api/auth): narrow WS exception catches + observability log
The 11 except Exception sites around websocket.send_json and
websocket.close are now except _WS_SEND_BENIGN_EXC — a narrow tuple of
WebSocketDisconnect, RuntimeError, ConnectionError, OSError. Real
programming errors (AttributeError, TypeError) no longer silently
disappear inside the handshake path. The receive_text branch grows a
narrow `(RuntimeError, ConnectionError, OSError)` case plus a final
`except Exception: logger.exception(...)` catch-all so genuinely
unexpected error shapes are recorded with a stack trace instead of
being swallowed.
2026-05-23 01:14:43 +03:00
alexei.dolgolyov d38021f061 refactor(processing): hot-path magic numbers -> named module constants
processed_stream lifts the 30-iteration filter recheck cadence to
_FILTER_RECHECK_EVERY_N_FRAMES with a comment explaining the 30 fps
trade-off. wled_target_processor lifts SKIP_REPOLL, the diagnostics
interval, and the CSPT recheck cadence to module-level
_SKIP_REPOLL_SLEEP_SECONDS, _DIAGNOSTICS_REPORT_INTERVAL_SECONDS, and
_CSPT_RECHECK_EVERY_N_ITERATIONS. Tests can monkeypatch them now.
2026-05-23 01:14:31 +03:00
alexei.dolgolyov 507e1385a6 feat(ui/icon-select): defence-in-depth XSS sanitiser on icon channel
Every IconSelect caller was audited: each builds item.icon from a
constant ICON_* literal, a lookup-table getter, or
renderDeviceIcon(stored_id) — none of which embed user input today.
The new sanitiseIcon() helper is the belt-and-braces guard for a
future caller that forgets the trusted-SVG contract: reject icon
strings containing <script>/<iframe>/<embed>/<object>/javascript:/on*=
markers, warn to the console, and fall back to empty so the cell still
renders the (escaped) label + desc.
2026-05-23 01:13:55 +03:00
alexei.dolgolyov 907bdaf043 test(url-scheme): WLED route-level integration + IPv6 regression
TestWLEDSchemeInference in test_devices_routes covers the POST/PUT
create-and-update flow with a stubbed WLED provider so the
infer_http_scheme integration hop has end-to-end coverage instead of
just the unit tests.

test_url_scheme grows public IPv6 (Cloudflare / Google / Quad9 DNS),
bracketed-form, and ULA cases. Adds an explicit pin for the Python
ipaddress documentation-prefix quirk (2001:db8::/32 is is_private,
so it routes to http:// even though some audits colloquially call it
"public").
2026-05-23 01:13:44 +03:00
alexei.dolgolyov 0dd8d430b9 fix(devices): preserve existing URL on PATCH-without-url
When a PATCH omits `url` (rename / icon-only edit), normalized_url
arrived at the processor as None and the manager kept whatever it had
cached — or refused to re-sync if it had nothing. Fall back to
existing.url so the processor is always told the current address.
Surfaced by the production-review backlog.
2026-05-23 01:13:13 +03:00
alexei.dolgolyov fd46c51dba docs: TODO + CLAUDE.md notes + locale keys for new features
TODO.md grows the device-support follow-up roadmap. CLAUDE.md trims a
stale section. en/ru/zh locales add the strings used by the new
HTTP-endpoint editor, MiniSelect labels, automations expansion, and
value-source kinds. Ru/zh parity for the older keys is tracked
separately in REVIEW_TODO.md.
2026-05-23 00:50:31 +03:00
alexei.dolgolyov ddae5719cf chore(frontend-infra): inbound-event allowlist + storage/state touch-ups
events-ws gains an inbound-event allowlist matching the new server-side
allowlist; test_events_ws_parity pins the two lists in sync.
state + storage modules and the streams / integrations /
z2m-light-targets / streams-*-templates editors absorb the
closeIfPristine guard alongside small UX fixes. css-editor template
picks up the new MiniSelect markup for the filter-kind picker.
2026-05-23 00:50:15 +03:00
alexei.dolgolyov 898912f8b1 chore(backend): MQTT/WLED/devices/capture/utils + api routes hardening
Bundle the remaining backend touch-ups that the production review
landed individually as small surgical edits across many modules:
- MQTT runtime: fire-and-forget task tracking + drain resilience.
- mqtt_source + store + storage/color_strip_source: secret_box
  encryption for credentials with auto-migration of plaintext fields.
- devices/discovery_watcher: task tracking on watcher start/stop.
- devices/wled_client + wled_provider: URL scheme inference helper
  applied at the create/update boundary so bare hostnames stay valid.
- core/capture/screen_capture: hardened error paths.
- core/processing (mapped/processed/processor_manager/video/wled_target):
  smaller follow-throughs from the registry refactor that landed
  earlier on the branch.
- utils/safe_source + utils/file_ops + utils/__init__: shared URL +
  IP classification helpers + larger streaming upload size caps.
- api/auth: WebSocket Origin allow-list + /docs auth-gate.
- api/dependencies: register the new HTTP-endpoint store.
- api/routes (assets, backup, webhooks): streaming-upload caps +
  asyncio.gather return_exceptions on broadcast loops.
- tests/test_api + tests/e2e/test_backup_flow: cover the new caps and
  the Origin allow-list.
2026-05-23 00:50:01 +03:00
alexei.dolgolyov 45d12b2811 feat(update-service): SSRF-validated redirects + restart hardening
update_service grows explicit URL validation on the redirect chain so a
hostile mirror can't bounce the updater to a private IP. restart.ps1
gets stricter argument handling and clearer log lines.
default_config.yaml exposes the new toggles. test_system_routes pins
the new behaviour.
2026-05-23 00:49:18 +03:00
alexei.dolgolyov 826e680f37 refactor(color-strip): rename static -> single + frontend follow-through
The "static" source kind always rendered a SINGLE color and the name
confused new code paths. Rename the module + kind to "single". Storage
keeps backward-compatible serialisation. Frontend color-strip cards /
gradient / index / test modules and the affected tests follow the new
name.
2026-05-23 00:49:00 +03:00
alexei.dolgolyov 737fd72b73 feat(value-sources): extend storage + schema + UI alongside new kinds
Storage model + Pydantic schema + route gain fields for the value-source
kinds introduced by the per-type factory refactor. Frontend editor adds
inputs for them and the modal template grows new field rows.
2026-05-23 00:48:48 +03:00
alexei.dolgolyov 3fe66d80cb feat(automations): expand automation rules + UI + engine coverage
Storage model + Pydantic schema + route surface gain the new rule
shapes the engine already supports. Frontend automations editor grows
the matching inputs. New core/test_automation_engine.py pins the
dispatch table rules behind ~285 lines of unit coverage.
2026-05-23 00:48:19 +03:00
alexei.dolgolyov f03cb303c3 feat(modal): closeIfPristine save-guard + per-editor adoption
Modal gains closeIfPristine(entityId): when editing an existing entity
and no tracked field has changed, the helper force-closes the modal
silently and returns true so the caller can skip the PUT and the
misleading "updated" toast. Each editor's save handler now early-returns
on the no-op edit path: advanced-calibration, assets,
audio-processing-templates, audio-sources, calibration, devices,
game-integration, ha-light-targets, home-assistant-sources,
mqtt-sources, pattern-templates, scene-presets, sync-clocks, targets,
weather-sources.
2026-05-23 00:48:00 +03:00
alexei.dolgolyov 9ff83bd6ca feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals
MiniSelect replaces the forbidden plain <select> in editors where the
option list isn't large enough to justify the full IconSelect grid. It
shares the IconSelect look-and-feel (chip + dropdown panel, keyboard
nav, search). IconSelect grows an explicit HTML-escape for item labels
and keeps `item.icon` documented as a trusted-SVG sink for callers
that build the icon string from constants. global-types.d.ts gives
existing window.* accesses real types so feature modules can stop
falling back to `(window as any)`. The modal.css additions style the
two selectors and the new dropdown panels.
2026-05-23 00:47:45 +03:00
alexei.dolgolyov d6cc80074d feat(http-endpoints): introduce HTTP endpoint output target stack
New output kind that POSTs the current strip frame to a user-configured
HTTP endpoint, alongside WLED / MQTT / Hue. Stack mirrors the existing
output-target shape end-to-end: storage model + store, FastAPI router +
Pydantic schemas, JS feature module + modal template, router wiring in
api/__init__.py and the modal include in index.html. Tests cover both
the routes and the store.
2026-05-23 00:47:31 +03:00
alexei.dolgolyov 06273ba2bc chore(tooling): vex semantic-search config + REVIEW_TODO backlog
Add .vex.toml so `vex` is the project's primary code-search backend with
auto-update + semantic embeddings enabled. Ignore the .fastembed_cache/
directory that vex creates on first --semantic run. REVIEW_TODO.md
captures items flagged by the multi-agent production review that were
deliberately deferred (multi-day refactors, profile-first perf, and
design-sensitive security work).
2026-05-23 00:46:44 +03:00
alexei.dolgolyov 628c6b2f0d docs: capture architecture-audit remainder for follow-up sessions
10 commits in this branch landed the data-safety bugs (C2, C11), the
worst parallel-change problems (C1/C3/C4/C6/C7), and 5 of the 9 HIGH
audit findings. The remainder splits cleanly into four follow-up
sessions: a frontend sprint (C8/C9/C10/H6/H7/H8/M7-M11/L1), a Device
redesign (H4), a BaseTargetProcessor ABC (C5/H5/M1/M2/L2), and polish
(M3/M6/M12/L3-L5).

This file documents what's left, the recommended ordering, and the
three registry patterns already in the codebase that new contributors
should reach for instead of writing fresh if/elif chains.
2026-05-23 00:36:39 +03:00
alexei.dolgolyov 2f15fbb752 refactor(output-targets): registry + coverage assertion for response builders
``_target_to_response`` in ``api/routes/output_targets.py`` used to be
an isinstance ladder over the three OutputTarget subclasses with a
silent fallback that fabricated a ``LedOutputTargetResponse`` for
unknown types (audit finding H3). The fallback masked exactly the
kind of bug we hit on the CSS side in Phase 1.1: a new target subclass
slipped past the ladder and got mis-shaped on the wire.

Replace the ladder with a ``_TARGET_RESPONSE_BUILDERS`` dict keyed by
the concrete subclass plus an import-time
``_assert_target_response_coverage()`` that requires the registry to
exactly match ``{WledOutputTarget, HALightOutputTarget,
Z2MLightOutputTarget}``. ``_target_to_response`` now raises
``RuntimeError`` instead of silently fabricating a LED response for an
unknown subclass — coverage is asserted at import so this branch is
unreachable in normal operation.

Tests: 5 new regression tests cover bijection between expected classes
and registered builders, callable shape, the rogue-target-raises
contract, and missing/extra entry rejection in the assertion. 24
existing output-target tests stay green; ruff clean.
2026-05-23 00:03:01 +03:00
alexei.dolgolyov c1aa2ebec5 fix(value-source): preserve store contract for game_event + error precedence
Two HIGH issues surfaced by review of 3b8f00e:

1. ``_build_game_event`` was newly succeeding where the old store
   raised ``ValueError("Invalid source type: game_event")``. The
   coverage-assertion-symmetry comment was honest about it being
   a path that didn't exist before, but silent broadening of the
   create contract is a real behaviour delta — any internal caller
   that previously caught the error would now succeed.

   Make ``_build_game_event`` raise NotImplementedError. The
   coverage assertion still passes (the entry exists), but the
   historical "you can't create game_event sources through the
   store" contract is preserved. game_event instances continue to
   be wired up by the game-integration setup path.

2. The new ``create_source`` ran ``_check_name_unique`` BEFORE
   ``build_source``. When both ``source_type`` is invalid AND
   ``name`` collides with an existing source, the old code raised
   ``"Invalid source type: …"`` first; the new code raised the
   name-collision error. Swap the order: build first (which
   validates source_type), then check name uniqueness, then
   persist. Bonus: a uuid is no longer minted for a source we end
   up rejecting on type.

New test pins the game_event NotImplementedError so a future
refactor doesn't accidentally re-open the create path.

38 value-source-store + factory tests stay green; ruff clean.
2026-05-23 00:00:30 +03:00
alexei.dolgolyov 3b8f00e3f9 refactor(value-source): per-type factories for create / update dispatch
ValueSourceStore.create_source used to be a ~260-line if/elif chain
over 14 source_type strings; update_source did the same dance again
with 14 isinstance branches (audit finding C7 store-side). Each
branch duplicated the common-fields scaffold and the per-type
defaulting + validation logic.

Lift each per-type create / update body into a free function in a
new ``storage.value_source_factories`` module:

  * ``CREATE_BUILDERS[source_type]`` — owns defaulting + per-type
    validation (HA needs ha_source_id + entity_id; gradient_map
    needs value_source_id; system_metrics validates against
    VALID_SYSTEM_METRICS; http rejects interval_s < 1; the two
    adaptive_* sub-modes route to the same AdaptiveValueSource
    class with different source_type discriminators).
  * ``UPDATE_APPLIERS[source_type]`` — mirrors the above on the
    update side; ``resolve_ref`` is applied to cross-entity
    references so empty-string clears keep working.
  * ``build_source(...)`` / ``apply_update(source, **kwargs)`` are
    the public entry points the store calls.
  * ``_assert_factory_coverage()`` runs at module import and
    requires BOTH registries to match storage's _VALUE_SOURCE_MAP
    exactly.

The store's ``create_source`` shrinks from ~260 lines to ~25;
``update_source`` from ~200 lines to ~40.

Tests: 14 new tests cover registry coverage in both directions
plus drift assertions, representative builder paths (static /
adaptive_time / adaptive_scene / ha_entity / http / unknown),
the AdaptiveValueSource dual-source-type discriminator, and
several applier paths including ``**_`` swallowing unknown kwargs
and HTTP zero-interval rejection. 47 existing value-source store
tests stay green; 769 storage / core / api tests in aggregate.
Ruff clean.
2026-05-22 23:56:10 +03:00
alexei.dolgolyov 05f73eedf9 refactor(types): extract bindable primitives into types/bindable.ts (H6 partial)
types.ts is 1159 lines of kitchen-sink discriminated-union definitions
(audit finding H6) shared across ~30 frontend modules. Splitting the
whole file in one pass would need careful per-group TypeScript wrangling
and verification across every entity shape; this commit lands the
first slice as a proof of pattern.

What changed
------------

* New ``static/js/types/bindable.ts`` owns ``BindableFloat`` /
  ``BindableColor`` plus their four accessor helpers
  (``bindableValue``, ``bindableSourceId``, ``bindableColor``,
  ``bindableColorSourceId``).
* ``static/js/types.ts`` keeps every interface and union shape that
  references those primitives, but the primitives themselves now come
  from the new file. Re-export keeps every existing ``import { ... }
  from '../types.ts'`` site working unchanged.

Why this slice first
--------------------

Bindable types and helpers are the most heavily-imported piece of
types.ts (~30 modules already use ``bindable*`` helpers) and they have
zero downstream dependencies — they don't reference any other type
group. That makes them the safest extraction and the cleanest
demonstration of the barrel-re-export pattern the remaining groups
(devices, sources, integrations, automations, templates, …) will
follow in a follow-up sprint.

Verification
------------

* ``npx tsc --noEmit`` clean (no compile errors anywhere in the
  frontend tree).
* ``npm run build`` clean (esbuild bundle and CSS bundle produced
  without warnings).

The remaining ~1130 lines of types.ts plus the C8/C9/C10 god-module
splits (value-sources.ts, streams.ts, graph-editor.ts) need a
dedicated frontend session with typescript-reviewer + manual UI
testing — deferring those rather than half-finishing them here.
2026-05-22 23:35:42 +03:00
alexei.dolgolyov 9f3f346543 refactor(value-source): MetricSpec registry for SystemMetricsValueStream
SystemMetricsValueStream used to dispatch on its ``self._metric`` string
across three independent if/elif chains (audit finding M5):

  * priming in ``start()`` (cpu_percent seed, initial network counter);
  * raw reading in ``_read_metric_psutil`` plus ``_read_metric_fallback``;
  * normalisation in ``_normalize`` (percent / min-max range / max-rate).

Adding a new metric meant editing all three chains plus the Android
fallback — and forgetting one branch made the metric silently return 0.

Lift each per-metric concern into a free function and register them as a
``MetricSpec(name, read_psutil, read_fallback, normalize, prime)`` in a
new ``core.processing.metric_readers`` module. Shared normalisers
(``_norm_percent`` / ``_norm_range`` / ``_norm_rate`` / ``_zero``) live
once. The stream's ``start()`` / ``_read_metric()`` / ``_normalize()``
collapse to a single registry lookup + delegation.

The stream still owns its mutable state (``_disk_path``,
``_sensor_label``, ``_gpu_unavailable``, ``_prev_net_bytes``,
``_prev_net_time``, etc.) — readers operate on the stream by
parameter, not by inheritance, so the kitchen-sink class shrinks by
~140 lines without losing the per-stream cadence bookkeeping. Each
spec function's docstring documents which fields it reads or mutates.

Tests: 16 new tests cover the 10-metric coverage set, callable shape
of every spec field, the three normaliser primitives' clamping +
divide-by-zero behaviour, prime-hook presence (only the three metrics
that need a baseline: cpu_load + network_rx + network_tx), and
fallback-path expectations (desktop-only sensors -> _zero, cpu/ram ->
real MetricsProvider).

754 existing core / storage / api tests stay green; ruff clean.
2026-05-22 23:29:33 +03:00
alexei.dolgolyov 98fb61d932 refactor(automations): rule dispatch via class-level handler table
AutomationEngine._evaluate_rule used to rebuild a 9-entry dispatch
dict on EVERY rule evaluation (audit finding H2). Unknown rule types
silently returned False — adding a new Rule subclass without an entry
just made it inert forever.

Refactor:

  * Per-rule-type bodies are now ``_handle_<kind>(self, rule, ctx)``
    methods on AutomationEngine.
  * A ``_RuleEvalContext`` frozen dataclass bundles all the
    cross-cutting state (running_procs, topmost_proc,
    topmost_fullscreen, fullscreen_procs, idle_seconds, display_state)
    so adding a new handler does not require widening
    ``_evaluate_rule``'s parameter list.
  * ``AutomationEngine._RULE_HANDLERS`` is bound once at module-import
    time after the class is defined.
  * ``_assert_rule_handler_coverage()`` runs at import: every Rule
    subclass imported by the module must have an entry, and entries
    keyed by an unknown class are also rejected.

Unknown-type fallback now logs a warning instead of silently returning
False, so a future Rule subclass missing from the registry surfaces in
operator logs rather than just behaving as if the automation were off.

The pure storage layer (storage/automation.py) is untouched — the
handler bodies stay on the engine where the cross-layer dependencies
(MQTT runtime, HA manager, HTTP endpoint store, webhook state) live.

Tests: 4 new tests cover the rule-type/handler bijection, callable
shape, missing-entry rejection, and unknown-class rejection. 44
existing automation engine tests stay green; ruff clean.
2026-05-22 23:07:07 +03:00
alexei.dolgolyov 5fec8db901 refactor(capture): lift duplicated edge-to-LED kernels into shared module
PixelMapper and AdvancedPixelMapper in calibration.py used to carry
byte-for-byte copies of two ~80-line numpy kernels (audit finding M4):

  * the vectorised average-colour-per-LED path with its cumsum + take
    scratch-buffer dance; and
  * the per-LED fallback loop for median / dominant colour modes.

Lift both into a new ``core.capture.edge_interpolation`` module exposing
``average_edge_to_leds(edge_pixels, edge_name, led_count, cache,
cache_key)`` and ``fallback_edge_to_leds(edge_pixels, edge_name,
led_count, calc_color)``. The cache parameter is the caller-owned dict
(``self._edge_cache``) so allocations still happen once per
(edge_len, led_count) signature — the difference is that the
boundary-builder, the buffer set, and the inner numpy ops live in
exactly one place.

PixelMapper keys its cache by edge name (``"top"`` / ``"left"`` etc.);
AdvancedPixelMapper keys by line-index int (same dict, no collision).
Both mappers' ``_map_edge_average`` / ``_map_edge_fallback`` shrink to
single delegating lines.

Tests: 9 new kernel-level tests cover uint8 dtype + shape, the cache
reuse / rebuild contract, independent cache keying, a gradient input
producing a monotonic output, the calc_color callable contract for the
fallback path, and segment-position tracking for both axes. 30
existing calibration tests stay green; ruff clean.
2026-05-22 23:03:44 +03:00
alexei.dolgolyov 97dae2cd62 refactor(processing): replace inline effect dispatch with @_effect_renderer registry
EffectColorStripStream._animate_loop used to rebuild a 12-entry dict
``renderers = {"fire": self._render_fire, ...}`` on every frame, then
look up ``renderers.get(self._effect_type, self._render_fire)``. Two
audit smells (H1) at once: per-frame dict-rebuild churn and a silent
fallback to fire whenever ``self._effect_type`` was a typo or any
``_render_*`` method got renamed without updating the dict.

Fix:

  * ``@_effect_renderer("fire")`` stamps an attribute on the unbound
    method.
  * ``@_collect_effect_renderers`` (applied to the class) walks
    members at class-creation, gathers the marked ones into
    ``cls._RENDERERS``, and raises ``RuntimeError`` on duplicate
    registration.

The loop now reads ``type(self)._RENDERERS`` once and calls the
unbound method with explicit ``self``. An unknown ``_effect_type``
logs a warning and skips the frame (sleep one frame_time) instead of
silently rendering fire — louder failure mode without crashing the
animation thread.

Tests: 5 new tests cover the 12-effect coverage set, callable shape,
class-level (not per-instance) dict identity, duplicate-name
rejection, and the marker stamp contract.

343 existing processing / storage / API tests stay green; ruff clean.
2026-05-22 23:00:00 +03:00
alexei.dolgolyov 29bdacf69a refactor(processing): dedupe HA/Z2M _swap_color_source via shared helper
HALightTargetProcessor and Z2MLightTargetProcessor used to carry
character-for-character identical _swap_color_source method bodies
(audit finding C5) — only the log prefix differed. Extract the body
into a free function ``swap_color_source(processor, new_kind,
new_color_vs_id, *, log_label)`` in a new ``light_target_helpers``
module. Each processor's _swap_color_source now delegates to the helper
and then clears its per-entity history (``_previous_colors`` /
``_previous_on``) — that bit stays on the processor because it's per-
target state, not colour-source state.

Scope deliberately narrower than the full BaseLightTargetProcessor ABC
the audit gestured at: the 76 read sites for the per-processor colour
state across the two files made a full state-composition refactor too
risky for the live LED control loop. The free-function helper is the
minimum-blast-radius way to delete the duplication while leaving WLED
(which has no value-stream-vs-CSS dispatch) untouched.

The helper standardises both warning messages on HA's original wording
("failed to acquire color VS stream" / "failed to re-acquire CSS
stream") so existing log alerts/grep patterns keep working.

A LightTargetSwapState Protocol under TYPE_CHECKING documents the
expected processor surface; no runtime enforcement (acceptable trade-
off vs a 76-site touchpoint).

Tests: 7 new tests cover the release+acquire ordering, the not-running
no-op path, the manager-error-swallowing behaviour, the empty-id
short-circuit, and the missing-manager (TargetContext(None, None))
fallback. 354 existing storage + API + e2e + processing tests stay
green; ruff clean.
2026-05-22 22:54:14 +03:00
alexei.dolgolyov 563cbac88c refactor(storage,processing): kind registries + versioned data migrations
Two CRITICAL data-safety bugs from the architecture audit and the two
worst parallel-change problems are fixed in one coherent pass.

Audit findings addressed:

- C2  silent CSS response fallback. The previous _RESPONSE_MAP fell
      through to a fabricated PictureCSSResponse whenever a source
      class lacked an entry; in particular game_event sources were
      silently mis-shaped. Now: GameEventCSSResponse/Create/Update
      schemas exist, _RESPONSE_MAP is re-keyed by source_type string,
      an import-time _assert_response_map_coverage() requires symmetric
      agreement with storage._SOURCE_TYPE_MAP, and the runtime path
      raises instead of fabricating a response.

- C11 string-replace JSON migration. ColorStripStore used
      blob.replace('"source_type": "static"', '"source_type":
      "single_color"') which can corrupt unrelated substrings (e.g.
      an animation type named "static_wave") and provides no audit,
      no transaction, no idempotency. Replaced with
      storage.data_migrations.MigrationRunner backed by a
      data_migrations audit table. Each migration runs inside one
      db.transaction() that covers the applied-check, the apply(),
      and the audit-INSERT — partial failures roll back atomically.
      StaticToSingleColorMigration parses each row with json.loads
      and mutates only the source_type field. Frozen-write databases
      skip with a warning.

- C3+C4 color-strip stream dispatch. The 7-branch elif in
      ColorStripStreamManager.acquire() and the duplicate one in
      ws_stream._create_stream() now share a single STREAM_BUILDERS
      registry in core.processing.color_strip_kinds, keyed by
      source.source_type. Both call sites populate a StreamDeps bag
      and delegate to build_stream(). _assert_stream_kind_coverage()
      asserts at import that STREAM_BUILDERS plus SHARABLE_KINDS
      partitions storage._SOURCE_TYPE_MAP. ws_stream's preview path
      wraps each FastAPI-DI getter in _safe() so non-audio previews
      no longer crash when audio/CSPT stores are not wired.

- C6+C7 value stream dispatch. The 14-branch isinstance ladder in
      ValueStreamManager._create_stream and its silent
      StaticValueStream(value=1.0) fallback are replaced by
      core.processing.value_kinds.STREAM_BUILDERS, keyed by
      source_type string (so AdaptiveValueSource's adaptive_time and
      adaptive_scene route to different builders correctly). The
      manager retains only the SyncClockRuntime pre-acquisition step
      for animated_color (kinds needing this are listed explicitly
      in NEEDS_CLOCK_RUNTIME). Symmetric coverage assertion plus a
      separate assertion that NEEDS_CLOCK_RUNTIME is a subset of the
      registry.

Bundled in: the static->single_color rename plus the HTTPValueStream
/ http_endpoint introduction that were already in flight on this
branch share these files; the registry refactor naturally absorbs
both via the new "single_color" / "static" alias entries and the
_build_http builder.

Tests: 26 new tests cover response-map coverage drift, migration
runner audit-table mechanics + transactional rollback +
frozen-write skip, and the two stream-builder registries. 343
existing storage / API / e2e tests stay green. Ruff clean.
2026-05-22 22:45:28 +03:00
alexei.dolgolyov e24f9d33cc fix(shutdown): survive PC restart with WAL fsync + Win32 session-end guard
Two bugs caused user data ('G502' target's color-strip ref, etc.) to
revert after PC restart while persisting fine across normal app
restarts:

1. SQLite was in WAL mode with synchronous=NORMAL and Database.close()
   was never called. On graceful Python exit the sqlite3 finalizer
   checkpoints the WAL, but on an unclean PC shutdown (power loss,
   forced reboot, or Windows force-terminating pythonw.exe) the WAL
   stayed in OS cache, never reached disk, and the next boot rolled the
   DB back to the last checkpoint -- losing recent edits.

2. Nothing handled WM_QUERYENDSESSION / WM_ENDSESSION, so on PC
   shutdown Windows force-killed pythonw.exe after ~5s and the FastAPI
   lifespan never ran. The 'stop_targets' setting was silently ignored
   and devices were left at their last frame.

Changes:
- Database: PRAGMA synchronous=FULL + wal_autocheckpoint=100, plus an
  explicit wal_checkpoint(TRUNCATE) inside Database.close().
- New utils/win_shutdown.py: hidden top-level window in a daemon thread
  with a ctypes WindowProc that catches WM_QUERYENDSESSION (calls
  ShutdownBlockReasonCreate to extend Windows' 5s hung-app timeout up
  to the ~20s GUI ceiling), fires the shutdown callback, then waits in
  WM_ENDSESSION on a completion event before returning. Also raises
  the process shutdown priority via SetProcessShutdownParameters. All
  Win32 argtypes/restypes are bound once at import to avoid LPARAM
  overflow on x64.
- New shutdown_state.py: leaf module owning the cross-thread Event so
  __main__ does not import the heavy ledgrab.main at startup.
- main.py lifespan: per-step asyncio.wait_for budgets (8s for
  processor_manager.stop_all, 1.5s each for HA/MQTT, etc.) so a hung
  device cannot starve the DB checkpoint, then db.close() and
  shutdown_complete.set() always run.
- __main__.py: install the Windows shutdown guard before tray start;
  install SIGINT/SIGTERM/SIGBREAK handlers only on the tray path
  (uvicorn overwrites them on no-tray); raise server_thread.join to 20s.
- Tests cover WM_QUERYENDSESSION (fires callback, returns TRUE,
  idempotent), WM_ENDSESSION (waits on event, times out cleanly,
  cancel-path returns instantly), signal handler installation, and
  that main and shutdown_state share the same Event instance.
2026-05-22 21:43:41 +03:00
alexei.dolgolyov e4bf58da19 fix(dashboard): stop showing perpetual MODIFIED for un-edited legacy layouts
The MODIFIED hint in the Customize Dashboard panel was driven by
`presetActive`, recomputed on every save/load via strict deep-equal
against each preset. Any drift between a saved layout and the current
defaults — older app versions that hadn't yet had some new perf cells
added, prior buggy merges that appended new registry keys to the end
of perfCells, or stale `visible` values from intermediate dev builds —
left `presetActive` undefined forever and pinned the panel in MODIFIED
state for users who had not actually edited anything.

Split the two concerns:

- `presetActive` keeps driving the chip highlight (recomputed). When
  the layout happens to match a preset exactly the chip lights up.
- New `userModified` boolean drives the MODIFIED indicator. Set to true
  only on actual edits through the panel (visibility / density /
  ordering / select changes) and on JSON import; cleared by applying a
  preset and by Reset.

Legacy saves without the field load as `userModified: false` so the
indicator no longer fires retroactively on data the user never
touched. Also tighten `_mergeWithDefaults` so newly-added registry
keys land at their canonical positions (subsequence detection) when
the saved order is consistent with defaults, which keeps the chip
highlight stable across upgrades.
2026-05-16 17:05:12 +03:00
alexei.dolgolyov f1b0f0eab2 fix(ui): repaint transport-bar uptime as soon as /health responds
The inline transport-uptime ticker only repainted on its 1 s setInterval,
so the field could sit on - for up to ~1 s after page load (and much
longer if init's first /health response landed between ticks - the next
seed then had to wait for the 10 s connection-monitor poll). Dispatch a
serverUptimeChanged DOM event when window.__serverUptime is seeded and
let the inline IIFE re-render on receipt, so the value appears as soon
as the response arrives.
2026-05-16 12:28:57 +03:00
alexei.dolgolyov 17684afba1 docs: record review-fix pass in TODO.md
Marks the pre-merge code review as complete in TODO.md and lists each
finding alongside the commit that closed it. Branch is in shape to merge.
2026-05-16 11:06:51 +03:00
alexei.dolgolyov 0e3ae78de7 fix(devices): address pre-merge review findings
Closes the issues surfaced by the pre-merge code review of the
expand-device-support branch.

CRITICAL #2 -- update_device double-encrypts secrets in memory.
storage/device_store.py round-tripped through device.to_dict() which
encrypts hue_username / hue_client_key / ble_govee_key / nanoleaf_token
via _enc(), but Device.__init__ does not decrypt. The cached
self._items[device_id] thus held ciphertext where plaintext belonged,
breaking runtime auth for paired devices on any update -- even an
innocuous rename. Sourcing kwargs from vars(device) directly avoids
the round-trip. Regression tests cover Nanoleaf and Hue.

HIGH #3 -- secrets leaked in GET /api/v1/devices response.
DeviceResponse previously returned nanoleaf_token / hue_username /
hue_client_key in plaintext (decrypted server-side from storage),
defeating the encryption-at-rest. Replaced with nanoleaf_paired and
hue_paired booleans. ble_govee_key intentionally stays -- it's a
user-managed value pasted from a third-party tool, must remain visible
for edit. Frontend types.ts + the one nanoleaf_token reader updated to
the boolean.

HIGH #4 -- SSRF surface. validate_lan_host() added to net_classify.py;
called from each new driver's validate_device (DDP / Yeelight / WiZ /
LIFX / Govee / OPC / Nanoleaf) and from pair_device. Rejects literal
public IPs with a descriptive ValueError; non-IP hostnames pass
through (mDNS labels, bare hostnames). RFC6890 ranges (documentation,
former class E) are accepted as LAN-like since Python's
ipaddress.is_private treats them so -- correct policy for LedGrab.

HIGH #5 -- decrypt failure deletes the device row. _dec() now catches
the exception, logs an error, and returns "" instead of propagating.
Without the fix, a regenerated data/.secret_key would silently make
every Hue / Nanoleaf / BLE-Govee device disappear from the device list
on next startup. Regression test asserts a corrupt envelope leaves the
device hydratable.

HIGH #6 -- update_device route does not rstrip("/") for non-WLED.
Moved the trim before the WLED-specific scheme inference so every
device type gets consistent URL normalization between create and
update.

MEDIUM #7 -- Govee discovery port 4002 collision. Added a lazily-
initialized module-level asyncio.Lock that serializes concurrent
discover_govee_devices() calls; the previous behavior had the second
parallel scan silently return [] when the first still held port 4002.
Error message also clarified to mention another Govee tool.

MEDIUM #8 -- Nanoleaf discover() leaked browser tasks on cancellation.
Moved the browser cancel loop into the finally block so an interrupted
mDNS scan still tears them down.

MEDIUM #9 -- pair endpoint logged user-supplied URL with exc_info=True.
Added _sanitize_url_for_log() that strips userinfo + fragment, and
demoted the log from exc_info to type(exc).__name__ + str(exc) so a
hostile receiver's response body can't end up in the log file.

LOW -- Nanoleaf was the only client without a .port property. Added
one (returns NANOLEAF_PORT, fixed) for cross-driver symmetry.

LOW -- no end-to-end pair-then-create coverage. Added
TestPairThenCreateFlow.test_pair_then_create_persists_encrypted_token
which exercises the full path: POST /api/v1/devices/pair returned
fields, store.create_device, then asserts (a) in-memory plaintext,
(b) to_config() plaintext, (c) persisted ciphertext, (d) API response
strip + paired-boolean.

Tests: 1379 pass (was 1358 -- 21 new regression tests added).
ruff clean. TypeScript clean.
2026-05-16 11:06:10 +03:00
alexei.dolgolyov 7736bc6f58 fix(utils): commit url_scheme + net_classify dependencies
The DDP commit (8f1140a) added imports of infer_http_scheme into
api/routes/devices.py but missed bringing in the module itself --
url_scheme.py and its net_classify.py dependency were in the working
tree as untracked files only. On a clean checkout the FastAPI app
fails to start with ModuleNotFoundError.

Caught by the pre-merge code review. The 1358 passing tests only
worked because the local working tree happens to have the files.

This commit adds:
- ledgrab.utils.url_scheme: infer_http_scheme() for LAN-vs-public WLED
  URL scheme inference
- ledgrab.utils.net_classify: HostCategory enum + classify_ip() +
  is_blocked_for_ssrf() + is_local_for_http_default() + is_loopback().
  Single source of truth for IP categorisation used by safe_source
  (SSRF), url_scheme (LAN), and auth (loopback exemption).
- 107 unit tests (test_url_scheme.py + test_net_classify.py).

net_classify.is_blocked_for_ssrf is the primitive the device-driver
validate_device methods will use in the next commit to close HIGH #4
from the review.
2026-05-16 10:46:45 +03:00
alexei.dolgolyov 390d2b472c docs: mark expand-device-support branch ready for merge
Updates TODO.md to reflect the verification pass outcome:
- pixel_reduce extraction marked done (commit cc87fba)
- pre-merge verification pass marked complete: 1358 pytest tests pass,
  ruff and black clean (against pre-commit-pinned 24.10.0),
  npx tsc --noEmit clean, bundle compiles
- Mi-Light / MiBoxer explicitly marked WONTDO with rationale
  (esp8266_milight_hub firmware -> existing MQTT target is the better
  path for modern Mi-Light deployments)
- duplicate Nanoleaf bullet removed; Twinkly noted as deferred

No code changes -- this is purely a checkpoint for the merge review.
2026-05-16 04:18:33 +03:00
alexei.dolgolyov cc87fba0dd refactor(devices): extract _average_color to pixel_reduce
Six single-pixel LED clients (Yeelight, WiZ, LIFX, Govee, Nanoleaf, BLE)
had byte-for-byte identical local copies of the strip-averaging helper.
Consolidates into core/devices/pixel_reduce.average_color so the next
single-pixel driver can drop the local copy and so behavior changes
land in one place.

Hue is intentionally left out -- its Entertainment API addresses up to
seven lights individually rather than averaging.

Behavior is byte-identical (each call site re-imports under the same
underscore-prefixed local name). 1358 tests still pass.
2026-05-16 04:14:36 +03:00
alexei.dolgolyov 426484adf8 feat(devices): Nanoleaf OpenAPI target type + first pair-flow user
Adds support for Nanoleaf controllers (Light Panels / Canvas / Shapes /
Lines / Elements) via the documented HTTP REST API on port 16021.
First concrete consumer of the pair-UX scaffold from commit 2f31680 --
the abstraction is no longer speculative.

Backend:
- NanoleafClient is a single-pixel HTTP adapter: averages the strip to
  one RGB triple, converts to Nanoleaf's HSB scale (H 0-360 / S 0-100 /
  B 0-100), and PUTs to /api/v1/<token>/state with duration:0 so
  transitions are instant for ambilight. Brightness is clamped to >=1
  because Nanoleaf rejects brightness=0.
- pair_nanoleaf(host) implements the two-step handshake: POST
  /api/v1/new during the 30-second pairing window the controller opens
  after the user holds the power button for 5 s.
    200 -> {auth_token: "..."}
    403 -> raises PairingNotReady ("Hold the power button...")
    other / transport error -> RuntimeError wrapping the cause
- NanoleafDeviceProvider.pair_device returns {nanoleaf_token: ...}
  forwarded by POST /api/v1/devices/pair to the frontend for inclusion
  in the subsequent create payload.
- mDNS discovery via _nanoleafapi._tcp (and the v1 variant); failures
  yield [] rather than raising.
- Health check probes /api/v1 without a token (401/403 still proves
  the host is alive).
- NanoleafConfig has nanoleaf_token + nanoleaf_min_interval_ms
  (default 100 ms = ~10 Hz; HTTP overhead caps practical max ~20 Hz).
- Auth token encrypted at rest via _enc/_dec, matching Hue / BLE-Govee.
- 42 unit tests cover URL parsing, RGB->HSB conversion, pairing
  handshake (200 / 403 / 500 / missing-token / transport-error),
  state mutations, brightness clamp, set_power / set_brightness /
  set_color, connection lifecycle, provider validate / pair /
  discover / capabilities, and Device.to_config round-trip including
  the encrypted-token roundtrip via to_dict + from_dict.

Frontend:
- 'nanoleaf' in DEVICE_TYPE_KEYS (next to 'govee'), HEXAGON icon
  (deliberate departure from the smart-bulb lightbulb family --
  Nanoleaf is panels, not bulbs, and the brand identity is hexagonal).
- isNanoleafDevice predicate + per-type field show/hide.
- Pair flow integration: when the device type is Nanoleaf, the add-
  device modal retitles its submit button to "Pair Device" and
  intercepts the submit. handleAddDevice awaits
  runPairingFlow({deviceType: 'nanoleaf', url}), merges result.fields
  ({nanoleaf_token}) into the create body, then POSTs. On
  PairingCancelled the user stays on the modal silently.
- Settings modal exposes the rate-limit field and a read-only
  "Paired" indicator reusing the pair-modal success badge. The token
  itself is never rendered to the DOM and never sent on update --
  re-pairing requires delete + re-add.
- Per-type pairing instructions in en/ru/zh
  (device.nanoleaf.pair.instructions) that the scaffold's i18n lookup
  resolves automatically.
- Bundle: +6.4 KiB (pairing-flow.ts was tree-shaken before this
  commit; now both it and the Nanoleaf branches are baked in).

The pair-UX scaffold is now proven, not speculative. Tuya and Twinkly
can follow the same shape when their phases arrive.
2026-05-16 03:59:38 +03:00
alexei.dolgolyov 2f31680823 feat(devices): pairing-UX scaffold (Phase 2)
Lays the groundwork for device families that require a one-time
physical pairing action (Nanoleaf hold-power-button, Tuya local-key
extraction, Twinkly network-setup mode, Hue link-button). No driver
uses it yet -- Nanoleaf will be the first concrete consumer.

Phase 2 as originally written had three bullets; only this one was
genuinely missing work. The other two (generic NetworkDiscoveryService
fan-out, unified scan-network UI) were already solved at the route
level by the existing /api/v1/devices/discover handler running all
providers in parallel via asyncio.gather(return_exceptions=True).
Marked WONTDO in TODO.md with rationale.

Backend:
- LEDDeviceProvider gains an async pair_device(url) -> dict method.
  Default raises NotImplementedError so missing implementations on a
  requires_pairing provider fail loud at request time.
- New PairingNotReady exception, distinct from generic errors so the
  route handler can return 409 (user must perform the physical action,
  retry possible) instead of 500.
- POST /api/v1/devices/pair endpoint with PairDeviceRequest /
  PairDeviceResponse schemas. Status-code mapping:
    200 -> paired, fields returned for the subsequent create payload
    400 -> unknown device type, or type doesn't support pairing
    409 -> PairingNotReady (retryable from the UI)
    422 -> invalid URL / device configuration (ValueError)
    502 -> transport / network failure (other exceptions)
    500 -> provider returned a non-dict (defensive)
- 8 route tests register a stub provider and exercise every
  status-code path.

Frontend:
- New modals/pair-device.html with five state blocks (idle / pairing
  / not_ready / success / failed) toggled via data-pair-state, plus
  a 30-second SVG progress ring with monospace countdown.
- New features/pairing-flow.ts exposing
  runPairingFlow({deviceType, url, instructionsKey?}) ->
  Promise<{fields: Record<string, unknown>>. Wires the modal to the
  pair endpoint, maps response codes to UI states, AbortControllers
  in-flight fetches on cancel. Exports a PairingCancelled sentinel
  error class.
- Generic pairing.* i18n keys in en/ru/zh. Drivers will add their own
  device.<type>.pair.instructions key that overrides the default.

Design decisions (per frontend-design skill):
- Single SVG ring + centered countdown (HomeKit-style)
- Instructions stay visible during pairing, dimmed to 60% via :has()
- Success state held 450 ms before auto-dismiss
- Cancel-X in the footer; primary action lives in the state block
- prefers-reduced-motion disables pulse/fade/ring transitions

Note: the components.css diff includes a pre-existing MiniSelect block
from the user's parallel work; pairing-specific styles are the second
hunk (lines ~1628+).
2026-05-16 03:26:53 +03:00
alexei.dolgolyov 31c6c3abb2 feat(devices): Open Pixel Control (OPC) target type
Adds support for Open Pixel Control receivers (Fadecandy boards,
xLights/Falcon endpoints, OPC bridges, art-installation controllers,
hobbyist LED driver software). OPC is a tiny TCP protocol on port
7890 with a 4-byte header [channel][cmd][len_hi][len_lo] + RGB body.

Backend:
- OPCClient opens one persistent TCP connection and streams frames as
  header+body byte pairs. Channel 0 broadcasts to every output on the
  OPC server; channels 1-255 address a specific channel on multi-output
  servers (Fadecandy with multiple Open Pixel chains).
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
  The fast path skips the async drain so the OS write-buffer flushes
  on its own schedule -- exactly what ambilight streaming wants.
- Brightness applies client-side before the frame is sent (OPC has no
  reply channel for hardware-side brightness).
- Health check opens a TCP connection and closes it.
- OPCConfig joins the typed config union; storage gains an opc_channel
  field; full to_dict/from_dict/to_config wiring.
- 36 unit tests cover URL parsing, header construction, send_pixels
  emitting header+body in order, brightness application, list and
  flat-array input shapes, drain behavior, connection lifecycle,
  provider validate/discover/capabilities, Device.to_config round-trip.

Frontend:
- 'opc' in DEVICE_TYPE_KEYS (next to 'ddp'), paper-plane icon -- same
  as DDP since both are open pixel-streaming protocols.
- isOpcDevice predicate + per-type field show/hide.
- Optional channel number input (default 0 = broadcast) with hint copy
  explaining the channel semantics.
- Locale strings in en/ru/zh.

No native discovery (OPC has no discovery protocol); users supply
the receiver IP manually.
2026-05-16 03:02:41 +03:00
alexei.dolgolyov 887131d4af feat(devices): Govee LAN target type
Adds support for Govee Wi-Fi smart bulbs and ambient-lighting kits via
their LAN API (opened in 2023). Discovery is multicast UDP on
239.255.255.250:4001; control commands go unicast to the device's port
4003; responses arrive on port 4002.

Each device requires "LAN Control" toggled ON in the Govee Home app
(Device -> settings -> LAN Control). Devices with LAN Control disabled
silently fail to appear in discovery and won't respond to commands; the
UI hint copy reminds users.

Backend:
- GoveeClient is a single-pixel UDP adapter: averages the strip to one
  RGB triple and pushes a 'colorwc' command with colorTemInKelvin=0 to
  select pure RGB mode (non-zero kelvin would switch the bulb to CCT
  mode and ignore the RGB values).
- Brightness folds into the RGB scaling so we burn one packet per
  frame instead of two.
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
  Default rate gate 50 ms (~20 Hz); UDP fire-and-forget tolerates it.
- Multicast discovery: scan request to 239.255.255.250:4001, listen on
  port 4002, parse the inner data dict for IP + device-id + SKU +
  firmware version. Degrades to [] when port 4002 is already bound or
  network is unavailable.
- Health check sends devStatus and waits 1.5s for any reply; the error
  message points at the LAN-Control toggle since that's the #1 root
  cause of silent failures.
- GoveeConfig joins the typed config union; storage gains
  govee_min_interval_ms; full to_dict/from_dict/to_config wiring.
- 40 unit tests cover URL parsing, scan-reply parsing (rejecting
  non-scan commands and malformed JSON), payload builders (colorwc
  with colorTemInKelvin=0, brightness clamping, power as 1/0 not
  true/false), strip averaging, rate limiting, fast-send hot path,
  provider validate/discover/health, Device.to_config round-trip.

Frontend:
- 'govee' in DEVICE_TYPE_KEYS (next to 'lifx'), lightbulb icon
  (deliberate smart-bulb family grouping).
- isGoveeDevice predicate + per-type field show/hide.
- Rate-limit number input (default 50 ms).
- URL hint copy explicitly instructs users to enable LAN Control in
  the Govee Home app -- the #1 source of "why isn't my Govee
  responding?" support churn.
- Locale strings in en/ru/zh.
2026-05-16 02:47:15 +03:00
alexei.dolgolyov 8f9d490063 feat(devices): LIFX LAN target type
Adds support for LIFX smart bulbs and lightstrips that speak the LIFX
binary UDP protocol on port 56700, with broadcast LAN discovery via the
standard GetService/StateService probe.

Backend:
- LIFXClient is a single-pixel UDP adapter: averages the strip to one
  RGB triple, converts to LIFX HSBK (16-bit hue/saturation/brightness +
  kelvin), and pushes a tagged SetColor packet so all bulbs on the
  subnet act on it. Brightness folds into the HSBK brightness channel.
- Hand-rolled packet builder: 36-byte LIFX header (frame +
  frame-address + protocol-header) + variable-length payload. Source
  ID 'LGGR' identifies LedGrab in protocol logs.
- supports_fast_send=True with a synchronous send_pixels_fast hot path
  -- UDP costs nothing, so the default rate gate is 50 ms (~20 Hz) to
  match LIFX's documented <=20 cmd/sec recommendation.
- Broadcast discovery sends GetService and parses StateService replies
  back into IP + MAC + service-port triples. Broadcast failures yield
  [] rather than raising.
- Health check sends GetService and waits 1.5s for any reply on a
  one-shot UDP socket.
- LIFXConfig joins the typed config union; Device storage gains a
  lifx_min_interval_ms field; full to_dict/from_dict/to_config wiring.
- 47 unit tests cover URL parsing, RGB->HSBK conversion (red/green/
  blue/white/black/clamping), packet construction (size, msg type,
  tagged flag, target MAC, sequence byte), SetColor and SetPower
  payload layouts, StateService reply parsing (including rejection
  of wrong msg types and runt payloads), strip averaging, rate
  limiting, fast-send hot path, provider validate/discover/health,
  and Device.to_config round-trip.

Frontend:
- 'lifx' in DEVICE_TYPE_KEYS (next to 'wiz'), lightbulb icon
  (deliberate smart-bulb family grouping with Hue + Yeelight + WiZ).
- isLifxDevice predicate + per-type field show/hide in create and
  settings modals.
- Rate-limit number input (default 50 ms) in both modals with hint
  text referencing LIFX's documented <=20 cmd/sec ceiling.
- Locale strings in en/ru/zh.

LIFX bulbs are reachable from the existing "Scan network" button -- no
new discovery UI affordance was needed. No brightness_control capability
exposed; LIFX brightness is folded into the HSBK on the wire.
2026-05-16 02:30:30 +03:00
alexei.dolgolyov ede627b4ac feat(devices): WiZ Connected LAN target type
Adds support for WiZ Connected (Philips' budget-tier) smart bulbs that
accept JSON commands as UDP datagrams on port 38899 with broadcast LAN
discovery on 255.255.255.255:38899.

Backend:
- WiZClient is a single-pixel UDP adapter: averages the incoming strip
  to one RGB triple and pushes it via setPilot with r/g/b params.
  Brightness folds into the RGB scaling so we burn one packet per frame
  instead of two.
- UDP fire-and-forget tolerates high update rates with no ack overhead,
  so the default rate gate is 50 ms (~20 Hz) -- 10x faster than Yeelight.
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
- Broadcast discovery sends the standard registration envelope; bulb
  replies are parsed for IP+MAC and surfaced as DiscoveredDevice
  entries. Broadcast failures (no network, firewall) yield [] rather
  than raising.
- Health check sends getPilot and waits 1.5s for any reply on a
  one-shot UDP socket.
- WiZConfig joins the typed config union; Device storage gains a
  wiz_min_interval_ms field; full to_dict/from_dict/to_config wiring.
- 36 unit tests cover URL parsing, MAC extraction, strip averaging,
  rate limiting, fast-send hot path, provider validate/discover/health,
  and Device.to_config round-trip.

Frontend:
- 'wiz' in DEVICE_TYPE_KEYS (next to 'yeelight'), lightbulb icon
  (deliberate smart-bulb family grouping with Hue + Yeelight).
- isWizDevice predicate + per-type field show/hide in create and
  settings modals.
- Rate-limit number input (default 50 ms) in both modals with hint
  text noting the UDP fire-and-forget characteristic.
- Locale strings in en/ru/zh.

WiZ bulbs are reachable from the existing "Scan network" button -- no
new discovery UI affordance was needed.
2026-05-16 02:12:01 +03:00
alexei.dolgolyov 4b65005823 feat(devices): Yeelight LAN target type
Adds support for Xiaomi/Yeelight smart bulbs and lightstrips that speak
the bulb-vendor's JSON-RPC protocol over TCP port 55443 with SSDP-style
LAN discovery on 239.255.255.250:1982.

Backend:
- YeelightClient is a single-pixel adapter: it averages the incoming
  strip down to one RGB triple, packs it into the 24-bit color int the
  bulb expects, and pushes it via set_rgb with sudden+0ms effect.
- Brightness folds into the RGB scaling on the wire so we burn one
  command per frame instead of two.
- A configurable client-side rate gate (yeelight_min_interval_ms, default
  500) keeps us under the bulb's ~1 cmd/sec cap. Frames that arrive
  inside the gate no-op without TX. Music mode (~60 Hz via reverse-TCP)
  is deferred -- the MVP caps at ~2 Hz and that's fine for a strip-to-
  single-pixel averaging device.
- SSDP discovery scans 239.255.255.250:1982 with the bulb-specific
  ST: wifi_bulb header; replies are parsed into DiscoveredDevice
  entries. Multicast failures (no network, firewall) yield [] rather
  than raising -- discovery is best-effort.
- Health check opens a TCP socket to the bulb and closes it.
- YeelightConfig joins the typed config union; Device storage gains a
  yeelight_min_interval_ms field; full to_dict/from_dict/to_config wiring.
- 34 unit tests cover URL parsing, RGB packing, strip averaging, rate
  limiting, SSDP response parsing, provider validate/discover/health,
  and Device.to_config round-trip.

Frontend:
- 'yeelight' in DEVICE_TYPE_KEYS (next to 'hue'), lightbulb icon
  (intentional family-grouping signal with Hue).
- isYeelightDevice predicate + per-type field show/hide in create and
  settings modals.
- Rate-limit number input (default 500 ms) in both modals with hint
  text explaining the trade-off.
- Locale strings in en/ru/zh.
- Drive-by: types.ts DeviceType union backfilled with 'ddp' and 'ble'
  for type-safety consistency.

Yeelight bulbs are now reachable from the existing "Scan network"
button -- no new discovery UI affordance was needed.
2026-05-16 01:44:13 +03:00
alexei.dolgolyov 8f1140abad feat(devices): standalone DDP target type
Promotes the existing DDP packet layer (previously WLED-internal) to a
first-class device type so any DDP-speaking receiver (Pixelblaze,
ESPixelStick, xLights/Falcon endpoints, generic firmware) can be driven
directly without WLED in the path.

Backend:
- New DDPLEDClient wraps the DDPClient transport as a proper LEDClient
  with supports_fast_send=True (synchronous UDP push on the hot loop).
- New DDPDeviceProvider — no native discovery, manual LED count,
  capabilities = {manual_led_count, health_check}.
- DDPConfig joins the typed config union; Device storage gains
  ddp_port / ddp_destination_id / ddp_color_order fields with safe
  defaults (0/1/1 -> port 4048, destination 1=display, RGB byte order).
- URL scheme: ddp://host[:port] or bare host[:port] (default 4048).
- Health check resolves the host via async DNS; UDP has no reply
  channel so reachability is best-effort by design.
- 29 new tests in test_ddp_led_client.py cover URL parsing, packet
  hot path (brightness, list/numpy input shapes, fast vs async send),
  provider validate/discover/capabilities, config round-trip via
  Device.to_config() and to_dict/from_dict.

Frontend:
- 'ddp' in DEVICE_TYPE_KEYS (next to 'dmx'), paper-plane icon.
- isDdpDevice predicate + per-type field show/hide in the create &
  settings modals.
- Color-order picker uses IconSelect (project rule bans plain select).
- Locale strings added in en/ru/zh.

Note: this commit also carries two pre-existing in-flight hunks that
were intermixed in the same files and could not be split out
non-interactively:
- api/routes/devices.py: URL-scheme inference for bare WLED hosts,
  safer error messages, exception-isolated parallel discovery.
- storage/device_store.py: secret_box helpers + at-rest encryption of
  Hue / BLE-Govee / MQTT credentials.
Both are independent of DDP and intentional per the user.
2026-05-16 01:26:45 +03:00
alexei.dolgolyov 337984c618 feat(color-strips): in-editor live preview for all viable source types
Extend the editor's Preview button to render unsaved form values for every
CSS source type that can be previewed without external calibration. New
types now supported transiently: audio, math_wave, weather, game_event,
api_input, mapped, composite, processed.

Backend (preview WebSocket):
- Dispatch in _create_stream by source_type, injecting the dependencies each
  stream needs (audio managers, weather manager, value stream manager, CSPT
  store via public get_cspt_store, color strip stream manager).
- Roll back clock + stream resources if start() fails so failed previews
  don't leak refs.
- On source_type change mid-preview, drop the rebuilt-stream reference if
  rebuild fails and close the WS rather than poll a stopped stream.

Stream lifecycle fixes flushed out by the new preview paths:
- MappedColorStripStream and ProcessedColorStripStream now stamp a per-
  instance UUID into the sub-stream consumer_id so concurrent consumers
  (multiple preview WS connections) don't collide in the CSM registry.
- ProcessedColorStripStream.update_source now re-acquires the input stream
  when input_source_id changes (previously silently kept the old input).

Frontend:
- Expand _PREVIEW_TYPES; route non-quirky types through a new exported
  getCSSEditorPreviewPayload helper that reuses the existing per-type
  handler registry.
- For picture / picture_advanced / key_colors (which depend on calibration
  or rectangles edited elsewhere), show a clearer "save the source first"
  message instead of the generic "unsupported" toast.
2026-05-16 00:40:26 +03:00
alexei.dolgolyov 530316c2c3 feat(mqtt): multi-broker MQTT + Zigbee2MQTT light target
- New Z2MLightOutputTarget storage, processor, editor and routes for
  Zigbee2MQTT light entities (shares the HA-Light editor UI via the new
  light-target-editor module)
- Replace global MQTTService/MQTTConfig with per-source MQTTManager +
  MQTTRuntime; thread mqtt_source_id through Z2M targets, DIY MQTT
  devices, and the automation engine
- Migrate legacy single-broker YAML/env config to a "Default Broker"
  MQTTSource on startup (core/mqtt/legacy_migration.py) and drop the
  obsolete core/mqtt/mqtt_service.py
- Refresh /api/v1/system integration status to surface every MQTT source
- Extract shared light-target editor and refactor OutputTargetStore +
  output_targets routes around typed factories / auto-registry
- Modal CSS polish, locale strings, and storage/bindable test coverage
2026-05-12 18:06:09 +03:00
alexei.dolgolyov 6e4c1b6642 perf(wled): cache per-frame max-pixel for brightness threshold
Cache the np.max(frame) result keyed on frame identity. The
min_brightness_threshold check ran a full reduction over the LED
array on every loop iteration even when the frame reference was
unchanged — at 60 fps with multiple targets this added up to
hundreds of redundant reductions per second.
2026-05-12 15:06:34 +03:00
alexei.dolgolyov ee4fa81376 perf(processing): event-driven frame hand-off and scheduling fixes
- LiveStream: add frame_id counter + Condition with wait_for_new_frame()
  helper. Producers (ScreenCaptureLiveStream, ProcessedLiveStream,
  StaticImageLiveStream, VideoCaptureLiveStream) now signal_new_frame()
  on each new frame; consumers (PictureColorStripStream, ProcessedLive
  Stream) wait on the event with frame_time as a safety timeout
  instead of polling + sleeping. Cuts glass-to-LED latency at matched
  FPS by up to one frame_time.
- ProcessedLiveStream ring buffer: 3 -> 5 slots. The previous "max 2
  frames in flight" assumption ignored the multi-consumer case where
  several PictureColorStripStream/HA-target threads can hold the same
  _latest_frame reference while we wrap. 5 slots gives ~83 ms of
  consumer-read margin at 60 FPS.
- PictureColorStripStream advanced mode: reuse the already-fetched
  primary frame instead of re-acquiring its lock from _live_streams.
- _blend_u16: use cv2.addWeighted (single SIMD-fused pass) when cv2
  is available; numpy fallback unchanged. Output verified bit-equal
  to the existing 6-pass implementation.
- FrameLimiter.wait: drop the 1 ms minimum-sleep floor. Over-budget
  loops no longer add an extra ms per iteration; the cap on achievable
  rate (~750 fps) is removed.
2026-05-12 15:06:11 +03:00
alexei.dolgolyov f184ef0afb perf(capture): vectorize hot paths and fix engine bugs
- WGC: replace per-frame ~30 MB BGRA->RGB fancy-index allocation with
  cv2.cvtColor into a 3-slot pre-allocated RGB pool. Use gc.collect(0)
  on cleanup instead of full GC to avoid multi-hundred-ms stalls.
- MSS: switch from screenshot.rgb (pure-Python BGRA->RGB rebuild) to
  screenshot.raw + cv2.cvtColor into a pooled buffer. Add cheap 256-byte
  hash-based change detection so idle frames return None — matches
  DXcam/BetterCam semantics.
- DXcam/BetterCam: fix silent factory leak — Python name-mangling
  rewrote self._dxcam.__factory to _DXcamCaptureStream__factory inside
  the class body, so cleanup never reached the real attribute. Use
  getattr with string literal to bypass mangling.
- calculate_dominant_color: replace np.random.choice(replace=False)
  (full sort) with np.random.randint, and np.unique(axis=0) (lexsort)
  with packed-RGB np.bincount. ~10x faster on dominant mode.
- calibration._map_edge_average: switch cached scratch buffers from
  float64 to float32. Halves memory bandwidth on the dominant reduction
  path; range-safe up to 8K screens.
- All engines: per-frame DEBUG logs use structlog kwarg style instead
  of f-strings to avoid per-frame string allocation.
2026-05-12 15:05:52 +03:00
alexei.dolgolyov ad84b60ae4 fix(ha-light): apply brightness_scale once and respect boost multipliers
`_send_entity_color` was multiplying the per-mapping `brightness_scale`
into the brightness payload twice when the effective scale was below 1,
yielding a quartered output for a configured half-scale. Conversely,
when the value-stream multiplier exceeded 1.0 with a default scale,
the entire scaling step was skipped and the boost was lost.

Compute brightness as `clamp(max(r,g,b) * bs * vs, 0, 255)` once and
ship it directly, with regression tests pinning the half-scale, boost,
and 255-clamp cases.
2026-05-11 01:42:02 +03:00
alexei.dolgolyov cdf7d94652 feat(ui): expand card icon picker (44 -> 120 icons, +5 categories)
Add 76 new icons to the custom card-icon picker and introduce five new
categories: weather, nature, controls, status, office. Existing icon ids
are unchanged so persisted card icons keep resolving.

- icon-paths.ts: +36 Lucide path constants (weather, nature, room,
  office, media, hardware, lighting variants)
- device-icons.ts: extend IconCategory union and CATEGORIES; add
  registry entries with labels + search aliases
- en/ru/zh locales: 5 new category labels + 76 per-icon labels each
  (126 device.icon keys per locale, fully aligned)

Tabs scroll horizontally via existing overflow-x; no migration needed
(picker reads/writes ids by value, missing ids fall back to inheritance).
2026-05-11 01:38:40 +03:00
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
568 changed files with 70798 additions and 12443 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:
+26 -1
View File
@@ -98,6 +98,9 @@ jobs:
print(json.dumps('\n\n'.join(sections))) print(json.dumps('\n\n'.join(sections)))
") ")
# Created as draft so the release isn't user-visible until every
# build job has attached its assets. The publish-release job at
# the end of the workflow flips draft=false once all builds pass.
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \ RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -105,7 +108,7 @@ jobs:
\"tag_name\": \"$TAG\", \"tag_name\": \"$TAG\",
\"name\": \"LedGrab $TAG\", \"name\": \"LedGrab $TAG\",
\"body\": $BODY_JSON, \"body\": $BODY_JSON,
\"draft\": false, \"draft\": true,
\"prerelease\": $IS_PRE \"prerelease\": $IS_PRE
}") }")
@@ -350,3 +353,25 @@ jobs:
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
docker push "$REGISTRY:latest" docker push "$REGISTRY:latest"
fi fi
# ── Publish the release (flip draft=false) ─────────────────
# Runs only after every build job succeeded so users never see a
# release that's missing artifacts or sha256 sidecars (the in-app
# updater refuses to install without them).
publish-release:
needs: [create-release, build-windows, build-linux, build-docker]
if: github.event_name == 'push' && success()
runs-on: ubuntu-latest
steps:
- name: Promote draft release to published
env:
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
curl -s -X PATCH "$BASE_URL/releases/$RELEASE_ID" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"draft": false}'
echo "Published release $RELEASE_ID"
+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
+10
View File
@@ -68,6 +68,11 @@ logs/
# shipped sound assets out of the CI tag checkout. # shipped sound assets out of the CI tag checkout.
/data/ /data/
/server/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
@@ -90,3 +95,8 @@ tmp/
# OS # OS
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# Added by code-review-graph
.code-review-graph/
# vex semantic-search embedding cache (auto-downloaded on first --semantic run)
.fastembed_cache/
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"type": "stdio"
}
}
}
+4 -1
View File
@@ -6,7 +6,10 @@ repos:
args: [--line-length=100, --target-version=py311] args: [--line-length=100, --target-version=py311]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0 # Bumped from v0.8.0 so the hook recognises UP045
# (non-pep604-annotation-optional), which the v0.13+ ruff split off
# from UP007. Pyproject.toml extend-selects both rules.
rev: v0.15.12
hooks: hooks:
- id: ruff - id: ruff
args: [--line-length=100, --target-version=py311] args: [--line-length=100, --target-version=py311]
+24
View File
@@ -0,0 +1,24 @@
# vex configuration — https://github.com/tenatarika/vex
#
# Place this file in your project root as .vex.toml
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
# exclude = [
# "vendor/**",
# "node_modules/**",
# "*.generated.go",
# "dist/**",
# ]
# Default output format: "text", "json", or "compact"
# format = "text"
# Enable semantic embeddings by default (slower indexing, enables meaning-based search)
semantic = true
# Automatically run `vex update` before search if the index is stale
auto_update = true
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
# Changing the embedder requires a full reindex.
# embedder = "minilm-l6-v2"
+428
View File
@@ -0,0 +1,428 @@
# LedGrab Architecture Audit — Remaining Items
Roadmap for the architecture-audit refactor sprint that started 2026-05-22.
This file lists every audit finding that is **not yet addressed**; the ones
already landed in commits `563cbac..2f15fbb` are summarised below for
context.
## Already done (10 commits)
| Commit | Findings addressed |
|---|---|
| `563cbac` | C2, C11, C1 (parallel-change only), C3, C4, C6, C7-streams |
| `29bdacf` | C5 (HA/Z2M swap helper; full ABC deferred) |
| `97dae2c` | H1 |
| `5fec8db` | M4 |
| `98fb61d` | H2 |
| `9f3f346` | M5 |
| `05f73ee` | H6 (bindable extraction only) |
| `3b8f00e` + `c1aa2eb` | C7 store-side |
| `2f15fbb` | H3 |
| _uncommitted (2026-05-27 autonomous pass)_ | H6-rest, H8, M7 (foundation + 3 reference files) |
All commits have ≥1 code-review subagent pass with HIGH findings fixed
before commit. Tests pass on each commit; ruff clean; tsc + bundle build
clean for the frontend commit.
The two CRITICAL **data-safety** items (C2 silent CSS fallback, C11
string-replace JSON migration) are fixed. The two CRITICAL
**parallel-change** problems for color-strip + value-source dispatch are
fixed. The two HIGH dispatch problems (H1 effects, H2 rules) are fixed.
---
## Remaining backend items
### HIGH
#### H4 — `Device.__init__` 40+ params mixing per-type fields
**File:** `server/src/ledgrab/storage/device_store.py:46-150`
The `Device` dataclass constructor accepts ~40 parameters that mix common
fields with DMX-only / DDP-only / Hue-only / Yeelight-only / Wiz-only /
LIFX-only / Govee-only / Nanoleaf-only / SPI-only / Chroma-only /
GameSense-only fields. Setting `hue_username` on a WLED device is
silently ignored.
**Approach:** introduce per-device-type config dataclasses
(`DmxConfig`, `HueConfig`, `DdpConfig`, …) and make `Device.config` a
discriminated union. Per-type validation moves to the config classes.
Wire migration: every existing device row needs to be re-parsed; use the
versioned `MigrationRunner` introduced in Phase 1.2.
**Risk:** medium-high. Touches:
- `storage/device_store.py` — Device dataclass, `from_dict`, `to_dict`,
`create_device`, `update_device`
- `api/schemas/devices.py` — Pydantic schemas
- `api/routes/devices.py` — request validation
- `core/devices/*` — every provider reads device fields
- A new migration to translate flat fields → nested `config`
**Estimated scope:** ~1500 LOC diff, 1-2 dedicated sessions.
#### H5 — `WledTargetProcessor` god class (32 methods, 5 responsibilities)
**File:** `server/src/ledgrab/core/processing/wled_target_processor.py` (1238 LOC)
Conflates:
1. Device connectivity (probe, liveness, reconnect)
2. FPS negotiation (adaptive_fps, keepalive_interval, state_check_interval)
3. LED resampling (`_fit_to_device` — 60 lines of numpy)
4. Preview WebSocket fanout (`_preview_clients`, `_broadcast_led_preview`)
5. Metrics emission (`get_state`, `get_metrics`)
**Approach:** extract `WledDeviceConnector`, `WledPixelSender`,
`TargetFitProcessor`, `TargetPreviewBroadcaster`, `TargetMetricsCollector`.
`WledTargetProcessor` becomes an orchestrator that composes them.
**Risk:** HIGHEST in the audit. This class drives physical LED hardware
in production. A regression caught at runtime (in the user's living
room) is the expensive failure mode. Needs manual verification with at
least one real WLED device after the refactor.
**Coupled with:** C5 (HA/Z2M shared the same shape; should extract a
common `BaseTargetProcessor` ABC at the same time so all three
processors share lifecycle / preview / metrics code).
**Estimated scope:** ~2000 LOC diff, 2-3 dedicated sessions, with manual
device testing after each.
#### H7 — `device-discovery.ts` 1745 LOC
Frontend mirror of H4. The `onDeviceTypeChanged` handler has a giant
switch with 15+ device kinds and 15+ `_showXxxFields` / `_buildXxxItems`
helpers. Adding a device type requires editing 5 separate frontend hooks.
**Approach:** mirror the H4 backend redesign — once the storage layer
has per-type config objects, the frontend can have a per-type field-set
registry. Best done **after** H4 lands so the schemas drive the
registry.
**Estimated scope:** 1-2 sessions; coupled to H4.
#### H8 — `automations.ts` 1410 LOC — ✅ DONE (uncommitted, 2026-05-27)
Frontend mirror of H2 (rule polymorphism). Already addressed on the
backend in `98fb61d`; the frontend dispatch on `RuleType` was
hand-rolled.
**Done:** the two remaining hand-rolled dispatch ladders were converted
to registries keyed by `RuleType`, alongside the pre-existing
`RULE_CHIP_RENDERERS`:
- `RULE_FIELD_RENDERERS` — the `renderFields` if/elif ladder was
extracted into module-level `_renderXxxFields(container, data)`
functions (they only ever closed over `container`); the in-row
`renderFields` is now a 3-line dispatcher.
- `RULE_COLLECTORS` — the `getAutomationEditorRules` if/elif ladder
became per-type collectors; the loop is now a registry lookup.
- All three registries are typed `Record<RuleType, …>` (compile-time
exhaustiveness) and an import-time `_assertRuleHandlerCoverage()`
logs loudly if any registry drifts from `RULE_TYPE_KEYS`. (Frontend
logs rather than throws — a thrown error at import would brick the
whole bundle, not just the editor — the one intentional divergence
from the backend's raising `_assert_rule_handler_coverage`.)
Adding a new rule type now means: one entry in `RULE_TYPE_KEYS`,
`RULE_TYPE_ICONS`, and each of the three registries — and tsc + the
coverage check flag any omission.
Verified: tsc + bundle build clean; typescript-reviewer APPROVE (the
extracted renderer bodies are byte-identical to the originals; no stray
closure captures; http_poll widget-stash + HA entity loading preserved).
### MEDIUM
#### M1 — `ProcessorManager.add_target` shotgun (11 args, WLED-leak)
**File:** `server/src/ledgrab/core/processing/processor_manager.py:396`
Method is named generically (`add_target`) but accepts `protocol="ddp"`
and `keepalive_interval` — WLED-only fields. HA and Z2M have sibling
methods with their own bespoke params.
**Approach:** extract a `TargetFactory` (per-kind builders, similar to
`value_source_factories.py` from Phase 7). Couple with H5/C5 work.
#### M2 — `TargetContext` god-bag
**File:** `server/src/ledgrab/core/processing/processor_manager.py`
`@dataclass TargetContext` exposes ~8 attributes (device_store,
color_strip_stream_manager, value_stream_manager, metrics_history,
mqtt_manager, ha_manager, …). Processors silently depend on whichever
fields they read. Tests have to construct a huge mock context.
**Approach:** make per-processor explicit dependency injection. Couple
with H5 work.
#### M3 — Validation duplicated across layers
Field-level constraints (composite nesting depth, name uniqueness, span
ranges) are enforced in route + schema + store. Adding a new constraint
means editing 3 places.
**Approach:** move all validation to the model/schema layer (Pydantic
validators + dataclass `__post_init__`). Routes trust the schema; store
trusts the model.
**Risk:** moderate — cross-cutting; needs careful review of which layer
currently owns which constraint.
#### M6 — `ws_stream.py` mixed concerns (699 LOC)
**File:** `server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py`
The worst part (stream-creation dispatch) was fixed in Phase 2.1 — it
now calls `color_strip_kinds.build_stream(source, deps)`. The remaining
699 lines mix config parsing + WebSocket lifecycle + frame loop. Could
extract the frame loop into a separate `PreviewFrameLoop` class.
**Estimated scope:** half a session. Low impact since the parallel-change
problem is already fixed.
#### M7 — No shared frontend API client — 🟡 FOUNDATION DONE (uncommitted, 2026-05-27)
**File:** every `static/js/features/*.ts`
`fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every
feature's save / load function. ~45 files, ~243 call sites.
**Done:** `static/js/core/api-client.ts` now provides typed
`apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` that wrap
`fetchWithAuth` (so auth, 401-relogin, retry, timeout, and the offline
toast are unchanged) and collapse the repeated
`if (!resp.ok) { detail || HTTP <status> } … resp.json()` dance into one
call returning a typed body and throwing `ApiError` on failure. The
`detail` unwrap is hardened to join FastAPI validation arrays instead of
stringifying to `[object Object]`. **35 feature/core files migrated**
(covers GET/POST/PUT/DELETE, typed response bodies, custom i18n error
messages, silent-failure GETs, bulk `Promise.allSettled` deletes,
inline-error saves, array-`detail` joins, fire-and-forget POSTs, and
local catch handling) — reviewer-approved for behaviour parity across
the riskier divergences. Migrated files include the integration sources
(weather / HA / MQTT / HTTP), the template families (capture / audio /
audio-processing / pattern), the scene-preset CRUD, the simple-CRUD
entity files (sync-clocks / audio-sources / game-integration /
gradient / displays / device-discovery), the light-target editors
(z2m / ha), the preferences modules (dashboard-layout / card-modes /
notifications-watcher), the calibration editors (simple + advanced),
the entire `automations.ts` and `devices.ts` CRUD surfaces, and several
core utilities (`api-client.ts` itself, `cache.ts`, `command-palette.ts`,
`graph-connections.ts`, `tag-input.ts`, `process-picker.ts`,
`perf-charts.ts`, `icon-picker.ts`, `update.ts`, `integrations.ts`).
Also added **14 new locale keys** (en / ru / zh) so the fallback
messages the migration surfaces — `pattern.error.save_failed`,
`audio_processing.error.save_failed`, `audio_template.error.save_failed`,
`audio_template.error.load_failed`, `templates.error.save_failed`,
`templates.error.load_failed`, `gradient.error.save_failed`,
`target.error.load_failed`, `device.error.load_failed`,
`automations.error.{load,save,delete,toggle}_failed`, plus
`gradient.error.delete_failed` for ru/zh — are translated instead of
hardcoded English. A scan confirms **no `errorMessage: '<English>'`
strings remain** in the migrated diff.
**Remaining:** 9 feature files (~94 call sites). All but one are the
big god-modules whose migration is best done as part of their C8/C9/C10
splits: `streams.ts` (18), `settings.ts` (18), `targets.ts` (16),
`dashboard.ts` (15), `color-strips/index.ts` (8), `graph-editor.ts` (7),
`assets.ts` (6 — also blocked by multipart upload + blob download paths
that legitimately bypass the JSON client), and `value-sources.ts` (5).
The lone leaf file still on `fetchWithAuth` is `pairing-flow.ts` (1) —
its branching on raw `Response.status` codes (200 / 409 / 4xx) doesn't
fit the api-client contract, so it stays on raw fetch by design.
Migration is mechanical but **not** a blind find/replace — each site
carries its own localised error key that must be preserved as the
`errorMessage` option, and binary/multipart endpoints (e.g.
`assets.ts` file upload / blob download) must stay on raw
`fetchWithAuth` (the client is JSON-only). Each migrated file ideally
gets manual UI smoke-testing. **Behaviour note:** migrated GET sites now
prefer the server's `detail` over the generic localised fallback when
present — matching what the write paths already did; intended, but
user-visible.
#### M8 — Global `_cached*` `let` vars
Mutable module-level state mutated from multiple feature modules. No
subscription model — features manually `invalidate()` after CRUD.
**Approach:** introduce a reactive cache (EventEmitter pattern or a tiny
store like Nano Stores). Couple with M7 (the API client can drive cache
invalidation on write).
#### M9 — `dashboard.ts` 1421 LOC
Frontend god-module orchestrating + rendering device / target / CSS
cards. Couple with C8/C9/C10 frontend split work.
#### M10 — Duplicate frontend modal classes
`ValueSourceModal`, `StreamEditorModal`, `TargetEditorModal`,
`AddDeviceModal`, etc. each reimplement pristine-check / undo / focus
management.
**Approach:** introduce a `FormModal<T>` base class.
#### M11 — Hardcoded `_getSectionForSource` / `_getTabForSource`
Routing tables duplicated across multiple feature files (streams.ts,
value-sources.ts). Adding a new stream type requires hunting strings.
**Approach:** single routing registry keyed by source_type.
#### M12 — Late imports masking cycles
Partially addressed by the kind registries (Phase 2.1, 2.2). Some
late-imports still exist in `value_stream.py`, `audio_stream.py`, the
target processors. Resolving them requires restructuring module layout
to break the circular dependencies.
**Estimated scope:** small follow-up after H5.
### LOW
#### L1 — `(src as any).field` casts in `value-sources.ts`
Discriminated unions aren't narrowed properly. Couple with C8 frontend
split.
#### L2 — Mutable state without locks
`_preview_clients`, `_last_preview_data`, `_color_stream`,
`_css_stream` are mutated from multiple async tasks without explicit
locks. Production has not exhibited issues but the contract is fragile.
**Approach:** add explicit `asyncio.Lock` per processor. Couple with H5.
#### L3 — `Calibration.validate()` raises instead of returning result
**File:** `server/src/ledgrab/core/capture/calibration.py:164`
All 4 call sites currently rely on the raise; converting to
`ValidationResult` would force every caller to check a return value
without adding safety. **Recommendation:** skip — current design is
appropriate.
#### L4 — `_SOURCE_TYPE_MAP` is module-private
No public `GET /api/v1/source-types` discovery endpoint. Frontend
hardcodes the list of source types in `types.ts`.
**Approach:** add a discovery route + matching frontend fetch. Couple
with H6 frontend split (since `types.ts` is involved).
#### L5 — `AudioValueStream` implicit state machine
**File:** `server/src/ledgrab/core/processing/value_stream.py:169-383`
`get_value()` can be called before `start()`; transitions are implicit.
**Approach:** explicit State pattern. Low value (production callers
always start before reading).
---
## Remaining frontend items (all)
### CRITICAL
- **C8** — `value-sources.ts` 1972 LOC (4 god-functions, type-dispatch ladders)
- **C9** — `graph-editor.ts` 2707 LOC (layout + interaction + state + WS sync + …)
- **C10** — `streams.ts` 2341 LOC (picture / audio / template kitchen-sink)
### Other frontend (severity in main list above)
- **H6 rest** — ✅ DONE (uncommitted, 2026-05-27): `types.ts` (1140 LOC)
split into 18 per-entity files under `types/` (joining the existing
`bindable.ts`); `types.ts` is now a ~200-line pure re-export barrel, so
every `import { … } from '../types.ts'` still resolves. Reviewer
confirmed all 102 exported symbols preserved, none renamed.
- **H7** — `device-discovery.ts` 1745 LOC (couple with H4)
- **H8** — `automations.ts` 1410 LOC (mirror H2)
- **M7** — shared API client
- **M8** — reactive cache
- **M9** — `dashboard.ts` 1421 LOC
- **M10** — `FormModal<T>` base
- **M11** — routing registry
- **L1** — narrowing the discriminated unions
The frontend remainder is **multi-day work** even when broken up by
finding. Recommended approach: a dedicated frontend sprint with the
typescript-reviewer agent + manual UI testing for each god-module
split. Order:
1. Finish `types.ts` split (H6) — pure organisation, low risk, unblocks
the rest
2. Introduce API client (M7) — every feature file gains a cleaner shape
3. Split `value-sources.ts` (C8) — uses the API client + per-type
registry pattern
4. Split `streams.ts` (C10)
5. Split `graph-editor.ts` (C9) — needs the most care; the file owns
the entire visual editor
6. Polish: `dashboard.ts` (M9), `device-discovery.ts` (H7),
`automations.ts` (H8), `FormModal` (M10), routing registry (M11),
reactive cache (M8), narrowing (L1)
---
## Recommended ordering for future sessions
### Session A — Frontend sprint (multi-day)
Address H6-rest, C8, C9, C10, H7, H8, M7-M11, L1. See order above.
Critical to have typescript-reviewer feedback + manual UI testing after
each split.
> **Progress (2026-05-27, uncommitted):** steps 1 & 2 of the order above
> are done — H6-rest (`types.ts` split) and M7-foundation (`api-client.ts`
> + 3 reference migrations). H8 (automations registry) also landed. Still
> open: C8, C9, C10, H7, the remaining ~40 M7 file migrations, M8-M11, L1.
> Next per the order: introduce the API client everywhere (finish M7),
> then split `value-sources.ts` (C8).
### Session B — Device redesign (1-2 sessions)
Address H4 alone. Touches device storage + provider classes; needs a
data migration. Once H4 lands, H7 frontend mirror can follow.
### Session C — BaseTargetProcessor ABC (2-3 sessions)
Address C5 (full) + H5 + M1 + M2 + L2 together. Highest risk in the
audit because it drives physical LED hardware. Each step needs manual
verification with a real device.
### Session D — Polish (half a session)
Address M3, M6 (remainder), M12 (remainder), L3 (decision: skip), L4,
L5.
---
## Pattern reference for new contributors
Three registry-pattern templates that already exist in the codebase and
should be the model for the remaining dispatch ladders:
1. **Class-level handler dict + import-time coverage assertion**
- `core/processing/effect_stream.py::_RENDERERS`
(`@_effect_renderer` decorator + `@_collect_effect_renderers`
class decorator)
- `core/automations/automation_engine.py::AutomationEngine._RULE_HANDLERS`
(module-level binding after class definition)
- `api/routes/output_targets.py::_TARGET_RESPONSE_BUILDERS`
(response-shape dispatch keyed by storage class)
2. **Per-type free functions + dependency-bag dataclass**
- `core/processing/color_strip_kinds.py` (`StreamDeps` + `STREAM_BUILDERS`)
- `core/processing/value_kinds.py` (`ValueStreamDeps` + `STREAM_BUILDERS`)
- `storage/value_source_factories.py` (`CREATE_BUILDERS` + `UPDATE_APPLIERS`)
3. **Versioned migration runner**
- `storage/data_migrations.py` (`MigrationRunner` + `DataMigration` ABC)
- Used for any storage rename / field-shape change in the future.
- Audit-table contract: atomic transaction covers
applied-check + apply + record, so partial-failure cannot leave
data rewritten but unrecorded.
Adding a new feature that touches dispatch should reach for one of
these three patterns before writing a fresh if/elif chain.
+39 -4
View File
@@ -55,10 +55,6 @@ The Android app (`android/app/build.gradle.kts`) installs the server package wit
| [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) | Reusable CI/CD patterns: Gitea Actions, cross-build, NSIS, Docker | | [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) | Reusable CI/CD patterns: Gitea Actions, cross-build, NSIS, Docker |
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks | | [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
## Task Tracking via TODO.md
Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the TodoWrite tool** — all progress tracking goes through `TODO.md`.
## Documentation Lookup ## Documentation Lookup
**Use context7 MCP tools for library/framework documentation lookups** (FastAPI, OpenCV, Pydantic, yt-dlp, etc.) instead of relying on potentially outdated training data. **Use context7 MCP tools for library/framework documentation lookups** (FastAPI, OpenCV, Pydantic, yt-dlp, etc.) instead of relying on potentially outdated training data.
@@ -104,3 +100,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.
+54 -13
View File
@@ -1,31 +1,72 @@
## v0.4.2 (2026-04-22) ## v0.8.0 (2026-05-28)
### Bug Fixes ### User-facing changes
- Ship previously-missing package assets in release artifacts — prebuilt notification sounds (`alert`, `bell`, `chime`, `ping`, `pop`) and game adapter YAMLs (`minecraft`, `rocket_league`, `valorant`). An unanchored `data/` rule in `.gitignore` was matching `server/src/ledgrab/data/`, so these files never reached the tag or CI builds. Also bump the `_FALLBACK_VERSION` literal to `0.4.2` so the Windows installer (which strips `.dist-info`) reports the correct version in the WebUI instead of `0.3.0`. Build scripts now patch this literal automatically to prevent future drift. ([5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd))
### Features #### Features
- Restyle the enhanced header locale picker as a LED-accent badge — 2-letter code in Orbitron, collapses to just the badge on narrow screens ([9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3))
##### Android TV — production-readiness pass
- **Security:** per-install random API key (persisted, threaded into the embedded server via env, embedded in the pairing QR as a URL-fragment so it never reaches HTTP logs); root-shell injection eliminated via POSIX-quoted `runAsRoot(argv)` overload; broadcast receivers locked to the app package; release builds refuse to silently sign with the debug keystore; crash log retention capped at 10 entries
- **Performance:** single reusable RGBA buffer in `ScreenCapture` / `RootScreenrecord` (eliminates ~15 MB/s GC churn at 30 fps); frame pacer switched to `elapsedRealtimeNanos` with catch-up accumulator (fixes ~30.3 fps drift); capture dimensions derived from source aspect ratio so non-16:9 displays aren't squashed; QR bitmap cached by URL
- **Compatibility:** compileSdk/targetSdk → 35 (Play Store requirement); armeabi-v7a build path; foreground service type declared as `mediaProjection|specialUse` with proper `ServiceCompat.startForeground` promotion; Ethernet > Wi-Fi > VPN > cellular selection in `NetworkUtils`; Android 15 predictive-back via `enableOnBackInvokedCallback`; splash screen API hides Chaquopy cold-start delay
- **UI/UX:** all hardcoded English strings localised across en/ru/zh; monochrome notification icon; 320×180 TV banner; ViewStub-based running panel; pulse animator on Running dot; "Starting…" button while probing root; autostart checkbox hidden on unrooted devices
- **Lifecycle hardening:** `processLock` serialises EOF respawn vs `stop()` to prevent orphaned screenrecord; publish-before-start under `@Synchronized` in `CaptureService.restartRootPipeline` closes the orphan window during watchdog restarts; watchdog give-up bound corrected ([ef1f9ea](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ef1f9ea))
##### Backup format — bundled DB + assets ZIP
- Auto-backups now produce a `.zip` containing `ledgrab.db` plus every file from the assets directory under `assets/` — matching the manual `GET /api/v1/system/backup` download. Restore accepts both `.zip` and legacy `.db` interchangeably
- Partial-write hardening: writes stage to `<name>.partial` then `os.replace` into place — a crash mid-write never leaves a corrupt backup masquerading as valid. Stale `.partial` files from prior crashes are swept on the next run
- Symlinks inside the assets directory are skipped (security guard against link targets outside the dir)
- Backups over 500 MB log a warning so operators notice unbounded asset growth before disk fills up
- `restart.py` redirects spawned restart script stdout/stderr to `restart.log` and bails out early if the script is missing — silent failures used to vanish into a detached child ([85da2e5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/85da2e5))
##### Spectrum-aperture icon set
- Regenerated icon family from a single Pillow script: rounded-square aperture traced by a continuous RGB color-wheel stroke over a vignette canvas with chromatic bloom. 4× supersampled then downsampled per output for crispness
- New 256 px transparent-background **tray variant** — taskbar icon reads cleanly against light themes instead of showing a dark tile
- `icon.ico` now embeds 16/24/32/48/64/128/256 frames sourced from the transparent master (fixes the dark-square halo on light Windows themes)
- Maskable 512 variant safe-area padded for PWA round-crops ([3645216](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3645216))
#### Bug Fixes
- **Notification sound dropdowns:** both the per-app override list and the main row now always render the EntitySelect (was silently inert before any sound assets were registered) and offer "no sound" as a first-class option via `allowNone` ([1f95993](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1f95993))
- **CSS editor:** `notification_sound` and `notification_volume` are now persisted on save — they were silently dropped from the payload before ([66b85b0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/66b85b0))
- **Python 3.13 ctypes:** Win32 message-pump prototypes (`GetMessageW` / `TranslateMessage` / `DispatchMessageW`) now share a single `LPMSG = POINTER(wintypes.MSG)` class across `WindowsShutdownGuard` and `PlatformDetector` — fixes the `expected LP_MSG instance instead of pointer to _MSG` error and the resulting shutdown-guard / display-power-monitor failure on 3.13 ([e4d24a0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4d24a0), [0d840ad](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0d840ad))
--- ---
### Development / Internal ### Development / Internal
#### CI/Build #### CI/Build
- Publish `.sha256` sidecars alongside release assets for easier integrity verification ([03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b))
- `release.yml` now creates the Gitea release as a **draft** and only flips `draft=false` once every build job (Windows, Linux, Docker) has uploaded its artifacts and sha256 sidecars — users never see a release page that's missing assets, which would have broken the in-app updater ([bc42604](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bc42604))
#### Refactoring #### Refactoring
- Move the Key Colors test out of the lightbox and into the `test-css-source` modal where the rest of the source-render debug tools live ([be2d5e1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be2d5e1))
- **Shared API client + automations registry (audit M7, H8):** new `core/api-client.ts` wraps `fetchWithAuth` with typed `apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete`; 35 feature/core files migrated. FastAPI validation-array detail unwrap hardened. Automations editor's two hand-rolled `RuleType` dispatch ladders converted to `Record<RuleType, ...>` registries with an import-time exhaustiveness check ([bb3a316](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bb3a316))
- **types.ts split (audit H6):** 1140 LOC `types.ts` split into 18 per-entity files under `types/`, original file kept as a pure re-export barrel — 102 type exports preserved with no import sites changed ([49c35a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49c35a2))
#### Documentation
- `REVIEW_RECONCILE_NOTES.md` — design doc for the dashboard innerHTML reconciliation work: bug-class analysis, latent-site inventory, decision ladder (helper / hand-rolled cells / Lit), and recommendation to migrate polling-heavy modules to Lit with `entity-events.ts` tab reconciliation sequenced first ([10eb24b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/10eb24b))
--- ---
<details> <details>
<summary>All Commits</summary> <summary>All Commits (11)</summary>
| Hash | Message | Author | | Hash | Message | Author |
|------|---------|--------| | ---- | ------- | ------ |
| [be2d5e1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be2d5e1) | refactor(color-strips): move Key Colors test from lightbox into test-css-source modal | alexei.dolgolyov | | [0d840ad](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0d840ad) | fix(ctypes): share wintypes.MSG with platform_detector to avoid argtype races | alexei.dolgolyov |
| [5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd) | fix(release): ship prebuilt assets and bump fallback version | alexei.dolgolyov | | [1f95993](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1f95993) | fix(notification): allow clearing the sound on per-app overrides and main row | alexei.dolgolyov |
| [9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3) | feat(ui): restyle enhanced header locale picker as LED-accent badge | alexei.dolgolyov | | [10eb24b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/10eb24b) | docs: dashboard innerHTML reconciliation review notes | alexei.dolgolyov |
| [03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b) | ci(release): publish .sha256 sidecars alongside release assets | alexei.dolgolyov | | [66b85b0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/66b85b0) | fix(css-editor): persist notification_sound + notification_volume | alexei.dolgolyov |
| [bc42604](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bc42604) | ci(release): publish release only after every build job uploads assets | alexei.dolgolyov |
| [3645216](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3645216) | feat(icons): spectrum aperture icon set + dedicated tray variant | alexei.dolgolyov |
| [85da2e5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/85da2e5) | feat(backup): bundle assets in ZIP + partial-write hardening + restart log | alexei.dolgolyov |
| [e4d24a0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4d24a0) | fix(ctypes): pin LPMSG across MSG-pump prototypes for Python 3.13 | alexei.dolgolyov |
| [bb3a316](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bb3a316) | refactor(frontend): shared API client + automations registry (audit M7, H8) | alexei.dolgolyov |
| [49c35a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49c35a2) | refactor(frontend): split types.ts into 18 per-entity files (audit H6) | alexei.dolgolyov |
| [ef1f9ea](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ef1f9ea) | feat(android): production-readiness pass — security, perf, compat, UI/UX | alexei.dolgolyov |
</details> </details>
+249
View File
@@ -0,0 +1,249 @@
# Dashboard Reconciliation — Review Notes
*Captured 2026-05-26. Session focused on dashboard + perf-card flicker and per-poll re-rendering.*
*Updated 2026-05-27 — widened the audit beyond the two poll timers and found a **second driver** (server push) plus the **highest-blast-radius site** (`entity-events.ts`). Added §3.5, corrected the "out of scope" reasoning in §5, and confirmed the decision: **commit to the Lit migration**. Implementation deferred — this is still a planning doc, not a spec.*
This is a thinking-aloud document for whoever picks up reconciliation work next (likely me). It captures the bug class, what's already shipped, what's still latent, the decision ladder we walked through, and the recommendation we landed on. It is **not** a spec — treat any code shown as illustrative.
---
## 1. The bug class in one sentence
> Every place a data-driven render — a poll timer **or** a server-pushed `server:*` event — writes `el.innerHTML = ...`, the existing DOM is torn down — even when the new HTML equals the old — which restarts CSS animations, drops focus, skips transitions, and burns wasted DOM mutation cycles.
The symptom only becomes visually loud when the destroyed subtree contains a CSS keyframe animation (e.g. the pulsing `.perf-patches-empty-dot`). Everywhere else the cost is silent: lost transitions, broken focus, wasted layout work. The bug is **load-bearing in the architecture**, not in any single call site — that's why we keep coming back to it.
---
## 2. What landed in commit `f6486f9` (this session)
Tactical work — solves the worst cases, does not change the architecture.
### `server/src/ledgrab/static/js/features/dashboard.ts`
- Collapsed the two fast-path branches into one. Fast path runs when `structureUnchanged && !forceFullRender` regardless of `running.length`. Previously, **zero running targets meant every poll rebuilt the entire dashboard** even when nothing changed.
- `_lastSyncClockIds` no longer fingerprints `is_running` — pausing/resuming a clock no longer tears down every card. `_updateSyncClocksInPlace` already handles the toggle.
- `_updateAutomationsInPlace` now called from the unified fast path. Automation badges were silently going stale on the fast path.
- `_initFpsCharts` rewritten diff-based: only destroy charts for ids that left or whose canvas was detached by a DOM swap; only create for new ids; only fetch `/api/metrics/history` when there are genuinely new ids needing seed data.
- Sync-clock pause/resume/reset callers + `server:automation_state_changed` SSE handler now use `loadDashboard()` (no force) — `forceFullRender` is now actually load-bearing, meaning "settings changed, full rebuild required."
### `server/src/ledgrab/static/js/features/perf-charts.ts`
- `_renderChartSvg` no longer rewrites `innerHTML` per poll. The SVG skeleton (ref line + sys area/line + app line) is built once via `_ensureSparkNodes` and mutated thereafter. WeakMap cache (`_sparkNodeCache`) keyed by host element avoids the per-tick `querySelector` cost.
- Hidden cards (env-disabled GPU/Temp) skip render entirely.
- `_fetchPerformance` switched to `fetchWithAuth`.
- Hardcoded English strings replaced with `t()` calls. New keys: `perf.no_captures`, `perf.captures_count.{one,few,many,other}`, `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`, `perf.tip.now`, `perf.tip.ago` (en/ru/zh).
- Tooltip reads `dashboardPollInterval` per mousemove tick (was captured at bind time).
- Dead `<defs><linearGradient>` block removed.
- `updateTotalCaptureFpsActual` now delegates to `_paintCaptureFpsActualValue` — single code path.
- `updateActivePatches` / `updateDevices` skip the `innerHTML` write when content signature hasn't changed. This is the direct fix for the "READY TO LAUNCH flickers every update" report — the empty-state dot's CSS pulse no longer resets.
- Two missing semicolons in `_seedAggregateHistories` (ASI was saving us).
### Reviewer findings addressed (typescript-reviewer pass)
- **HIGH:** `_metricLabel` was looking up `dashboard.perf.${key}` but the FPS family uses `dashboard.perf.total_fps`, `total_capture_fps`, `total_capture_fps_actual`. Tooltip would have shouted `FPS` / `CAPTURE_FPS` / `CAPTURE_FPS_ACTUAL`. Fixed via explicit `METRIC_LABEL_KEYS` map.
- **HIGH:** `_ensureSparkNodes` silently coerced `null` children to non-null when the SVG existed but a child was missing. Hardened to validate all four children and rebuild if any are missing.
---
## 3. Hot spots still latent
These are the call sites where `innerHTML` is still written every poll. None are flickering today (no CSS animations on their inner elements), but every one is the same bug shape and will bite the next time someone adds a keyframe / transition / focus target inside.
### `perf-charts.ts`
| Line | Site | Fires per poll? | Notes |
|------|------|-----------------|-------|
| 462 | `updateActivePatches``listEl.innerHTML` | yes | guarded by signature compare (✓) |
| 493 | `updateTotalFps``valEl.innerHTML` | yes | FPS value, no inner animation |
| 526 | `updateTotalCaptureFps``valEl.innerHTML` | yes | same |
| 638 | `_paintNetworkValue``valEl.innerHTML` | yes | bytes/s value |
| 655 | `_paintDeviceLatencyValue``valEl.innerHTML` (no-devices hint) | yes | hint span |
| 657 | `_paintDeviceLatencyValue``valEl.innerHTML` (offline hint) | yes | hint span |
| 660 | `_paintDeviceLatencyValue``valEl.innerHTML` (ms value) | yes | value |
| 676 | `_paintSendTimingValue``valEl.innerHTML` (idle hint) | yes | hint span |
| 679 | `_paintSendTimingValue``valEl.innerHTML` (ms value) | yes | value |
| 738 | `_paintErrorsValue``valEl.innerHTML` | yes | rate value |
| 806 | `updateDevices``dotsEl.innerHTML` | yes | guarded by signature compare (✓) |
| 1086 | `_renderValuePair``mainEl.innerHTML = appVal` | yes | dual sys/app value |
| 1088 | `_renderValuePair``mainEl.innerHTML = sysVal` | yes | dual sys/app value |
| 1094 | `_renderValuePair``tagEl.innerHTML` (App tag) | mode='both' only | App tag in `both` mode |
| 1181 | `_applyPerfDataToDom` temp hint | only when cpu_temp_hint_key changes | rare |
| 1449 | `_paintFpsValue` | seed only | once per init |
| 1456 | `_paintCaptureFpsValue` | seed only | once per init |
| 1463 | `_paintCaptureFpsActualValue` (no-captures hint) | yes via live updater | now goes through painter |
| 1469 | `_paintCaptureFpsActualValue` (value) | yes via live updater | same |
| 1499 | `_paintErrorsValue` (duplicate of 738) | seed only | once per init |
| 1823 | tooltip `tip.innerHTML` | per mousemove | rate-limited by hover only |
### `dashboard.ts`
| Line | Site | Fires per poll? | Notes |
|------|------|-----------------|-------|
| 275 | `_updateRunningMetrics``fpsEl.innerHTML` | per running target | live FPS pill — visible churn |
| 293 | `_updateRunningMetrics``labelEl.innerHTML` (errors label) | per running target | rebuilt each poll |
| 340 | `_updateAutomationsInPlace``btn.innerHTML` | only on enable/disable change | low frequency |
| 366 | `_updateSyncClocksInPlace``btn.innerHTML` | per poll for every clock | wasteful |
| 975 | `loadDashboard` first-load → `container.innerHTML` | once per init | fine |
| 989 | `loadDashboard` slow path → `dynamic.innerHTML = dynamicHtml` | only when slow path fires | the **big** swap, scoped already |
| 1010 | `loadDashboard` error path | rare | fine |
| 1416 | `subscribeDashboardLayout` clear | rare | fine |
### What this list tells us
- The remaining innerHTML writes are **per-cell value updates** that paint formatted spans (`{value}<span class="perf-fps-unit">fps</span>`). Each rewrite destroys two text nodes + a span every poll across ~10 cells. Not flickering today; will flicker the moment anyone adds an animation to `.perf-fps-unit` or `.perf-fps-ceiling`.
- The pattern can be killed without architectural change by splitting these into a stable structure (number text node + static unit span) and only updating `textContent` of the number. That's what L3 / Lit would force naturally.
---
## 3.5 Beyond dashboard/perf — push-driven reconciliation
*Added 2026-05-27. The §3 audit was scoped to the two poll timers we were debugging. Widening the `\.innerHTML\s*=` search showed the bug class has a **second driver** and lives outside dashboard/perf too.*
### Two drivers, not one
The teardown is triggered by anything that re-renders **without user intent**:
- **Poll timers** (`setInterval`) — what §2/§3 covered (`dashboard.ts` `_uptimeTimer` + main refresh, `perf-charts.ts` `_pollTimer`).
- **Server-pushed `server:*` events** — `core/events-ws.ts` turns each WS message into a `server:*` CustomEvent; feature modules listen and re-render through the *same* `innerHTML` paths.
So the one-line bug class in §1 reads "poll- **or** push-driven," not just poll.
### Genuinely-affected sites outside dashboard/perf
| Site | Driver | Shape | Notes |
| ---- | ------ | ----- | ----- |
| `core/entity-events.ts` `_invalidateAndReload` | push (`server:entity_changed`, `server:device_health_changed`) | full-**tab** rebuild via `loadTargetsTab` / `loadPictureSources` / `loadAutomations` / `loadIntegrations` | **highest blast radius.** A single pushed entity change tears down and rebuilds an entire tab — losing scroll, focus, open inline editors, restarting card-enter animations. |
| `features/game-integration.ts` event feed (`_eventMonitorTimer`) | poll (2 s) | `feed.innerHTML = events.slice(0,20).map(...)` | full 20-item list rebuild every 2 s while the panel is open. |
| `features/game-integration.ts` connection test (`_connectionTestTimer`) | poll | `panel.innerHTML = …` per tick | transient, low frequency. |
`entity-events.ts` already has the **L1 floor applied by hand**: a 600 ms debounce plus a diff check (`oldData === newData`, then length + `id` + `updated_at` compare) that skips the reload when nothing changed. That kills the *no-op* case — but a **real** change still does the full-tab teardown. This is exactly the §4-L1 limitation ("still tears down when content *does* differ"), live across the whole app.
### Counter-examples that already do it right
Two poll loops never flicker because they mutate `textContent` on a **stable structure** instead of rewriting `innerHTML`:
- `core/api.ts` `loadServerInfo` (connection-check poll) — `versionEl.textContent` / `statusEl.textContent`.
- `features/color-strips/test.ts` FPS sampler (1 s) — `valueEl.textContent` / `avgEl.textContent`.
These are live proof that "stable structure + mutate text node" is the fix — i.e. what L3 / Lit force by construction.
### What this changes about the plan
The §4 ladder was reasoned entirely around **per-cell** rendering, because that was the visible flicker. The push-driven finding surfaces a second, qualitatively different problem:
- **Problem A — cell value churn:** every poll, one value span. Loud only with animations. *Mostly fixed in `f6486f9`.* → wants `setText` / skip-if-unchanged.
- **Problem B — list/tab teardown:** on change/push, an entire list or tab. Loses scroll/focus/open editors. *Unaddressed.* `entity-events.ts` and the game feed are Problem B. → wants **keyed list reconciliation**.
Problem B is a **list-level** concern, not a cell-level one. In Lit terms it maps to a keyed `repeat()` directive over the tab/list body — the dashboard-card work in Phase 2 already needs this, but `entity-events.ts` needs it for tabs that §5 used to list as "out of scope." This does **not** change the chosen direction (Lit); it adds `entity-events.ts` as a first-class, high-priority target.
---
## 4. Decision ladder
Walked through with the user 2026-05-26. Captured here so we don't re-litigate.
### L1 — drop-in `setInnerHtmlIfChanged` helper
- **Shape:** `WeakMap<Element, string>` cache; replace every `el.innerHTML = x` with `setInnerHtmlIfChanged(el, x)`.
- **Wins:** stops the no-change rewrites globally; zero behavior risk; ~30 call-site changes.
- **Misses:** still tears down DOM when content *does* differ (e.g. FPS row values change every tick); doesn't preserve focus/transition state inside a list.
- **Verdict:** floor, not ceiling. Worth doing for cells that don't get migrated to L3/Lit.
### L2 — lint guard
- **Shape:** pre-commit script greps `\.innerHTML\s*=` in `static/js/` outside an allowlist, fails the commit.
- **Wins:** keeps the discipline; cheap.
- **Misses:** only useful as a pair with L1+; bare guard with no helper makes contributors angry.
- **Verdict:** pair with whatever helper we land on.
### L3 — hand-rolled cell-component pattern
- **Shape:** `defineCell({ html, refs, mount, update, unmount })` + `reconcileList(host, items, binding)` + `setText/setClass/setAttr` mutators. ~150300 lines of runtime.
- **Wins:** correct by construction; no dependencies; explicit about what mutates; composes with existing customize panel / color picker.
- **Misses:** we own the abstraction — it grows over time as we need transitions, async data, focus, devtools, error boundaries. Death by a thousand features.
- **Verdict:** second-best. Strong contender if zero-deps is a hard constraint.
### Lit migration of polling modules — **recommended**
- **Shape:** convert each perf cell + each dashboard card cell to a Lit web component. Use `html\`<span>${value}</span>\`` tagged-template + targeted diff. ~5KB gzip added to bundle, no new build step (esbuild handles it).
- **Wins:** solves the bug class by design; maintained by Google + community; web-components-based so no framework lock-in; composes with vanilla DOM trivially; mental model is close to current template-string idiom; non-polling code can stay vanilla forever.
- **Misses:** introduces a dependency; contributors learn one more thing; rare edge cases (`@html`-equivalent exists and reintroduces the bug if misused).
- **Verdict:** best ceiling-to-cost ratio for a small team. Recommended.
### Full framework rewrite (React / Vue / Solid)
- **Verdict:** overkill. The bug class lives in polling paths; the rest of the app is fine. Spending the migration budget on rebuilding IconSelect / EntitySelect / modals / customize panel / graph editor — none of which are broken — is a bad trade.
---
## 5. Recommendation
**Lit for the polling-heavy modules.**
Migration plan:
### Phase 0 — spike (2-hour time-box)
- Convert `patches` cell to a Lit component, end to end.
- Verify it plays nicely with: color picker integration, customize panel layout reorder, `rerenderPerfGrid` reconciliation, `setPerfMode` toggle, hidden-by-env state, the spark tooltip handler.
- If any of those break in an unfixable way → pivot to L3.
- If they work → commit to the migration.
### Phase 1 — perf-charts cells
1. `patches` (already spiked)
2. `devices`
3. `fps` / `capture_fps` / `capture_fps_actual` (share a sparkline base class)
4. `cpu` / `ram` / `gpu` / `temp` (share `_sparkCardHtml` template family)
5. `network` / `device_latency` / `send_timing` / `errors`
Each is its own PR, dashboard stays working at every step. `renderPerfSection` becomes a registry of Lit components; `rerenderPerfGrid` becomes "reorder existing elements in the grid" (which it mostly already does).
### Phase 2 — dashboard card cells
6. Output target cards (running variant — biggest payoff, has live FPS + uptime + errors)
7. Output target cards (stopped variant)
8. Sync clock cards
9. Automation cards
10. Integration (HA / MQTT) cards
These get bigger wins from the migration because they have nested mutable state (FPS pill, errors cell, health dot, action button) that's currently rebuilt per poll via the `_updateRunningMetrics` path.
### Highest-impact: `entity-events.ts` tab reconciliation (sequence early)
`entity-events.ts` (§3.5) is the single highest-blast-radius site and is **not** on the dashboard — it re-renders the Targets / Integrations / Automations tabs on server push. Whether or not those tabs' cells become Lit components, the loader path (`loadTargetsTab` / `loadIntegrations` / `loadAutomations`) should switch from a full `innerHTML` rebuild to a **keyed list reconcile** (a Lit `repeat()` over the tab body). This preserves scroll / focus / open inline editors across pushes. If the goal is "biggest UX win first" rather than "lowest-risk first," sequence this ahead of Phase 2.
### Phase 3 — stopgap helper for the rest
Add `setInnerHtmlIfChanged` and apply to any remaining vanilla polling sites we don't plan to migrate. Add the L2 lint guard at this point — by now everything that polls is either Lit-managed or uses the helper.
### Out of scope (deliberately) — with one correction (2026-05-27)
- Targets tab, automations editor, integrations, scene presets — these render on-demand, **but they are ALSO re-rendered on server push** via `entity-events.ts` (see §3.5). The original claim that "the bug class doesn't bite them" was **wrong**: a pushed `server:entity_changed` does a full-tab `innerHTML` teardown. The *editor / on-demand views* can stay vanilla, but the **list/tab render that entity-events triggers needs reconciliation** (a keyed list diff) regardless of whether those cells become Lit components. Treat the entity-events reload path as **in-scope** — it is the highest-blast-radius Problem B site.
- Color strips editor, graph editor, settings — genuinely on-demand, no push re-render path, stay vanilla.
- Transport bar cells (CPU/Mem chip in the top bar) — read from the same perf payload, can be migrated opportunistically but not urgent.
---
## 6. Open questions to settle before committing
These came up during the discussion and weren't resolved:
1. **Bundle-size budget.** Is +5KB acceptable? Current bundle is 2.7MB so this is noise — but worth confirming there isn't a strict cap (e.g. for slow networks / Android Chaquopy embed).
2. **Contributor model.** If the project will grow to multiple contributors, Lit's smaller community vs React's is a recruiting tradeoff. Currently solo-ish, so probably moot.
3. **Android TV target.** Chaquopy embed serves the same bundle. Lit works fine in any modern browser — Android TV WebView is Chromium-based. Should be a no-op but verify in Phase 0 spike.
4. **Long-term framework intent.** If there's a chance we ever migrate to React/Vue/Solid for the rest of the app, doing Lit now is *not* lock-in (web components are standard), but it does add a second mental model. Probably fine; just naming the tradeoff.
5. **Customize panel.** The drag-reorder code in `dashboard-customize.ts` mutates `.dashboard-section` DOM directly. Lit components reorder cleanly via `moveBefore` / `insertBefore` since they're just elements, but the dnd library needs to treat them as opaque drag handles. Phase 0 spike should confirm.
---
## 7. Pointers
- Source files most relevant:
- `server/src/ledgrab/static/js/features/dashboard.ts`
- `server/src/ledgrab/static/js/features/perf-charts.ts`
- `server/src/ledgrab/static/js/features/dashboard-layout.ts` (cell ordering + visibility)
- `server/src/ledgrab/static/js/features/dashboard-customize.ts` (drag-reorder UI)
- `server/src/ledgrab/static/js/core/card-modes.ts` (mode toggle that hangs off section headers)
- `server/src/ledgrab/static/js/core/entity-events.ts` (push-driven tab reloads — §3.5, highest blast radius)
- `server/src/ledgrab/static/js/core/events-ws.ts` (WS → `server:*` CustomEvent dispatch)
- `server/src/ledgrab/static/js/features/game-integration.ts` (2 s event-feed list rebuild — §3.5)
- Most recent reconciliation commit: `f6486f9`.
- Related skill files in `~/.claude/skills/`: `frontend-patterns`, `documentation-lookup` (for Lit docs via Context7).
- Locale convention: `perf.*` for cross-card primitives, `dashboard.perf.*` for cell titles.
---
## 8. If this doc gets stale
If you read this and the perf cells are already Lit components — delete this file. If you read this and there's a new flicker / focus / transition bug nobody can explain — search for `\.innerHTML\s*=` in `static/js/features/` **and `static/js/core/`** (`entity-events` lives in core) and you've probably found it. For *state loss on a server event* (scroll jump, focus drop, an inline editor closing itself), look at the `server:*` listeners in `core/entity-events.ts` first.
+180
View File
@@ -0,0 +1,180 @@
# Production Review — Remaining Items
Output of the multi-agent production review (security / Python / TypeScript /
performance / architecture / code-quality). Each entry below is something
the original audit flagged and the autonomous hardening pass deliberately
did **not** address — either because it needs design input, profiling
validation, or a multi-day refactor that should land in its own session.
The hardening pass landed everything else: see git log between `master` and
the head of the review branch for the applied changes (URL-scheme +
malicious-input rejection, IconSelect XSS escape, MiniSelect for forbidden
plain `<select>`s, WebSocket Origin allow-list, /docs auth-gate, security
headers middleware, streaming upload size caps, fire-and-forget task
tracking + drain resilience in MQTT runtime, discovery_watcher task
tracking, asyncio.gather return_exceptions, secret_box encryption for MQTT
/ Hue / Govee credentials with auto-migration, SSRF-validated update
redirects, single source of truth for IP classification in
`utils/net_classify.py`, allowlist + parity test for inbound WS events,
typed `Window` globals, and more).
## Items completed in the follow-up autonomous pass (2026-05-23)
- [x] **devices.py PATCH-without-url processor desync**`update_device`
now falls back to `existing.url` so a rename / icon-only edit
always tells the processor the current address.
- [x] **WLED scheme integration test** on `/api/v1/devices` — covers
bare IPv4 (`http://`), public hostname (`https://`), and trailing-slash
normalisation; lives in `tests/api/routes/test_devices_routes.py`.
- [x] **IPv6 regression test**`tests/test_url_scheme.py` now pins
public IPv6 → `https://`, ULA → `http://`, and documents the
Python-`ipaddress` documentation-prefix classification quirk.
- [x] **IconSelect XSS audit + defence-in-depth** — every caller
audited (all feed `icon` from constants or lookup tables); added
`sanitiseIcon` that rejects `<script>`, `javascript:`, `on*=`,
`<iframe>`, `<embed>`, `<object>` and warns to the console.
- [x] **`Optional[T]``T | None` (PEP 604)** — 55 sites cleaned via
`ruff --fix UP007`. The remaining `Union[…]` aliases for
pixel/colour/device-config typing converted by hand. `UP007` now
lives in `pyproject.toml` so the rule fires on new code.
- [x] **Hot-path magic numbers → named constants**`processed_stream`
gains `_FILTER_RECHECK_EVERY_N_FRAMES`; `wled_target_processor`
gains `_SKIP_REPOLL_SLEEP_SECONDS`, `_DIAGNOSTICS_REPORT_INTERVAL_SECONDS`,
`_CSPT_RECHECK_EVERY_N_ITERATIONS`.
- [x] **`api/auth.py` `except Exception` tightening** — every WS send /
close site is now `except _WS_SEND_BENIGN_EXC` (a narrow tuple of
WebSocketDisconnect / RuntimeError / ConnectionError / OSError).
The auth-receive path catches the same set plus a final
`logger.exception` catch-all for observability on truly unexpected
shapes.
- [x] **`(window as any)` cleanup** — 59 static-property accesses
migrated to typed `window.<name>` against `global-types.d.ts`. The
remaining 7 sites use dynamic string indexing (`window[fnName]`)
and intentionally keep the cast (documented in the typedef file).
---
## Architecture refactors (multi-day — own session)
- [ ] **Split `core/processing/value_stream.py`** (1856 LOC, 14 stream classes)
into a `value_streams/` package. Each value-stream type gets its own
file ≤300 LOC; `manager.py` holds `ValueStreamManager`.
- [ ] **Split `storage/color_strip_source.py`** (1841 LOC, 18 source kinds)
into a `color_strip_sources/` package mirroring `value_streams/`.
- [ ] **Frontend file splits**`graph-editor.ts` (2707), `streams.ts`
(2335), `value-sources.ts` (1889), `types.ts` (1062). Highest-churn
modules; mixed UI / state / network responsibilities.
- [ ] **Layering reversal**: introduce a neutral `domain/` package and move
shared DTOs (`FilterInstance`, `CalibrationConfig`, etc.) into it so
`storage/` no longer imports `core/`. Eliminates 7+ layering
violations and the lazy-import hacks used to break the resulting
circulars.
- [ ] **`main.py` boot refactor** — extract import-time side effects into
`bootstrap.py` + `create_app()` factory. `lifespan()` becomes the
single place that wires stores and managers.
- [ ] **DI consolidation** — replace `api/dependencies.py` getter sprawl
(30+ `get_*()` functions reading a process-global `_deps` dict) with
a single typed `get_container()` dependency. Makes test-overrides
trivial; ban direct getter calls in handler bodies.
- [ ] **Exception hierarchy** — define `ledgrab/errors.py` (`LedGrabError`,
`NotFoundError`, `ValidationError`, `RemoteUnavailableError`,
`SSRFBlockedError`). Move HTTP translation into a FastAPI exception
handler. Stop raising `HTTPException` from `utils/safe_source.py`.
- [ ] **Lazy-import audit** — 289 in-function `from ledgrab.*` imports.
Specifically `core/processing/daylight_settings.py` imports
`api.dependencies` (core → api inversion). Pass the database in via
the constructor instead of service-locator lookup.
## Performance (profile before applying)
- [ ] **`composite_stream.py` blend modes** — pre-allocate scratch buffers
in `_blend_override / overlay / hard_light / soft_light / difference
/ exclusion`. Each currently allocates per frame (`mul`, `scr`,
`blended`, `np.where(...)`). At 100 LEDs × 30 fps × N layers this
adds up.
- [ ] **`mapped_stream` / `composite_stream` zone resize** — replace the
per-channel `np.interp` calls with a cached `floor/ceil/frac` LUT
(same trick as `wled_target_processor._fit_to_device`) or a single
`cv2.resize` call on the (N,3) array. `np.interp` allocates a new
`float64` array per channel per frame even on cache-hit.
- [ ] **`processed_stream._processing_loop`** — add ping-pong output
buffers and pass them as `out=` to filter `process_strip()` calls.
Today every filter that returns a fresh allocation costs us a copy
per frame. Also: the loop uses `time.sleep` instead of an
event-driven wait on the input stream — input updates faster than
30 fps see up to `frame_time` of latency.
- [ ] **`mqtt_client.py` `send_pixels`** — add a binary publish path (or
at minimum cache the outer dict skeleton). Today every frame
`pixels.tolist()` + `json.dumps` for ~300 LEDs × 30 fps × N devices.
- [ ] **Frontend `static/js/features/color-strips/test.ts`** — cache
`ImageData` per canvas (`canvas._imageData`); only re-create on
dimension change; use a `Uint32Array` view to copy pixels in one
loop instead of the per-pixel JS loop. Border-overlay rebuild on
every frame should also be debounced to dimension changes only.
- [ ] **`ws_stream.py` composite branch** — pre-allocate a `bytearray`
sized to the largest frame and write into slices instead of
`b"".join(tobytes()) per layer` every iteration. Same anti-pattern
in `wled_target_processor._broadcast_led_preview`.
- [ ] **Preview broadcast slow-client guard**`asyncio.gather` over
preview clients waits for the slowest. Move to `asyncio.wait` with a
timeout and drop slow clients, or fire-and-forget with a
`ws.application_state` filter.
## Security (deferred — non-trivial or design-sensitive)
- [ ] **Content-Security-Policy header** — would need careful tuning
because the UI uses inline event handlers / Jinja templates.
Mis-set CSP would break the app silently. Defer until templates can
move to event-delegated handlers, then add a strict policy.
- [x] **`api/auth.py` exception specificity** — done in the 2026-05-23
pass; see top of file.
- [ ] **Hue bridge cert pinning**`httpx.AsyncClient(verify=False)` for
Hue bridge (self-signed cert by design). Should record the
certificate fingerprint at pairing time and pin it on subsequent
requests; otherwise an on-path attacker can MITM the bridge.
## Mechanical / code-quality (low risk, high line-count)
- [ ] **i18n parity** — confirmed **328** keys missing in `ru.json` and
**325** missing in `zh.json` against the canonical English file.
Translation work — needs a native speaker, not a machine-translation
pass. Run `py scripts/diff_locale_keys.py` (or copy the diff block
out of the 2026-05-23 pass log) to get the exact key list.
- [x] **`Optional[T]``T | None`** — done; `UP007` now enforced via
`pyproject.toml` so the rule prevents regressions.
- [ ] **Hot-path `logger.error(f"...")` → `logger.error("... %s", e)`**
lazy-eval — 658 sites flagged by `ruff --select G004`. Deferred
because it is genuinely cosmetic at ERROR level (always emitted)
and the cumulative cost is negligible. Worth doing if/when ruff
gains a safe autofix, or as a Codemod in a dedicated session.
- [x] **Remaining `(window as any)` sites** — 59 migrated to typed
`window.<name>` access; the 7 surviving sites use dynamic string
indexing and are documented as the legitimate exception.
- [x] **Magic numbers → named constants** — done; see `processed_stream`
and `wled_target_processor` constants at the top of each module.
- [ ] **Standardise `from __future__ import annotations`** — partially
mooted by the UP007 cleanup. Files that previously relied on
`Optional`/`Union` no longer need the future import; the few that
already use `__future__` keep it for forward-reference convenience.
A blanket policy would still help — leave as a stylistic followup.
## Test gaps
- [x] **Route-level integration test** for the WLED scheme inference —
done; covers create + update in `tests/api/routes/test_devices_routes.py::TestWLEDSchemeInference`.
- [x] **IPv6 public address regression** — done; pinned in
`tests/test_url_scheme.py` for both bracketless and bracketed forms.
## Pre-existing issues surfaced during the audit (not in our diff)
These were flagged by the auditors but predate the review session — kept
here as a future-work backlog:
- [x] **`icon-select.ts:_buildGrid` `item.icon` is interpolated raw** —
audited; all callers pass project-owned literals or table-lookup
results. Added a runtime sanitiser as defence-in-depth.
- [x] **`devices.py` `manager.update_device_info(device_url=update_data.url)`**
None-on-PATCH path — fixed; now falls back to `existing.url`.
- [ ] **`asyncio.gather` over uncapped client lists** in preview broadcasts
— slow clients block the loop. Already noted under Performance
above; pre-existing.
+855 -1
View File
@@ -1,5 +1,703 @@
# LedGrab TODO # LedGrab TODO
## HTTP polling automation trigger
Goal: a new automation trigger that periodically polls an HTTP endpoint
and activates a scene when the response matches a condition. Split into
three single-responsibility entities so the endpoint can be reused
beyond automations (e.g. as a value-source driving brightness/color):
- `HTTPEndpoint` (storage/http_endpoint.py) — connection definition:
URL + auth + headers + timeout. NO polling cadence; NO extraction.
- `HTTPValueSource` (storage/value_source.py, source_type='http') —
references an endpoint + owns json_path + interval + min/max + EMA
smoothing. Backed by `HTTPValueStream` (core/processing/value_stream.py)
which lives under the existing `ValueStreamManager` (ref-counted,
one poll task per unique value source).
- `HTTPPollRule` (storage/automation.py) — thin: `{value_source_id,
operator, value}`. Reads `stream.get_raw_value()` from the value
source and compares with `_apply_operator`.
Pivoted from a 2-entity shape mid-build (was: HTTPSource+rule with
interval+json_path mushed). The 3-entity shape mirrors HA's pattern
(HomeAssistantSource → HAEntityValueSource → rule).
### Phase 1 — endpoint + value source + thin rule (backend) ✅
- [x] `storage/http_endpoint.py` — `HTTPEndpoint` dataclass with
secret_box auth_token encryption + `__post_init__` plaintext
invariant. NO `default_interval_s` (moved to value source).
- [x] `storage/http_endpoint_store.py` — `HTTPEndpointStore` with
`_migrate_plaintext_tokens()`. ID prefix `htep_`.
- [x] `storage/database.py` — `"http_endpoints"` in `_ENTITY_TABLES`
(replaces the old `"http_sources"`).
- [x] `storage/value_source.py` — added `HTTPValueSource` alongside
`HAEntityValueSource` (endpoint_id, json_path, interval_s,
min/max, smoothing). Registered in `_VALUE_SOURCE_MAP`.
- [x] `storage/value_source_store.py` — CRUD branch for `source_type =
"http"` + new kwargs on create/update.
- [x] `core/processing/value_stream.py` — `HTTPValueStream` with poll
task + `get_value()` (normalized 0-1) + `get_raw_value()` (raw
extracted value). Dispatched in `ValueStreamManager._create_stream`.
Manager now takes `http_endpoint_store` so the stream can resolve
endpoints at fetch time.
### Phase 2 — rule + engine wiring ✅
- [x] `storage/automation.py` — `HTTPPollRule` is now thin: just
`{value_source_id, operator, value}` (no http_source_id, no
json_path on the rule). Legacy keys silently dropped on load.
- [x] `core/automations/automation_engine.py` — drops the standalone
http_poll_manager; takes `value_stream_manager`. Engine
`_sync_value_stream_refs` acquires/releases value streams for
every enabled HTTPPollRule, mirroring the HA/MQTT sync pattern.
`_evaluate_http_poll` reads `stream.get_raw_value()` and applies
the operator. `_apply_operator` kept at module top.
- [x] `api/schemas/automations.py` — RuleSchema fields are now
`value_source_id + operator + value` (dropped http_source_id +
json_path).
- [x] `api/routes/automations.py` — `http_poll` factory updated.
### Phase 3 — CRUD endpoints + wiring ✅
- [x] `api/schemas/http_endpoints.py` — Create/Update/Response/List/Test
(no interval field; that's on the value source).
- [x] `api/routes/http_endpoints.py` — full CRUD + `/test` +
plaintext-http-token warning.
- [x] `api/schemas/value_sources.py` — `HTTPValueSource{Create,Update,Response}`
added to the discriminated unions.
- [x] `api/routes/value_sources.py` — `_RESPONSE_MAP` entry for
`HTTPValueSource`.
- [x] `api/__init__.py` — `http_endpoints_router` registered.
- [x] `api/dependencies.py` — `get_http_endpoint_store` (dropped the
http_poll_manager getter).
- [x] `main.py` — instantiate `HTTPEndpointStore`, pass it through
`ProcessorDependencies`, wire `value_stream_manager` +
`value_source_store` into `AutomationEngine`.
- [x] `core/processing/processor_manager.py` — `ProcessorDependencies`
gains `http_endpoint_store`; threaded into `ValueStreamManager`.
### Phase 4 — tests ✅
- [x] `tests/storage/test_http_endpoint_store.py` — 14 tests (CRUD +
auth_token encryption + headers + case-insensitive Authorization).
- [x] `tests/core/test_automation_engine.py` — `TestApplyOperator` +
`TestHTTPPollRuleEvaluation` (new shape: mock ValueStreamManager
with `_streams` dict) + `TestSyncValueStreamRefs` (acquire /
release / disabled-ignored) + `TestHTTPValueStreamExtraction`
(`_extract_simple_path` now lives in value_stream.py).
- [x] `tests/api/routes/test_http_endpoints_routes.py` — CRUD shape, no
auth_token leak in responses, schema-layer method allowlist,
CRLF / invalid header rejection, `/test` endpoint, LAN policy.
- [x] Removed: `tests/core/test_http_poll_manager.py` (manager deleted —
polling now lives inside `HTTPValueStream`).
- [x] Full suite: 1426 passed, ruff clean.
### Phase 5 — frontend ✅
- [x] `static/js/features/http-endpoints.ts` (new, ~540 LOC) — endpoint
CRUD, modal subclass with dirty-check, headers row editor, test
result rendering, card builder, event delegation. Mirrors
`home-assistant-sources.ts`.
- [x] `templates/modals/http-endpoint-editor.html` (new) — sectioned
rack-panel modal (Identity / Request / Headers / Notes) with
IconSelect method picker, password-toggle on auth token, inline
Test button + result block.
- [x] `static/js/features/value-sources.ts` — added `http` branch with
EntitySelect over `httpEndpointsCache`, edit-data/defaults,
`onValueSourceTypeChange` section toggle, save-payload assembly
+ required-field validation.
- [x] `templates/modals/value-source-editor.html` — new
`#value-source-http-section` with endpoint picker + json_path +
interval + min/max + smoothing.
- [x] `static/js/features/automations.ts` — `http_poll` rule type with
operator IconSelect + value-source EntitySelect; hides Value
field when operator is `exists`.
- [x] `static/js/features/integrations.ts` — `csHTTPEndpoints` section,
tree/tab entry, render + reconcile + delegation paths.
- [x] `static/js/types.ts` — `HTTPEndpoint`, `HTTPMethod`,
`HTTPEndpointListResponse`, `HTTPTestRequest/Response`,
`HTTPValueSource`, `HTTPPollOperator`; extended `RuleType` +
`AutomationRule`.
- [x] `static/js/core/state.ts` — `httpEndpointsCache` (`/http/endpoints`).
- [x] `static/js/core/icons.ts` — `http: P.globe` in
`_valueSourceTypeIcons`.
- [x] `templates/index.html` — includes
`modals/http-endpoint-editor.html`.
- [x] Locales: 77 new keys per file in `en.json` / `ru.json` /
`zh.json` (parity confirmed).
- [x] Verification: `npx tsc --noEmit` clean; `npm run build` clean
(app.bundle.css 366.6kb, app.bundle.js 2.7mb).
### Follow-ups (out of scope for initial PR)
- [ ] **Global concurrency cap / minimum interval.** Each
`HTTPValueStream` runs its own task at `interval_s` (min 1s); no
project-wide cap. Reviewer flagged: pick a min (e.g. 5s) + max
active runtimes (e.g. 32) + shared `httpx.AsyncClient` with
`limits=httpx.Limits(max_connections=N)`.
- [ ] **DNS-rebinding hardening.** `safe_request_bounded` validates
the URL hostname's resolved IPs once; httpx independently
re-resolves. The window is short but not zero. True fix: pin
to the validated IP + set Host header (and SNI for HTTPS). This
affects every outbound caller (`safe_fetch`, weather, image
sources) — handle as a project-wide hardening, not local to
this feature.
- [ ] **`delete_http_endpoint` orphan refs.** When an admin deletes an
endpoint referenced by N value sources, the value-stream task
keeps polling until its source is also deleted. Same shape as
the MQTT defect — fix both together (refuse-with-409 when in
use, or cascade value-source deletion).
- [ ] **Per-endpoint `connected` / last-poll status on the response**
(frontend agent flagged). `HTTPEndpointResponse` has no live
status, unlike HA/MQTT sources. Card LEDs default to "on".
Could aggregate `last_status_code` / `last_error` from all
`HTTPValueStream` instances referencing the endpoint and surface
on `GET /http/endpoints/{id}`.
- [x] **Per-endpoint live `/test` after save** — added `POST
/http/endpoints/{id}/test` (runs stored config server-side so the
auth token never round-trips) and wired a flask-icon test action
on the endpoint card (toasts the result). Custom-headers section
and inline test-result UI in the editor modal also restyled to
match the `.group-child-row` and result-card vocabulary.
- [ ] **Dedicated icon for HTTP value source / endpoint** (frontend
agent flagged). Both use `P.globe` — visually fine in practice
but adding a `cable`/`webhook` glyph in `icon-paths.ts` would
improve differentiation.
## Multi-broker MQTT refactor
Goal: drop the global `MQTTService` / `MQTTConfig`. Every MQTT consumer
references an `MQTTSource.id`; `MQTTManager` is the only entry point.
`MQTTManager` + `MQTTRuntime` already exist — the job is to migrate every
caller off the legacy path, then delete it.
### Phase 1 — `mqtt_source_id` on Z2M target
- [x] Field on `Z2MLightOutputTarget` storage dataclass (+ to/from_dict)
- [x] Field on Z2M create/update/response schemas
- [x] Validate referenced `MQTTSource` exists at create/update
- [x] Thread through `output_target_store.create_z2m_light_target` + update
- [x] Thread through `ProcessorManager.add_z2m_light_target`
- [x] Thread through `Z2MLightTargetProcessor` constructor
### Phase 2 — Z2M processor uses `MQTTManager`
- [x] Replace `_mqtt_service` with `_mqtt_runtime` acquired from manager
- [x] `start()` acquire / `stop()` release
- [x] `_publish_payload` → `self._mqtt_runtime.publish(...)`
- [x] `turn_off_lights` borrow-pattern via manager (mirror HA-light)
- [x] Add `mqtt_manager` to `ProcessorDependencies` / `TargetContext`
### Phase 3 — Z2M editor UI
- [x] Add MQTT broker `EntitySelect` in Routing
- [x] Reuse `mqttSourcesCache`
- [x] Wire `mqtt_source_id` into edit-load + save payload + validation
### Phase 4 — DIY MQTT device (`MQTTLEDClient`)
- [x] `mqtt_source_id` field on `Device` storage
- [x] Field on `device_config.MQTTConfig`
- [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()`
- [x] Provider threads `mqtt_manager` via `ProviderDeps`
- [ ] Device editor: MQTT source picker shown for `device_type=mqtt` *(UI still
pending — backend accepts the field, but the device-create form doesn't
expose it yet)*
### Phase 5 — `AutomationEngine`
- [x] Drop `mqtt_service` ctor parameter
- [x] Drop legacy fallback in `_evaluate_mqtt` (rule must reference a source)
### Phase 6 — `api/routes/system.py`
- [x] Replace integration status with `mqtt_manager.get_all_sources_status()`
- [ ] Update frontend dashboard payload (MQTT widget now expects a list of
sources instead of a single `enabled`/`connected` pair — surface in UI)
### Phase 7 — Startup migration
- [x] Seed a "Default Broker" `MQTTSource` if legacy YAML / env had a
broker configured and the store is empty (`core.mqtt.legacy_migration`)
- [x] Deprecation warning logged on migration; YAML/env no longer read after
### Phase 8 — Remove legacy
- [x] Delete `core/mqtt/mqtt_service.py`
- [x] Delete `set_mqtt_service` / `get_mqtt_service` (mqtt_client.py)
- [x] Remove `MQTTService` from `main.py`
- [x] Remove `MQTTConfig` + `resolve_mqtt_password` from `config.py`
- [x] Remove `mqtt: MQTTConfig` from `Config` (with `extra="ignore"` so legacy
YAML still loads)
### Phase 9 — Verification
- [x] `pytest tests/ --no-cov -q` clean (973 passing; removed obsolete
`test_default_mqtt_disabled`)
- [x] `ruff check src/` clean
- [x] `tsc --noEmit` + `npm run build`
- [ ] Smoke test: Z2M target on a configured MQTT Source publishes to broker
(manual)
## Refactor: typed output-target factories + auto-registry
Replaced `target_type` string elif chains in `OutputTargetStore` and
`OutputTarget.from_dict` with: (1) `__init_subclass__` registry for
deserialization, (2) per-type typed `create_*_target` /
`update_*_target` methods called directly from the route layer's
`match data:` dispatch. API contract unchanged, no DB migration.
### Phase 1 — Registry on `OutputTarget`
- [x] Added `_registry` + `_type_key` ClassVars + `__init_subclass__(*, type_key)`
- [x] Rewrote `OutputTarget.from_dict` to dispatch via registry
- [x] Declared `type_key="led"` / `"ha_light"` / `"z2m_light"` on the three subclasses
### Phase 2 — Typed `create_*_target` methods
- [x] Extracted `_resolve_brightness`, `_resolve_transition`, `_check_unique_name`,
`_new_id_and_now`, `_finalize` helpers on the store
- [x] Added `create_wled_target` / `create_ha_light_target` / `create_z2m_light_target`
with per-type defaults (transition 0.5/0.3, update_rate 2.0/5.0) baked into
their signatures
### Phase 3 — Typed `update_*_target` methods
- [x] Added `update_wled_target` / `update_ha_light_target` / `update_z2m_light_target`
with `_begin_update` / `_commit_update` helpers
- [x] Each typed update method validates the target's class before mutating
### Phase 4 — Route migration
- [x] `create_target` route uses `match data:` to call typed store methods —
no more `getattr(data, "x", default)` pyramid
- [x] `update_target` route uses `match data:` and computes `settings_changed` /
`css_changed` / `brightness_changed` per-arm from typed fields
- [x] Helpers `_build_ha_mappings`, `_build_z2m_mappings`,
`_validate_device_exists`, `_resolve_effective_color_vs_id` extracted
### Phase 5 — Decision: keep both shims
After grepping for callers, `src/ledgrab/core/scenes/scene_activator.py:90`
calls `target_store.update_target(target_id, **changed)` with a dynamically
built dict — it legitimately doesn't know the target's type at the call site.
The shims are now ~30-line dispatchers that route to typed methods (no more
inline construction elif chains), so the original anti-pattern is gone while
the generic API remains available for "don't-know-the-type" callers like the
scene activator. Tests continue to use the shorthand `create_target("A", "led")`
form without churn.
### Phase 6 — Verify
- [x] `ruff check` clean on all modified files
- [x] `py -3.13 -m pytest tests/ --no-cov -q` — 974 passed (was 974 before)
- [ ] Manual smoke test in UI: create/edit/delete each of the three target types
## 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 (notifications)
- [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,9 +818,165 @@ 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)
- [x] Phase 4 — migrate `tests/test_group_device.py` to `GroupConfig`/`ProviderDeps`; remove legacy `GroupLEDClient` init path; 47-test config suite with 100% coverage on `device_config.py` - [x] Phase 4 — migrate `tests/test_group_device.py` to `GroupConfig`/`ProviderDeps`; remove legacy `GroupLEDClient` init path; 47-test config suite with 100% coverage on `device_config.py`
- [ ] Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in `api/schemas/devices.py`; scope frontend POST/PATCH payloads by `device_type` - [ ] Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in `api/schemas/devices.py`; scope frontend POST/PATCH payloads by `device_type`
## Expand device support (Phase 1: open protocols)
Branch: `feat/expand-device-support`.
Goal: maximize the universe of LED controllers LedGrab can drive by adding aggregator + open-protocol providers in roughly-this order. Each driver follows the established `LEDDeviceProvider` + `*Config` + tests pattern.
### Phase 1.1 — Standalone DDP target ✅ shipped (commit `8f1140a`)
DDP packet layer (previously WLED-internal) promoted to a first-class device
type. Pixelblaze, ESPixelStick, xLights/Falcon endpoints, and generic DDP
receivers are now drivable directly without WLED in the path.
### Phase 1.2 — Yeelight LAN
Xiaomi/Yeelight bulbs, port 55443 TCP JSON. Direct protocol (no
`python-yeelight` dependency — implementation is ~200 lines).
- [x] `YeelightConfig` dataclass with `yeelight_min_interval_ms` rate limit
- [x] `YeelightClient` in `core/devices/yeelight_client.py` — TCP JSON-RPC,
averaging single-pixel adapter, client-side rate gate
- [x] SSDP-style discovery (Yeelight's variant on `239.255.255.250:1982`)
- [x] `YeelightDeviceProvider` with validate/health/discover
- [x] Storage + API schemas + route handler wiring
- [x] 34 unit tests (URL parsing, RGB packing, averaging, rate limit, SSDP
parsing, provider validate/discover, Device.to_config round-trip)
- [ ] Frontend: Yeelight in device-type picker + edit form (spawned to a
`frontend-design` subagent)
- [ ] Locale strings (en/ru/zh)
- [ ] Music mode (~60 Hz updates via reverse-TCP) — follow-up, current
MVP caps at ~2 Hz via the client-side rate gate
### Phase 1.3 — WiZ Connected
Philips' UDP-local budget tier. Port 38899 JSON UDP.
- [x] `WiZConfig` + `WiZClient` + `WiZDeviceProvider`
- [x] UDP broadcast discovery on 255.255.255.255:38899 with the standard
`registration` envelope; replies parsed for IP+MAC.
- [x] Sync `send_pixels_fast` for the hot loop (UDP is fire-and-forget,
no async needed). 50 ms default min interval → ~20 Hz cap.
- [x] Health check sends `getPilot` and waits for any reply.
- [x] Storage + API schemas + route handler wiring
- [x] 36 unit tests
- [ ] Frontend: WiZ in device-type picker + edit form
- [ ] Locale strings (en/ru/zh)
### Phase 2 — Unified discovery + pairing UX layer
After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen for replies, present a list". Factor that out into a generic discovery scaffold + a "first-run pairing" UX component before adding Tuya/Govee/etc., which each need a one-time pairing dance.
- [WONTDO] Generic `NetworkDiscoveryService` — the existing
`/api/v1/devices/discover` route already runs all providers in parallel
via `asyncio.gather(return_exceptions=True)`. Extracting it would not
unlock anything; revisit only if discovery cadence/dedup becomes a
real complaint.
- [WONTDO] Unified scan UI — already exists; one "Scan network" button
triggers the cross-provider fan-out.
- [x] **Reusable pair-device scaffold** (the actually-needed piece).
Backend: `LEDDeviceProvider.pair_device(url)` abstract method with
`PairingNotReady` sentinel; `POST /api/v1/devices/pair` endpoint
with status-code mapping (200/400/409/422/502); 8 route tests
covering every outcome. Frontend: `templates/modals/pair-device.html`
five-state modal (idle / pairing / not-ready / success / failed)
with a 30-second SVG progress ring; reusable
`static/js/features/pairing-flow.ts` exposing
`runPairingFlow({deviceType, url}) → Promise<{fields}>` with
`PairingCancelled` sentinel; locale strings in en/ru/zh. No driver
uses it yet — Nanoleaf will be the first concrete consumer.
### Phase 3 — Big aggregator unlocks
- [ ] ESPHome native API (`aioesphomeapi`)
- [ ] Tuya Local (`tinytuya`) — biggest single market unlock; needs the pairing UX from Phase 2
- [ ] Matter over IP (forward-looking)
- [ ] Hyperion JSON downstream
### Phase 4 — Major consumer brands
- [x] **LIFX LAN** — UDP binary protocol on port 56700; RGB→HSBK 16-bit
conversion; broadcast discovery via GetService/StateService probe;
47 unit tests. Single-pixel adapter shape, identical to WiZ
structurally. Frontend wired via subagent.
- [x] **Govee LAN API** — UDP JSON on port 4003 (control) + 4002
(responses) + 4001 (multicast discovery on 239.255.255.250).
Single-pixel `colorwc` command with `colorTemInKelvin=0` for RGB
mode. **Per-device "LAN Control" toggle required in Govee Home
app.** 40 unit tests. Frontend wired via subagent.
- [x] **Nanoleaf OpenAPI** — Light Panels / Canvas / Shapes / Lines /
Elements via HTTP REST on port 16021. **First concrete user of
the pairing-UX scaffold from Phase 2.** mDNS discovery via
`_nanoleafapi._tcp`. Single-pixel adapter (averaged strip → HSB
`PUT /state`). Auth token encrypted at rest via `_enc`/`_dec`.
42 unit tests covering URL parsing, RGB→HSB conversion, pairing
handshake (200/403/500/missing-token/transport-error), state
mutations, brightness clamping, Device.to_config round-trip
including encrypted-token roundtrip.
- [ ] Twinkly — multi-pixel + login flow; deferred
- [WONTDO] Mi-Light / MiBoxer UDP gateway — the recommended path for
modern Mi-Light deployments is `esp8266_milight_hub` firmware → MQTT,
which LedGrab already supports through the existing MQTT device target
(commit `530316c`). Native V6 driver would be ~400 lines + finicky
session protocol + custom 1-byte hue table; the marginal benefit over
the MQTT path is small. Revisit if a user complaint surfaces.
### Phase 5 — Open pixel protocols (cheap completionism)
- [x] **OPC (Open Pixel Control)** — TCP, port 7890, 4-byte header
`[channel][cmd][len_hi][len_lo]` + RGB body. Channel 0 broadcasts.
Single-pixel-strip protocol, no discovery, no pairing. 36 unit
tests. Fadecandy + xLights + hobbyist receivers reachable.
- [ ] TPM2.net
### Phase 6 — PC gaming RGB completion
- [ ] Corsair iCUE SDK
- [ ] Logitech LIGHTSYNC
- [ ] ASUS Aura SDK
### Phase 7 — Proprietary USB HID ambient kits
- [ ] Generic HID-ambient framework + VID/PID registry
- [ ] First reverse-engineered target (probably Govee Immersion / DreamView)
### Cleanup + verification
- [x] **`_average_color` extraction** (commit `cc87fba`). Six identical
copies (Yeelight / WiZ / LIFX / Govee / Nanoleaf / BLE) collapsed
into `core/devices/pixel_reduce.average_color`. Net -76 lines.
Hue is out by design — its Entertainment API addresses up to seven
lights individually.
- [x] **Pre-merge verification pass.** 1358 pytest tests pass; ruff
clean across all device modules and tests; black clean against
the pre-commit-pinned 24.10.0; `npx tsc --noEmit` clean; bundle
compiles.
- [x] **Pre-merge code review (subagent)** — surfaced 2 CRITICAL +
4 HIGH + 3 MEDIUM + 3 LOW findings.
- [x] **All review findings fixed** (commits `7736bc6` + `0e3ae78`):
- CRITICAL #1: missing `url_scheme.py` / `net_classify.py`
committed (4 files / 557 lines).
- CRITICAL #2: `update_device` no longer re-encrypts secrets in
memory via the `to_dict()` round-trip (uses `vars()` directly).
- HIGH #3: `nanoleaf_token` / `hue_username` / `hue_client_key`
stripped from `DeviceResponse`; replaced with paired-flag
booleans. Frontend updated.
- HIGH #4: `validate_lan_host()` rejects literal public IPs at
each driver's `validate_device` + `pair_device`.
- HIGH #5: `_dec()` failures clear the field and log, not crash
the row.
- HIGH #6: update route now rstrip's URL for all device types.
- MEDIUM #7: Govee discovery serialized via `asyncio.Lock`.
- MEDIUM #8: Nanoleaf mDNS browser cleanup moved to `finally`.
- MEDIUM #9: pair endpoint sanitizes URL userinfo in logs.
- LOW: Nanoleaf `.port` property added; pair-then-create E2E test
added.
- Tests: 1379 pass (+21 regression tests).
+44 -11
View File
@@ -30,23 +30,39 @@ val ledgrabVersionCode: Int = run {
android { android {
namespace = "com.ledgrab.android" namespace = "com.ledgrab.android"
compileSdk = 34 // SDK 35 (Android 15) — required for Play Store from Aug 2025 onward.
compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.ledgrab.android" applicationId = "com.ledgrab.android"
minSdk = 24 // Android 7.0 — covers nearly all TV boxes minSdk = 24 // Android 7.0 — covers nearly all TV boxes
targetSdk = 34 targetSdk = 35
// Derived from git commit count (or ANDROID_VERSION_CODE env var // Derived from git commit count (or ANDROID_VERSION_CODE env var
// 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.2" versionName = "0.8.0"
// ABI selection. Detect armeabi-v7a wheel presence and opt the
// ABI in only when the matching pydantic-core wheel is on disk —
// otherwise Chaquopy would fail the build searching for it. The
// build script (build-scripts/build-pydantic-core.sh) is the
// source of truth for which ABIs we *can* ship.
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
val ledgrabAbis = buildList {
add("arm64-v8a")
add("x86_64")
add("x86")
if (v7Wheel) add("armeabi-v7a")
}
ndk { ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern // arm64-v8a is the primary target (real TV hardware).
// emulators), x86 (legacy emulators). Wheels in android/wheels/ // x86_64/x86 cover emulators.
// must be kept in sync — see build-scripts/build-pydantic-core.sh. // armeabi-v7a is opt-in: many pre-2018 Mecool/X96/H96 TV boxes
abiFilters += listOf("arm64-v8a", "x86_64", "x86") // still ship 32-bit ARMv7 — when a wheel exists in wheels/ we
// automatically include the ABI in builds.
abiFilters += ledgrabAbis
} }
} }
@@ -54,9 +70,12 @@ android {
// Each split contains only one native ABI's shared libraries + wheels. // Each split contains only one native ABI's shared libraries + wheels.
splits { splits {
abi { abi {
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
isEnable = true isEnable = true
reset() reset()
include("arm64-v8a", "x86_64", "x86") include("arm64-v8a", "x86_64", "x86")
if (v7Wheel) include("armeabi-v7a")
isUniversalApk = true // also produce a fat APK for sideloading isUniversalApk = true // also produce a fat APK for sideloading
} }
} }
@@ -96,10 +115,21 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",
) )
signingConfig = if (hasCiSigning) { // Refuse to silently sign release APKs with the debug
signingConfigs.getByName("release") // keystore — that's how a debug-signed release accidentally
} else { // ships. CI must provide all four signing env vars. If a
signingConfigs.getByName("debug") // local "release" build is genuinely intended for testing,
// set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to opt out.
val allowDebugSigned =
System.getenv("ANDROID_ALLOW_DEBUG_SIGNED_RELEASE") == "1"
signingConfig = when {
hasCiSigning -> signingConfigs.getByName("release")
allowDebugSigned -> signingConfigs.getByName("debug")
else -> throw GradleException(
"Release builds require signing env vars " +
"(ANDROID_KEYSTORE_PATH/PASSWORD, ANDROID_KEY_ALIAS/PASSWORD). " +
"Set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to force a debug-signed release."
)
} }
} }
} }
@@ -175,6 +205,9 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-service:2.8.7") implementation("androidx.lifecycle:lifecycle-service:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// SplashScreen API — keeps a friendly logo on screen while Chaquopy
// unpacks the Python stdlib on first launch (can take 1-3s).
implementation("androidx.core:core-splashscreen:1.0.1")
// QR code generation for displaying server URL on TV // QR code generation for displaying server URL on TV
implementation("com.google.zxing:core:3.5.3") implementation("com.google.zxing:core:3.5.3")
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for // USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
+27 -7
View File
@@ -26,9 +26,15 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- MediaProjection requires a foreground service --> <!-- Foreground service permissions.
FOREGROUND_SERVICE_MEDIA_PROJECTION: required on API 34+ for the
MediaProjection capture path.
FOREGROUND_SERVICE_SPECIAL_USE: required on API 34+ for the root
screenrecord capture path (it doesn't use MediaProjection).
Both are declared because the service may run in either mode. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification --> <!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -60,27 +66,41 @@
<application <application
android:name=".LedGrabApp" android:name=".LedGrabApp"
android:allowBackup="false" android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:banner="@drawable/ic_launcher" android:banner="@drawable/banner_tv"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.LedGrab"> android:theme="@style/Theme.LedGrab">
<!-- TV launcher activity --> <!-- TV launcher activity. Boots through the SplashScreen theme so
the (sometimes multi-second) Chaquopy stdlib unpack doesn't
show as a black screen on first launch. -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:theme="@style/Theme.LedGrab.Splash">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Foreground service for screen capture + Python server --> <!-- Foreground service for screen capture + Python server.
Declares BOTH mediaProjection AND specialUse: only one is
active at a time but Android needs to see the union of
possible types up-front so it doesn't kill the service when
we promote it with a different type at runtime.
FOREGROUND_SERVICE_TYPE_SPECIAL_USE on API 34+ requires the
PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale below. -->
<service <service
android:name=".CaptureService" android:name=".CaptureService"
android:foregroundServiceType="mediaProjection" android:foregroundServiceType="mediaProjection|specialUse"
android:exported="false" /> android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Root-mode screen capture for ambient LED sync. Uses /system/bin/screenrecord on rooted devices to avoid MediaProjection's persistent capture indicator overlay, which is required for the always-on ambient-lighting use case." />
</service>
<!-- Autostart — fires on device boot (and package replace). <!-- Autostart — fires on device boot (and package replace).
On rooted devices, launches CaptureService directly so capture On rooted devices, launches CaptureService directly so capture
@@ -0,0 +1,98 @@
package com.ledgrab.android
import android.content.Context
import android.util.Log
import java.security.SecureRandom
/**
* Persists the per-install API key for the embedded FastAPI server.
*
* The server's auth gate ([ledgrab.api.auth]) requires a Bearer token
* for any non-loopback request when ``auth.api_keys`` is configured.
* Without a key, LAN clients (phone, laptop) get 401 — which is the
* server's secure default but breaks the QR-scan workflow.
*
* This class generates one key per install (random 32-byte → 64-char
* hex), persists it to SharedPreferences, and exposes it to:
* - [PythonBridge] which sets ``LEDGRAB_AUTH__API_KEYS=android:<key>``
* before uvicorn starts.
* - [MainActivity] which embeds the key as a URL fragment
* (``http://ip:port/#k=<key>``) in the QR. Fragments are never sent
* to the server in HTTP requests, so the key doesn't appear in
* access logs.
*/
class ApiKeyManager(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// Once we've materialised a key in this process, cache it so
// subsequent reads don't hit prefs and don't risk re-checking
// length under contention.
@Volatile private var cached: String? = null
private val lock = Any()
/** Persistent random API key, generated lazily on first access. */
val apiKey: String
get() = getOrCreateKey()
/** Force a new key. Useful if a user thinks the QR was photographed. */
fun rotate(): String {
synchronized(lock) {
val next = generateKey()
// apply() is fine for rotation — by definition the user
// initiated this and will see the new QR; the worst case
// on crash is they need to re-rotate.
prefs.edit().putString(KEY_API_KEY, next).apply()
cached = next
Log.i(TAG, "Rotated API key")
return next
}
}
private fun getOrCreateKey(): String {
cached?.let { return it }
synchronized(lock) {
// Double-checked under the lock.
cached?.let { return it }
val existing = prefs.getString(KEY_API_KEY, null)
if (existing != null && existing.length >= MIN_KEY_LENGTH) {
cached = existing
return existing
}
val generated = generateKey()
// commit() (synchronous disk write) on the FIRST write so
// the key is durable before MainActivity encodes it into a
// QR. If the process is killed between QR display and the
// async write landing, the user's phone would scan a key
// the server never learned about. Subsequent rotates can
// safely use apply().
prefs.edit().putString(KEY_API_KEY, generated).commit()
cached = generated
Log.i(TAG, "Generated new API key (length=${generated.length})")
return generated
}
}
private fun generateKey(): String {
val bytes = ByteArray(KEY_BYTES)
SecureRandom().nextBytes(bytes)
// Hex-encode so the key survives copy/paste, URL fragments, env
// vars, and YAML config without escaping concerns. Mask to 0xff
// first — Kotlin's Byte is signed, and `%02x` on a negative
// Byte sign-extends to an 8-char hex string ("ffffffff" instead
// of "ff"), which would produce an invalid key.
return bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
companion object {
private const val TAG = "ApiKeyManager"
private const val PREFS_NAME = "ledgrab_auth"
private const val KEY_API_KEY = "api_key"
private const val KEY_BYTES = 32
private const val MIN_KEY_LENGTH = 32
/** Label used as the LEDGRAB_AUTH__API_KEYS map key. */
const val LABEL = "android"
}
}
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.projection.MediaProjection import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.os.Build import android.os.Build
@@ -15,6 +16,7 @@ import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import android.view.WindowManager import android.view.WindowManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -26,7 +28,13 @@ import kotlinx.coroutines.launch
/** /**
* Foreground service that runs the Python LedGrab server and captures * Foreground service that runs the Python LedGrab server and captures
* the screen via MediaProjection. * the screen via MediaProjection or root screenrecord.
*
* On Android 14+ the foreground-service "type" must match the work
* being done. We promote the service with the correct type (mediaProjection
* for the consent path, specialUse for the root path) instead of
* declaring a single fixed type in the manifest — the manifest now
* declares the *union* so promotion at runtime is permitted.
*/ */
class CaptureService : Service() { class CaptureService : Service() {
@@ -92,15 +100,33 @@ class CaptureService : Service() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// CRITICAL: startForeground must be called IMMEDIATELY — val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
// before any other work, especially before getMediaProjection().
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown" // CRITICAL: startForeground must be called IMMEDIATELY — before
// any other work, especially before getMediaProjection(). The
// service type must match the work; pass it explicitly via
// ServiceCompat so we stay compatible back to API 24.
val localIp = NetworkUtils.getLocalIpAddress(this) ?: ""
val url = "http://$localIp:$SERVER_PORT" val url = "http://$localIp:$SERVER_PORT"
try { try {
startForeground(NOTIFICATION_ID, buildNotification(url)) val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
if (useRoot) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
} else {
0
}
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
buildNotification(url),
type,
)
} catch (e: Exception) { } catch (e: Exception) {
// Most common cause: missing foregroundServiceType permission // Most common cause: missing foregroundServiceType permission
// or denied POST_NOTIFICATIONS on API 34+. // or denied POST_NOTIFICATIONS on API 33+.
Log.e(TAG, "startForeground failed — service cannot run", e) Log.e(TAG, "startForeground failed — service cannot run", e)
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
@@ -109,8 +135,6 @@ class CaptureService : Service() {
// otherwise `isRunning=true` sticks forever when startForeground throws. // otherwise `isRunning=true` sticks forever when startForeground throws.
isRunning = true isRunning = true
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
if (intent == null && !useRoot) { if (intent == null && !useRoot) {
// MediaProjection mode can't recover from a redelivery — // MediaProjection mode can't recover from a redelivery —
// the consent token in the original intent is single-use. // the consent token in the original intent is single-use.
@@ -140,10 +164,13 @@ class CaptureService : Service() {
return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
} }
private fun apiKey(): String? =
(application as? LedGrabApp)?.apiKeyManager?.apiKey
private fun startRootCapture(url: String) { private fun startRootCapture(url: String) {
val newBridge = PythonBridge(this).also { b -> val newBridge = PythonBridge(this).also { b ->
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT) b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT) b.startServer(SERVER_PORT, apiKey())
} }
bridge = newBridge bridge = newBridge
@@ -167,12 +194,21 @@ class CaptureService : Service() {
* Replace the active root pipeline with a fresh instance, reusing * Replace the active root pipeline with a fresh instance, reusing
* the existing Python bridge (no server restart). Returns true if * the existing Python bridge (no server restart). Returns true if
* the new pipeline launched, false otherwise. * the new pipeline launched, false otherwise.
*
* Synchronized so a concurrent onDestroy() either (a) sees the old
* instance and stops it then null-out, or (b) sees the new instance
* and stops it. There is no window where a fresh instance can be
* orphaned with no one holding a reference to it.
*/ */
@Synchronized
private fun restartRootPipeline(): Boolean { private fun restartRootPipeline(): Boolean {
val currentBridge = bridge ?: return false val currentBridge = bridge ?: return false
val old = rootCapture // Tear down the old instance first so we don't run two
rootCapture = null // screenrecord processes simultaneously fighting for the GPU.
runCatching { old?.stop() } rootCapture?.let { old ->
rootCapture = null
runCatching { old.stop() }
}
val next = RootScreenrecord( val next = RootScreenrecord(
bridge = currentBridge, bridge = currentBridge,
@@ -180,11 +216,21 @@ class CaptureService : Service() {
height = CAPTURE_HEIGHT, height = CAPTURE_HEIGHT,
fps = CAPTURE_FPS, fps = CAPTURE_FPS,
) )
// Publish BEFORE start() — if onDestroy fires after this
// assignment but before start() completes, the field is non-null
// and onDestroy will stop() it properly. start() is idempotent
// enough (running=true, then resource construction) that being
// raced by stop() at most produces a brief partial-init that
// the next stop() call cleans up.
rootCapture = next
if (!next.start()) { if (!next.start()) {
Log.e(TAG, "Root capture failed to restart") Log.e(TAG, "Root capture failed to restart")
// start() already called stop() on itself on the failure
// path — but null out the field so the watchdog/onDestroy
// don't try to stop it again.
rootCapture = null
return false return false
} }
rootCapture = next
return true return true
} }
@@ -212,7 +258,7 @@ class CaptureService : Service() {
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " + "Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
"restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS", "restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS",
) )
if (restartAttempts > WATCHDOG_MAX_RESTARTS) { if (restartAttempts >= WATCHDOG_MAX_RESTARTS) {
Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts") Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts")
stopSelf() stopSelf()
return@launch return@launch
@@ -263,7 +309,6 @@ class CaptureService : Service() {
val bounds = windowMetrics.bounds val bounds = windowMetrics.bounds
widthPixels = bounds.width() widthPixels = bounds.width()
heightPixels = bounds.height() heightPixels = bounds.height()
// densityDpi is still needed for VirtualDisplay; read from resources.
densityDpi = resources.displayMetrics.densityDpi densityDpi = resources.displayMetrics.densityDpi
} }
} else { } else {
@@ -276,7 +321,7 @@ class CaptureService : Service() {
val newBridge = PythonBridge(this).also { b -> val newBridge = PythonBridge(this).also { b ->
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT) b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT) b.startServer(SERVER_PORT, apiKey())
} }
bridge = newBridge bridge = newBridge
@@ -323,10 +368,10 @@ class CaptureService : Service() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"LedGrab Screen Capture", getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
).apply { ).apply {
description = "Shows while LedGrab is capturing the screen" description = getString(R.string.notification_channel_description)
} }
val manager = getSystemService(NotificationManager::class.java) val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
@@ -343,9 +388,14 @@ class CaptureService : Service() {
PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_IMMUTABLE,
) )
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("LedGrab Running") .setContentTitle(getString(R.string.notification_title))
.setContentText("Web UI: $url") .setContentText(getString(R.string.notification_text, url))
.setSmallIcon(R.drawable.ic_launcher) // ic_notification is a monochrome 24dp vector — status-bar
// icons must be white-on-transparent or they render as a
// gray blob on Android 5+.
.setSmallIcon(R.drawable.ic_notification)
.setColor(0xFF64FFDA.toInt())
.setColorized(true)
.setContentIntent(tapIntent) .setContentIntent(tapIntent)
.setOngoing(true) .setOngoing(true)
.build() .build()
@@ -26,9 +26,13 @@ class LedGrabApp : Application() {
var initError: Throwable? = null var initError: Throwable? = null
private set private set
/** Lazily-initialized API-key manager (see [ApiKeyManager]). */
val apiKeyManager: ApiKeyManager by lazy { ApiKeyManager(this) }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
installCrashLogger() installCrashLogger()
pruneOldCrashLogs()
try { try {
if (!Python.isStarted()) { if (!Python.isStarted()) {
Python.start(AndroidPlatform(this)) Python.start(AndroidPlatform(this))
@@ -47,6 +51,15 @@ class LedGrabApp : Application() {
// Bind application context for the BLE bridge so Python can // Bind application context for the BLE bridge so Python can
// scan and connect to BLE LED controllers. // scan and connect to BLE LED controllers.
BleBridge.init(this) BleBridge.init(this)
// Pre-warm the API key on a background thread. First-launch
// generation does a SharedPreferences.commit() (synchronous
// disk write — 10-50 ms on slow TV-box flash), which would
// hit the Main thread otherwise when MainActivity / CaptureService
// reads it. Doing it here makes subsequent reads memory-only.
Thread({
runCatching { apiKeyManager.apiKey }
}, "ledgrab-apikey-warmup").apply { isDaemon = true }.start()
} }
/** /**
@@ -77,7 +90,24 @@ class LedGrabApp : Application() {
} }
} }
/**
* Keep only the most recent [MAX_CRASH_LOGS] crash files so a
* long-lived install doesn't slowly fill its private storage with
* historical traces. Cheap on every launch — listFiles is O(n)
* but n is tiny by construction.
*/
private fun pruneOldCrashLogs() {
val logs = filesDir.listFiles { f ->
f.isFile && f.name.startsWith("crash-") && f.name.endsWith(".log")
} ?: return
if (logs.size <= MAX_CRASH_LOGS) return
logs.sortedByDescending { it.lastModified() }
.drop(MAX_CRASH_LOGS)
.forEach { runCatching { it.delete() } }
}
companion object { companion object {
private const val TAG = "LedGrabApp" private const val TAG = "LedGrabApp"
private const val MAX_CRASH_LOGS = 10
} }
} }
@@ -1,7 +1,10 @@
package com.ledgrab.android package com.ledgrab.android
import android.Manifest import android.Manifest
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
@@ -13,12 +16,16 @@ import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.ViewStub
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Button import android.widget.Button
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
import android.app.Activity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -46,25 +53,47 @@ class MainActivity : Activity() {
private const val SERVER_PORT = 8080 private const val SERVER_PORT = 8080
private const val REQUEST_MEDIA_PROJECTION = 1001 private const val REQUEST_MEDIA_PROJECTION = 1001
private const val REQUEST_POST_NOTIFICATIONS = 1002 private const val REQUEST_POST_NOTIFICATIONS = 1002
private const val QR_SIZE_PX = 560
} }
// Stopped-state views (always inflated).
private lateinit var stoppedPanel: View private lateinit var stoppedPanel: View
private lateinit var runningPanel: View
private lateinit var statusText: TextView private lateinit var statusText: TextView
private lateinit var urlText: TextView
private lateinit var qrImage: ImageView
private lateinit var toggleButton: Button private lateinit var toggleButton: Button
private lateinit var stopButtonRunning: Button
private lateinit var versionText: TextView private lateinit var versionText: TextView
private lateinit var autostartCheck: CheckBox private lateinit var autostartCheck: CheckBox
private lateinit var autostartPrefs: AutostartPrefs private lateinit var autostartPrefs: AutostartPrefs
// Running-state views (lazy-inflated via ViewStub).
private lateinit var runningPanelStub: ViewStub
private var runningPanel: View? = null
private var urlText: TextView? = null
private var qrImage: ImageView? = null
private var stopButtonRunning: Button? = null
private var statusDot: View? = null
private var statusDotAnimator: ObjectAnimator? = null
// Cache of the most recently rendered QR (and the URL it encodes).
// updateUI() runs on every onResume (HDMI-CEC wakes, app switches,
// overlay dismissal, etc.). Rebuilding the 560×560 bitmap each time
// is wasteful — usually the IP and key are unchanged. Cache and
// short-circuit when the URL matches.
private var cachedQrUrl: String? = null
private var cachedQrBitmap: Bitmap? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Install the splash screen BEFORE super.onCreate so the system
// keeps it on screen until our first frame is ready. This hides
// the Chaquopy stdlib unpack delay on cold first launch.
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Surface fatal Python init errors instead of crashing. // Surface fatal Python init errors instead of crashing.
val initError = (application as? LedGrabApp)?.initError val initError = (application as? LedGrabApp)?.initError
if (initError != null) { if (initError != null) {
// Tell the splash screen to dismiss immediately — we're
// about to render an error screen, not the main UI.
splashScreen.setKeepOnScreenCondition { false }
showFatalErrorScreen(initError) showFatalErrorScreen(initError)
return return
} }
@@ -72,39 +101,62 @@ class MainActivity : Activity() {
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
stoppedPanel = findViewById(R.id.stopped_panel) stoppedPanel = findViewById(R.id.stopped_panel)
runningPanel = findViewById(R.id.running_panel) runningPanelStub = findViewById(R.id.running_panel_stub)
statusText = findViewById(R.id.status_text) statusText = findViewById(R.id.status_text)
urlText = findViewById(R.id.url_text)
qrImage = findViewById(R.id.qr_image)
toggleButton = findViewById(R.id.toggle_button) toggleButton = findViewById(R.id.toggle_button)
stopButtonRunning = findViewById(R.id.stop_button_running)
versionText = findViewById(R.id.version_text) versionText = findViewById(R.id.version_text)
autostartCheck = findViewById(R.id.autostart_check) autostartCheck = findViewById(R.id.autostart_check)
val versionName = packageManager val versionName = packageManager.getPackageInfo(packageName, 0).versionName
.getPackageInfo(packageName, 0).versionName
versionText.text = getString(R.string.version_prefix, versionName ?: "?") versionText.text = getString(R.string.version_prefix, versionName ?: "?")
autostartPrefs = AutostartPrefs(this) autostartPrefs = AutostartPrefs(this)
autostartCheck.isChecked = autostartPrefs.isEnabled // Autostart only takes effect on rooted devices. Hide the
// Autostart only takes effect on rooted devices — grey it out // checkbox entirely on unrooted hardware instead of showing a
// on unrooted hardware so users don't expect magic. Cheap probe // disabled-but-visible control, which reads as broken UI from
// (file-existence only, no process spawn). // across the room.
if (!Root.looksRooted()) { if (Root.looksRooted()) {
autostartCheck.isEnabled = false autostartCheck.visibility = View.VISIBLE
autostartCheck.text = getString(R.string.autostart_unavailable) autostartCheck.isChecked = autostartPrefs.isEnabled
} autostartCheck.setOnCheckedChangeListener { _, isChecked ->
autostartCheck.setOnCheckedChangeListener { _, isChecked -> autostartPrefs.isEnabled = isChecked
autostartPrefs.isEnabled = isChecked if (isChecked) ensureIgnoringBatteryOptimizations()
if (isChecked) ensureIgnoringBatteryOptimizations() }
} else {
autostartCheck.visibility = View.GONE
} }
toggleButton.setOnClickListener { startCapture() } toggleButton.setOnClickListener { startCapture() }
stopButtonRunning.setOnClickListener { stopCaptureService() }
updateUI() updateUI()
} }
override fun onDestroy() {
stopStatusDotPulse()
uiScope.cancel()
super.onDestroy()
}
override fun onStop() {
// ObjectAnimator retains a hard reference to the dot View. On
// backgrounded TV apps onDestroy may never fire, so cancel here
// to avoid leaking the entire view hierarchy through an
// INFINITE-repeat animator.
stopStatusDotPulse()
super.onStop()
}
override fun onResume() {
super.onResume()
// Restart the pulse if we returned to the foreground while the
// service is still running. The running panel's view may have
// been recreated; ensureRunningPanelInflated already keys off
// the field reference.
if (CaptureService.isRunning && ::stoppedPanel.isInitialized) {
updateUI()
}
}
/** /**
* Decide whether to go through the MediaProjection consent flow or * Decide whether to go through the MediaProjection consent flow or
* jump straight into root capture. Root check is fast but may block * jump straight into root capture. Root check is fast but may block
@@ -112,20 +164,19 @@ class MainActivity : Activity() {
* on the UI thread is acceptable because we're responding to a * on the UI thread is acceptable because we're responding to a
* button press and we want to block until the user answers. * button press and we want to block until the user answers.
*/ */
override fun onDestroy() {
uiScope.cancel()
super.onDestroy()
}
private fun startCapture() { private fun startCapture() {
// `su -c id` can block for seconds while Magisk shows its grant // `su -c id` can block for seconds while Magisk shows its grant
// dialog; running it on the Main thread caused ANRs. // dialog; running it on the Main thread caused ANRs. Render an
// explicit "starting" state so the button doesn't look frozen.
val originalText = toggleButton.text
toggleButton.isEnabled = false toggleButton.isEnabled = false
statusText.text = "Checking root access…" toggleButton.text = getString(R.string.btn_starting)
statusText.text = getString(R.string.status_checking_root)
uiScope.launch(Dispatchers.IO) { uiScope.launch(Dispatchers.IO) {
val rooted = Root.requestGrant() val rooted = Root.requestGrant()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
toggleButton.isEnabled = true toggleButton.isEnabled = true
toggleButton.text = originalText
statusText.text = "" statusText.text = ""
if (rooted) { if (rooted) {
Log.i(TAG, "Root available — skipping MediaProjection consent") Log.i(TAG, "Root available — skipping MediaProjection consent")
@@ -156,7 +207,7 @@ class MainActivity : Activity() {
if (resultCode == RESULT_OK && data != null) { if (resultCode == RESULT_OK && data != null) {
startCaptureService(resultCode, data) startCaptureService(resultCode, data)
} else { } else {
statusText.text = "Permission denied — screen capture requires authorization" statusText.text = getString(R.string.status_permission_denied)
Log.w(TAG, "MediaProjection permission denied") Log.w(TAG, "MediaProjection permission denied")
} }
} }
@@ -174,42 +225,130 @@ class MainActivity : Activity() {
updateUI() updateUI()
} }
private fun updateUI() { private fun ensureRunningPanelInflated(): View {
if (CaptureService.isRunning) { runningPanel?.let { return it }
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown" val view = runningPanelStub.inflate()
val url = "http://$localIp:$SERVER_PORT" urlText = view.findViewById(R.id.url_text)
qrImage = view.findViewById(R.id.qr_image)
stopButtonRunning = view.findViewById(R.id.stop_button_running)
statusDot = view.findViewById(R.id.status_dot)
stopButtonRunning?.setOnClickListener { stopCaptureService() }
runningPanel = view
return view
}
urlText.text = url private fun updateUI() {
qrImage.setImageBitmap(null) // Fatal-init-error path took over setContentView and the
// Build the bitmap pixels off the Main thread — encode + 313k // lateinit view fields are unassigned. Guard so any future
// setPixel calls were noticeably janky on slow TV boxes. // caller (Resume, broadcast receiver, etc.) doesn't NPE.
uiScope.launch(Dispatchers.Default) { if (!::stoppedPanel.isInitialized) return
val bitmap = generateQrCode(url) if (CaptureService.isRunning) {
withContext(Dispatchers.Main) { val running = ensureRunningPanelInflated()
if (CaptureService.isRunning && urlText.text == url) { val localIp = NetworkUtils.getLocalIpAddress(this)
qrImage.setImageBitmap(bitmap) if (localIp == null) {
// No network — show the no-network state inside the
// stopped panel and keep capture stopped. The service
// is alive (capture works on loopback) but the URL/QR
// are useless without a routable address.
statusText.text = getString(R.string.status_no_network)
stoppedPanel.visibility = View.VISIBLE
versionText.visibility = View.VISIBLE
running.visibility = View.GONE
toggleButton.requestFocus()
return
}
val displayUrl = "http://$localIp:$SERVER_PORT"
val qrUrl = qrUrlFor(displayUrl)
urlText?.text = displayUrl
val cachedForUrl = cachedQrBitmap?.takeIf { cachedQrUrl == qrUrl }
if (cachedForUrl != null) {
qrImage?.setImageBitmap(cachedForUrl)
} else {
qrImage?.setImageBitmap(null)
// Build the bitmap pixels off the Main thread — encode + 313k
// setPixel calls were noticeably janky on slow TV boxes.
uiScope.launch(Dispatchers.Default) {
val bitmap = generateQrCode(qrUrl)
withContext(Dispatchers.Main) {
if (CaptureService.isRunning && urlText?.text == displayUrl) {
cachedQrUrl = qrUrl
cachedQrBitmap = bitmap
qrImage?.setImageBitmap(bitmap)
}
} }
} }
} }
stoppedPanel.visibility = View.GONE stoppedPanel.visibility = View.GONE
versionText.visibility = View.GONE versionText.visibility = View.GONE
runningPanel.visibility = View.VISIBLE running.visibility = View.VISIBLE
stopButtonRunning.requestFocus() stopButtonRunning?.requestFocus()
startStatusDotPulse()
} else { } else {
urlText.text = "" stopStatusDotPulse()
qrImage.setImageBitmap(null) urlText?.text = ""
qrImage?.setImageBitmap(null)
// Drop the cached bitmap so a Start → IP change → Start
// sequence rebuilds the QR for the new address.
cachedQrUrl = null
cachedQrBitmap = null
runningPanel.visibility = View.GONE runningPanel?.visibility = View.GONE
stoppedPanel.visibility = View.VISIBLE stoppedPanel.visibility = View.VISIBLE
versionText.visibility = View.VISIBLE versionText.visibility = View.VISIBLE
toggleButton.requestFocus() toggleButton.requestFocus()
} }
} }
/**
* Build the URL we encode into the QR. Embeds the API key as a
* URL fragment (``#k=<token>``) so:
* - The token never appears in HTTP requests (fragments aren't
* sent over the wire) — no access-log leak.
* - The frontend can read [location.hash] on first visit and
* persist the key to localStorage (see static/js/app.ts).
* - The visible URL chip stays short and human-readable.
*
* The chip text in [updateUI] intentionally uses the *base* URL
* (without the fragment) so a human reading the URL out loud
* doesn't have to dictate 64 hex chars; only the QR carries the
* key. Do not collapse these into a single string — that would
* leak the key onto the screen.
*/
private fun qrUrlFor(base: String): String {
val key = (application as? LedGrabApp)?.apiKeyManager?.apiKey
return if (key.isNullOrBlank()) base else "$base/#k=$key"
}
private fun startStatusDotPulse() {
val dot = statusDot ?: return
if (statusDotAnimator?.isStarted == true) return
val animator = ObjectAnimator.ofFloat(dot, "alpha", 1f, 0.35f).apply {
duration = 900
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.REVERSE
interpolator = AccelerateDecelerateInterpolator()
}
animator.start()
statusDotAnimator = animator
}
private fun stopStatusDotPulse() {
statusDotAnimator?.cancel()
statusDotAnimator = null
statusDot?.alpha = 1f
}
private fun generateQrCode(text: String): Bitmap { private fun generateQrCode(text: String): Bitmap {
val size = 560 val size = QR_SIZE_PX
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size) val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
// ALPHA_8 = 1 byte/px instead of 2 (RGB_565) or 4 (ARGB_8888).
// The ImageView gets tinted white via the matrix — for a pure
// black-and-white QR that's all we need and it halves heap usage
// compared to the previous RGB_565 path.
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val pixels = IntArray(size * size) val pixels = IntArray(size * size)
for (y in 0 until size) { for (y in 0 until size) {
val rowOffset = y * size val rowOffset = y * size
@@ -218,34 +357,54 @@ class MainActivity : Activity() {
if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
} }
} }
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
bitmap.setPixels(pixels, 0, size, 0, 0, size, size) bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
return bitmap return bitmap
} }
/** /**
* Minimal failure UI shown when Python.start() (Chaquopy) blew up. * Minimal failure UI shown when Python.start() (Chaquopy) blew up.
* Rendered programmatically so we don't depend on the regular layout * Stack trace is hidden behind a "Show details" toggle so we don't
* (which itself may reference resources affected by the failure). * print user-path data on shared TV screens by default.
*/ */
private fun showFatalErrorScreen(error: Throwable) { private fun showFatalErrorScreen(error: Throwable) {
Log.e(TAG, "Fatal init error — showing error screen", error) Log.e(TAG, "Fatal init error — showing error screen", error)
val stackText = android.util.Log.getStackTraceString(error) val stackText = Log.getStackTraceString(error)
val container = android.widget.LinearLayout(this).apply { val container = LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
setPadding(48, 48, 48, 48) setPadding(48, 48, 48, 48)
} }
val title = TextView(this).apply { val title = TextView(this).apply {
text = "LedGrab failed to start" text = getString(R.string.fatal_title)
textSize = 22f textSize = 22f
} }
val description = TextView(this).apply {
text = getString(R.string.fatal_body_prefix)
textSize = 14f
setPadding(0, 24, 0, 12)
}
val body = TextView(this).apply { val body = TextView(this).apply {
text = "Python runtime initialization failed:\n\n$stackText" text = stackText
textSize = 12f textSize = 12f
setTextIsSelectable(true) setTextIsSelectable(true)
visibility = View.GONE
}
val scroll = ScrollView(this).apply {
addView(body)
visibility = View.GONE
}
val toggleBtn = Button(this).apply {
text = getString(R.string.fatal_show_details)
setOnClickListener {
val showing = scroll.visibility == View.VISIBLE
scroll.visibility = if (showing) View.GONE else View.VISIBLE
body.visibility = scroll.visibility
text = getString(
if (showing) R.string.fatal_show_details else R.string.fatal_hide_details,
)
}
} }
val copyBtn = Button(this).apply { val copyBtn = Button(this).apply {
text = "Copy log" text = getString(R.string.fatal_copy_log)
setOnClickListener { setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE) val cm = getSystemService(CLIPBOARD_SERVICE)
as android.content.ClipboardManager as android.content.ClipboardManager
@@ -254,19 +413,20 @@ class MainActivity : Activity() {
) )
} }
} }
val scroll = android.widget.ScrollView(this).apply { addView(body) }
container.addView(title) container.addView(title)
container.addView(description)
container.addView(toggleBtn)
container.addView(copyBtn) container.addView(copyBtn)
container.addView(scroll) container.addView(scroll)
setContentView(container) setContentView(container)
} }
/** /**
* Prompt the user to exempt LedGrab from battery optimization. On * Prompt the user to exempt LedGrab from battery optimization.
* TV boxes this is usually a no-op, but on phones Doze/App Standby * Strictly a phone-side concern (Doze/App Standby kill the FG
* will kill the foreground service after a few hours of sleep. We * service after hours of sleep); essentially a no-op on TV boxes.
* only ask when autostart is turned on. No-op on pre-M or when * Only asked when autostart is turned on, which is itself only
* already exempt. * available on rooted devices.
* *
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default * Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
* — LedGrab's ambient-capture use case falls under the documented * — LedGrab's ambient-capture use case falls under the documented
@@ -3,6 +3,8 @@ package com.ledgrab.android
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.LinkProperties import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import java.net.Inet4Address import java.net.Inet4Address
/** /**
@@ -11,18 +13,58 @@ import java.net.Inet4Address
object NetworkUtils { object NetworkUtils {
/** /**
* Return the device's local IPv4 address on the active network, * Return the device's local IPv4 address, preferring (in order):
* or `null` if unavailable. * - Ethernet (wired TV-box link)
* - Wi-Fi
* - any other transport
* - whatever the active network reports
*
* Returns ``null`` only when no IPv4 link addresses exist at all.
*
* Why not just ``activeNetwork``: on TV boxes with both Ethernet
* AND Wi-Fi connected, Android's active-network heuristic can
* pick Wi-Fi while the user's phone is on the Ethernet subnet —
* leading to a URL/QR that the phone can't reach.
*/ */
fun getLocalIpAddress(context: Context): String? { fun getLocalIpAddress(context: Context): String? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return null // TODO(AP-mode): On TV boxes acting as a Wi-Fi tether/hotspot,
val props: LinkProperties = cm.getLinkProperties(network) ?: return null // TRANSPORT_WIFI here will resolve to the AP-side interface
// (typically 192.168.43.x) which clients on the user's actual
// home LAN can't reach. Detecting AP mode requires the @SystemApi
// WifiManager.getWifiApState reflection trick — defer until a
// user reports needing it.
val networks = cm.allNetworks
if (networks.isEmpty()) return ipv4Of(cm, cm.activeNetwork ?: return null)
val ranked = networks
.mapNotNull { n ->
val caps = cm.getNetworkCapabilities(n) ?: return@mapNotNull null
val rank = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 0
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 1
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 3
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4
else -> 2
}
Triple(rank, n, caps)
}
.sortedBy { it.first }
for ((_, network, _) in ranked) {
val ip = ipv4Of(cm, network)
if (ip != null) return ip
}
return null
}
private fun ipv4Of(cm: ConnectivityManager, network: Network): String? {
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
return props.linkAddresses return props.linkAddresses
.asSequence()
.map { it.address } .map { it.address }
.filterIsInstance<Inet4Address>() .filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress } .firstOrNull { !it.isLoopbackAddress && !it.isLinkLocalAddress }
?.hostAddress ?.hostAddress
} }
} }
@@ -9,7 +9,8 @@ import com.chaquo.python.Python
* Bridge between Kotlin and the LedGrab Python server. * Bridge between Kotlin and the LedGrab Python server.
* *
* All Python calls go through Chaquopy's `Python.getInstance()`. * All Python calls go through Chaquopy's `Python.getInstance()`.
* Frame data crosses the JNI boundary as a `ByteArray`. * Frame data crosses the JNI boundary as a `ByteArray` (reused across
* frames — see ScreenCapture / RootScreenrecord for buffer pools).
*/ */
class PythonBridge(private val context: Context) { class PythonBridge(private val context: Context) {
@@ -55,9 +56,14 @@ class PythonBridge(private val context: Context) {
/** /**
* Start the LedGrab FastAPI server on a background thread. * Start the LedGrab FastAPI server on a background thread.
* *
* This blocks until [stopServer] is called, so it runs in its own thread. * Passes [apiKey] through so the Python server's auth gate accepts
* Bearer-authenticated LAN requests; null disables auth (loopback
* only — see [ApiKeyManager]).
*
* This blocks until [stopServer] is called, so it runs in its own
* thread.
*/ */
fun startServer(port: Int = 8080) { fun startServer(port: Int = 8080, apiKey: String? = null) {
if (running) { if (running) {
Log.w(TAG, "Server already running") Log.w(TAG, "Server already running")
return return
@@ -71,7 +77,11 @@ class PythonBridge(private val context: Context) {
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)") Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
val py = Python.getInstance() val py = Python.getInstance()
val entry = py.getModule("ledgrab.android_entry") val entry = py.getModule("ledgrab.android_entry")
entry.callAttr("start_server", dataDir, port) if (apiKey != null) {
entry.callAttr("start_server", dataDir, port, apiKey)
} else {
entry.callAttr("start_server", dataDir, port)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Python server error", e) Log.e(TAG, "Python server error", e)
} finally { } finally {
@@ -106,7 +116,8 @@ class PythonBridge(private val context: Context) {
* *
* Called from [ScreenCapture] on the capture thread. The byte array * Called from [ScreenCapture] on the capture thread. The byte array
* crosses the JNI boundary — keep frames small (downscale to 480p * crosses the JNI boundary — keep frames small (downscale to 480p
* before calling). * before calling) and pass reusable buffers (see ScreenCapture's
* buffer pool).
*/ */
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) { fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
if (!running) return if (!running) return
@@ -100,14 +100,41 @@ object Root {
} }
/** /**
* Run an `su -c <cmd>` command. Returns true on exit-zero. Failure * Run a command as root.
* invalidates the cached grant so the next [requestGrant] re-checks *
* (covers cases like Magisk grant being revoked mid-session). * The [argv] array is passed to `su -c` as **a single string** built by
* shell-quoting each element. This prevents the shell-injection class
* of bug where a caller passes user-influenced data containing
* spaces, semicolons, or backticks: each element is treated as a
* single shell token regardless of contents.
*
* Returns true on exit-zero. Failure invalidates the cached grant so
* the next [requestGrant] re-checks (covers cases like Magisk grant
* being revoked mid-session).
*/ */
@JvmStatic @JvmStatic
fun runAsRoot(cmd: String, timeoutSeconds: Long = 5): Boolean { @JvmOverloads
fun runAsRoot(argv: Array<String>, timeoutSeconds: Long = 5): Boolean {
require(argv.isNotEmpty()) { "runAsRoot called with empty argv" }
val quoted = argv.joinToString(" ") { shellQuote(it) }
return execSu(quoted, timeoutSeconds)
}
/**
* Convenience for fully-trusted constant commands (e.g.
* ``runAsRoot("pkill -TERM screenrecord")``). DO NOT pass anything
* derived from user input through this overload — use [runAsRoot]
* with an argv array instead so each token is quoted individually.
*/
@JvmStatic
@JvmOverloads
fun runAsRoot(command: String, timeoutSeconds: Long = 5): Boolean {
return execSu(command, timeoutSeconds)
}
private fun execSu(shellLine: String, timeoutSeconds: Long): Boolean {
return try { return try {
val process = ProcessBuilder("su", "-c", cmd) val process = ProcessBuilder("su", "-c", shellLine)
.redirectErrorStream(true) .redirectErrorStream(true)
.start() .start()
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS) val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
@@ -122,12 +149,34 @@ object Root {
true true
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}") Log.w(TAG, "runAsRoot('$shellLine') failed: ${e.message}")
cachedGranted = null cachedGranted = null
false false
} }
} }
/**
* POSIX-shell-style single-quote escape. Wraps in single quotes and
* escapes embedded single quotes as ``'\''`` so shell metacharacters
* inside [s] are inert.
*/
private fun shellQuote(s: String): String {
if (s.isEmpty()) return "''"
// Optimisation: if the string contains only safe characters,
// skip the quoting overhead. The set is intentionally narrow —
// notably `=` is excluded because an unquoted "FOO=bar" at the
// start of a command would be parsed as a shell variable
// assignment, not a literal arg. Quoting it forces literal use.
if (s.all { it.isLetterOrDigit() || it in "_-./" }) return s
val sb = StringBuilder(s.length + 2)
sb.append('\'')
for (ch in s) {
if (ch == '\'') sb.append("'\\''") else sb.append(ch)
}
sb.append('\'')
return sb.toString()
}
/** Forget the cached grant result — useful if Magisk permission was revoked. */ /** Forget the cached grant result — useful if Magisk permission was revoked. */
@JvmStatic @JvmStatic
fun invalidateCache() { fun invalidateCache() {
@@ -38,8 +38,15 @@ class RootScreenrecord(
private const val TAG = "RootScreenrecord" private const val TAG = "RootScreenrecord"
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
private const val INPUT_CHUNK = 64 * 1024 private const val INPUT_CHUNK = 64 * 1024
// How long to back off when MediaCodec has no input buffer free.
// 50 ms keeps the input pump from busy-spinning if the decoder
// is stalled (codec init, severe stall, etc.).
private const val NO_BUFFER_BACKOFF_MS = 5L
} }
// Instance is single-use: stop() permanently disposes it. Callers
// wanting to restart the pipeline must construct a new instance —
// see CaptureService.restartRootPipeline().
@Volatile private var process: Process? = null @Volatile private var process: Process? = null
private var decoder: MediaCodec? = null private var decoder: MediaCodec? = null
private var imageReader: ImageReader? = null private var imageReader: ImageReader? = null
@@ -48,7 +55,22 @@ class RootScreenrecord(
private var outputThread: Thread? = null private var outputThread: Thread? = null
@Volatile private var running = false @Volatile private var running = false
private val framesDeliveredCounter = AtomicInteger(0) private val framesDeliveredCounter = AtomicInteger(0)
@Volatile private var stopped = false // disposed gates duplicate-stop calls only — not start() after
// stop() (which is unsupported, see note above). Set at the START
// of cleanup so a second concurrent stop() (rare under @Synchronized
// but possible if a future caller drops it) doesn't re-run runCatching
// blocks against already-released resources.
@Volatile private var disposed = false
// Guards process respawn vs. concurrent disposal. The input pump
// can spawn a fresh `su -c screenrecord` after EOF; without this
// lock, stop() could destroy the OLD process between spawn and
// assignment, leaving the new one orphaned (GPU encoder leak).
private val processLock = Any()
// Reusable RGBA buffer for ImageReader callbacks (single-threaded
// reader callback). See ScreenCapture for the rationale: avoids
// ~15 MB/s of per-frame garbage at 30 fps × 480×270×4 B.
private val frameBuffer: ByteArray = ByteArray(width * height * 4)
/** Monotonic count of frames pushed to the Python bridge. */ /** Monotonic count of frames pushed to the Python bridge. */
val framesDelivered: Int get() = framesDeliveredCounter.get() val framesDelivered: Int get() = framesDeliveredCounter.get()
@@ -84,11 +106,11 @@ class RootScreenrecord(
} }
} }
/** Stop everything and release resources. Idempotent. */ /** Stop everything and release resources. Idempotent. Single-use: do not call start() again. */
@Synchronized @Synchronized
fun stop() { fun stop() {
if (stopped) return if (disposed) return
stopped = true disposed = true
// Order matters: signal first so worker loops drop out, then // Order matters: signal first so worker loops drop out, then
// stop the codec on the thread that created it (this one), then // stop the codec on the thread that created it (this one), then
// join workers BEFORE releasing the codec/ImageReader they may // join workers BEFORE releasing the codec/ImageReader they may
@@ -107,7 +129,9 @@ class RootScreenrecord(
// Best-effort: kill the screenrecord child before reaping `su`, // Best-effort: kill the screenrecord child before reaping `su`,
// otherwise screenrecord can outlive su as an orphan and keep // otherwise screenrecord can outlive su as an orphan and keep
// the GPU encoder busy. Fire-and-forget; ignore failures. // the GPU encoder busy. Fire-and-forget; ignore failures.
runCatching { Root.runAsRoot("pkill -TERM screenrecord", timeoutSeconds = 2) } runCatching {
Root.runAsRoot(arrayOf("pkill", "-TERM", "screenrecord"), timeoutSeconds = 2)
}
runCatching { decoder?.release() } runCatching { decoder?.release() }
decoder = null decoder = null
@@ -120,8 +144,13 @@ class RootScreenrecord(
runCatching { readerThread?.join(500) } runCatching { readerThread?.join(500) }
readerThread = null readerThread = null
runCatching { process?.destroy() } // Use the same lock as the respawn path so we don't destroy a
process = null // not-yet-published process or leak one that was spawned after
// we already destroyed the old reference.
synchronized(processLock) {
runCatching { process?.destroy() }
process = null
}
Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})") Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})")
} }
@@ -131,7 +160,7 @@ class RootScreenrecord(
readerThread = thread readerThread = thread
val handler = Handler(thread.looper) val handler = Handler(thread.looper)
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2) val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 3)
reader.setOnImageAvailableListener({ r -> reader.setOnImageAvailableListener({ r ->
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
try { try {
@@ -139,19 +168,17 @@ class RootScreenrecord(
val buffer = plane.buffer val buffer = plane.buffer
val rowStride = plane.rowStride val rowStride = plane.rowStride
val pixelStride = plane.pixelStride val pixelStride = plane.pixelStride
val bytes = if (rowStride == width * pixelStride) { val rowBytes = width * pixelStride
ByteArray(buffer.remaining()).also { buffer.get(it) } val expected = rowBytes * height
if (rowStride == rowBytes && buffer.remaining() >= expected) {
buffer.get(frameBuffer, 0, expected)
} else { } else {
// Strip row padding — common when width isn't a multiple of 16. for (row in 0 until height) {
val rowBytes = width * pixelStride buffer.position(row * rowStride)
ByteArray(width * height * 4).also { out -> buffer.get(frameBuffer, row * rowBytes, rowBytes)
for (row in 0 until height) {
buffer.position(row * rowStride)
buffer.get(out, row * rowBytes, rowBytes)
}
} }
} }
bridge.pushRootFrame(bytes, width, height) bridge.pushRootFrame(frameBuffer, width, height)
framesDeliveredCounter.incrementAndGet() framesDeliveredCounter.incrementAndGet()
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Root frame delivery failed: ${e.message}") Log.w(TAG, "Root frame delivery failed: ${e.message}")
@@ -173,18 +200,26 @@ class RootScreenrecord(
} }
private fun spawnScreenrecord(): Process? { private fun spawnScreenrecord(): Process? {
val cmd = buildString { // argv form — passes safely through Root.runAsRoot's shell-quote
append("screenrecord") // logic so future changes to flag values can't introduce injection.
append(" --output-format=h264") val args = arrayOf(
append(" --size=${width}x$height") "screenrecord",
append(" --bit-rate=$bitRate") "--output-format=h264",
"--size=${width}x$height",
"--bit-rate=$bitRate",
// Time limit 0 isn't supported; the largest accepted is 180s. // Time limit 0 isn't supported; the largest accepted is 180s.
// We restart the process ourselves if it exits early. // We restart the process ourselves if it exits early.
append(" --time-limit=180") "--time-limit=180",
append(" -") "-",
} )
// Inline ProcessBuilder so we have direct access to the child's
// stdout (Root.runAsRoot returns Boolean). We still pass args
// unquoted because the entire array is a fixed program+flags
// with no user-controlled content.
return try { return try {
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd)) ProcessBuilder("su", "-c", args.joinToString(" "))
.redirectErrorStream(false)
.start()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}") Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
null null
@@ -210,21 +245,56 @@ class RootScreenrecord(
// exits cleanly we respawn so capture survives // exits cleanly we respawn so capture survives
// long sessions instead of freezing after ~3min. // long sessions instead of freezing after ~3min.
Log.i(TAG, "screenrecord EOF — respawning") Log.i(TAG, "screenrecord EOF — respawning")
runCatching { process?.destroy() } synchronized(processLock) {
runCatching { process?.destroy() }
process = null
}
val next = spawnScreenrecord() val next = spawnScreenrecord()
if (next == null) { if (next == null) {
// Avoid a tight loop if `su` is suddenly unhappy. // Avoid a tight loop if `su` is suddenly unhappy.
try { Thread.sleep(500) } catch (_: InterruptedException) { break } try { Thread.sleep(500) } catch (_: InterruptedException) { break }
continue@outer continue@outer
} }
process = next // Publish the new process under the lock so a
// concurrent stop() either (a) sees no process,
// tears down later, and lets us assign it for
// the destroy on the NEXT stop call — or (b) sees
// !running and we destroy the new process ourselves.
val accepted = synchronized(processLock) {
if (!running) {
false
} else {
process = next
true
}
}
if (!accepted) {
// running flipped false between EOF and now —
// someone called stop(). Drop the new process
// on the floor; the codec and output thread
// are stop()'s responsibility (it's the only
// writer to `running`, so we don't need to
// tear them down here).
runCatching { next.destroy() }
break@outer
}
stream = next.inputStream stream = next.inputStream
continue@outer continue@outer
} }
var offset = 0 var offset = 0
while (offset < n && running) { while (offset < n && running) {
val index = codec.dequeueInputBuffer(50_000) val index = codec.dequeueInputBuffer(50_000)
if (index < 0) continue if (index < 0) {
// Codec is starved — back off briefly instead
// of spinning. Without this, a stalled codec
// burns 100% of one core hammering dequeue.
try {
Thread.sleep(NO_BUFFER_BACKOFF_MS)
} catch (_: InterruptedException) {
break
}
continue
}
val inputBuffer = codec.getInputBuffer(index) ?: continue val inputBuffer = codec.getInputBuffer(index) ?: continue
inputBuffer.clear() inputBuffer.clear()
val chunk = minOf(n - offset, inputBuffer.capacity()) val chunk = minOf(n - offset, inputBuffer.capacity())
@@ -1,6 +1,5 @@
package com.ledgrab.android package com.ledgrab.android
import android.graphics.Bitmap
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay import android.hardware.display.VirtualDisplay
@@ -8,24 +7,26 @@ import android.media.ImageReader
import android.media.projection.MediaProjection import android.media.projection.MediaProjection
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
import android.os.SystemClock
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import java.nio.ByteBuffer
/** /**
* Captures the Android screen via MediaProjection and feeds frames * Captures the Android screen via MediaProjection and feeds frames
* to [PythonBridge]. * to [PythonBridge].
* *
* Frames are downscaled to [targetWidth] x [targetHeight] before * Frames are downscaled to roughly [targetWidth] x [targetHeight] before
* crossing the JNI boundary to minimize overhead. For LED ambient * crossing the JNI boundary to minimize overhead. The actual capture
* lighting, even 480x270 contains far more data than needed. * dimensions preserve the source screen's aspect ratio (snapped to even
* pixels for codec friendliness) so non-16:9 displays don't get
* squashed.
*/ */
class ScreenCapture( class ScreenCapture(
private val projection: MediaProjection, private val projection: MediaProjection,
private val metrics: DisplayMetrics, private val metrics: DisplayMetrics,
private val bridge: PythonBridge, private val bridge: PythonBridge,
private val targetWidth: Int = 480, targetWidth: Int = 480,
private val targetHeight: Int = 270, targetHeight: Int = 270,
private val targetFps: Int = 30, private val targetFps: Int = 30,
private val onProjectionStopped: () -> Unit = {}, private val onProjectionStopped: () -> Unit = {},
) { ) {
@@ -34,13 +35,51 @@ class ScreenCapture(
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture" private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
} }
// Snap to the source aspect ratio so we don't squash 21:9 / portrait
// / rotated screens. Width is the budget; height follows.
private val captureWidth: Int
private val captureHeight: Int
init {
val srcW = metrics.widthPixels.coerceAtLeast(1).toFloat()
val srcH = metrics.heightPixels.coerceAtLeast(1).toFloat()
val budget = targetWidth.coerceAtLeast(16)
val aspect = srcW / srcH
val w = budget
val h = (w / aspect).toInt().coerceAtLeast(16)
// Bias toward even dimensions — some encoders/ImageReaders are
// unhappy with odd sizes when row strides come into play.
captureWidth = (w and 1.inv()).coerceAtLeast(16)
captureHeight = (h and 1.inv()).coerceAtLeast(16)
if (captureWidth != targetWidth || captureHeight != targetHeight) {
Log.i(
TAG,
"Capture size adjusted for ${srcW.toInt()}x${srcH.toInt()} " +
"(${"%.2f".format(aspect)}:1) → ${captureWidth}x$captureHeight",
)
}
}
private var virtualDisplay: VirtualDisplay? = null private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null private var imageReader: ImageReader? = null
private var captureThread: HandlerThread? = null private var captureThread: HandlerThread? = null
private var captureHandler: Handler? = null private var captureHandler: Handler? = null
@Volatile private var running = false @Volatile private var running = false
private var lastFrameTimeMs = 0L
private val frameIntervalMs = 1000L / targetFps // Reusable RGBA frame buffer — sized once for the capture dimensions.
// The capture handler is single-threaded so no synchronisation is
// required around this buffer (each callback runs to completion
// before the next is dispatched). Eliminates ~15 MB/s of per-frame
// garbage at 30 fps × 480×270×4 B that previously caused GC pauses
// on low-end TV boxes.
private val frameBuffer: ByteArray = ByteArray(captureWidth * captureHeight * 4)
// Monotonic frame pacing. `nextFrameNanos` is the target render
// time of the next frame; carrying it forward as an accumulator
// avoids the integer-division drift the wall-clock version had
// (e.g. 30 fps → 33 ms produced ~30.3 fps).
private val frameIntervalNanos = (1_000_000_000L / targetFps.coerceAtLeast(1))
private var nextFrameNanos = 0L
/** /**
* Start capturing the screen. * Start capturing the screen.
@@ -48,6 +87,7 @@ class ScreenCapture(
fun start() { fun start() {
if (running) return if (running) return
running = true running = true
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
captureThread = HandlerThread("LedGrab-Capture").also { it.start() } captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
captureHandler = Handler(captureThread!!.looper) captureHandler = Handler(captureThread!!.looper)
@@ -56,28 +96,32 @@ class ScreenCapture(
projection.registerCallback(object : MediaProjection.Callback() { projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() { override fun onStop() {
Log.i(TAG, "MediaProjection stopped (external)") Log.i(TAG, "MediaProjection stopped (external)")
stop() // We're on captureHandler's thread here — calling stop()
// Notify the service so the foreground notification / // directly would self-join captureThread (handler.join()
// Python server get torn down too — otherwise a stale // from inside the handler thread hangs until the join
// "Running" notification lingers after the user taps // timeout, then closes resources while we're STILL
// Android's system Cast/Screen-capture stop banner. // inside this callback). Just flip `running` to halt
// frame processing and hand off to the service; its
// onDestroy will call stop() from the main thread,
// which is safe to join captureThread from.
running = false
onProjectionStopped() onProjectionStopped()
} }
}, captureHandler) }, captureHandler)
imageReader = ImageReader.newInstance( imageReader = ImageReader.newInstance(
targetWidth, captureWidth,
targetHeight, captureHeight,
PixelFormat.RGBA_8888, PixelFormat.RGBA_8888,
2, // maxImages — double buffer 3, // maxImages — small ring buffer; 3 is more forgiving than 2 under jitter
) )
imageReader?.setOnImageAvailableListener({ reader -> imageReader?.setOnImageAvailableListener({ reader ->
if (!running) return@setOnImageAvailableListener if (!running) return@setOnImageAvailableListener
val now = System.currentTimeMillis() val now = SystemClock.elapsedRealtimeNanos()
if (now - lastFrameTimeMs < frameIntervalMs) { if (now < nextFrameNanos) {
// Skip frame to maintain target FPS // Too early — drop this image to stay on cadence.
reader.acquireLatestImage()?.close() reader.acquireLatestImage()?.close()
return@setOnImageAvailableListener return@setOnImageAvailableListener
} }
@@ -88,26 +132,30 @@ class ScreenCapture(
val buffer = plane.buffer val buffer = plane.buffer
val rowStride = plane.rowStride val rowStride = plane.rowStride
val pixelStride = plane.pixelStride val pixelStride = plane.pixelStride
val rowBytes = captureWidth * pixelStride
val expected = rowBytes * captureHeight
// Handle row padding: rowStride may be > width * pixelStride // Fill the reusable buffer. Two paths:
val rgbaBytes = if (rowStride == targetWidth * pixelStride) { // - rowStride == rowBytes: bulk get into the buffer
// No padding — direct copy // - rowStride > rowBytes: row-by-row copy stripping padding
val bytes = ByteArray(buffer.remaining()) if (rowStride == rowBytes && buffer.remaining() >= expected) {
buffer.get(bytes) buffer.get(frameBuffer, 0, expected)
bytes
} else { } else {
// Strip row padding for (row in 0 until captureHeight) {
val rowBytes = targetWidth * pixelStride
val bytes = ByteArray(targetWidth * targetHeight * 4)
for (row in 0 until targetHeight) {
buffer.position(row * rowStride) buffer.position(row * rowStride)
buffer.get(bytes, row * rowBytes, rowBytes) buffer.get(frameBuffer, row * rowBytes, rowBytes)
} }
bytes
} }
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight) bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
lastFrameTimeMs = now
// Advance the pacing accumulator. If we fell badly behind
// (long GC, JNI stall), snap forward to "now" instead of
// accumulating a burst of catch-up frames.
nextFrameNanos += frameIntervalNanos
if (now - nextFrameNanos > frameIntervalNanos * 4) {
nextFrameNanos = now + frameIntervalNanos
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Frame processing error: ${e.message}") Log.w(TAG, "Frame processing error: ${e.message}")
} finally { } finally {
@@ -117,8 +165,8 @@ class ScreenCapture(
virtualDisplay = projection.createVirtualDisplay( virtualDisplay = projection.createVirtualDisplay(
VIRTUAL_DISPLAY_NAME, VIRTUAL_DISPLAY_NAME,
targetWidth, captureWidth,
targetHeight, captureHeight,
metrics.densityDpi, metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader?.surface, imageReader?.surface,
@@ -126,7 +174,7 @@ class ScreenCapture(
captureHandler, captureHandler,
) )
Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)") Log.i(TAG, "Screen capture started (${captureWidth}x${captureHeight} @ ${targetFps}fps)")
} }
/** /**
@@ -8,6 +8,7 @@ import android.content.IntentFilter
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import com.hoho.android.usbserial.driver.UsbSerialDriver import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber import com.hoho.android.usbserial.driver.UsbSerialProber
@@ -54,8 +55,23 @@ object UsbSerialBridge {
if (!initialized.compareAndSet(false, true)) return if (!initialized.compareAndSet(false, true)) return
val filter = IntentFilter(ACTION_USB_PERMISSION) val filter = IntentFilter(ACTION_USB_PERMISSION)
val ourPackage = app.packageName
val receiver = object : BroadcastReceiver() { val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) { override fun onReceive(ctx: Context, intent: Intent) {
// Defence-in-depth: the receiver is registered as
// RECEIVER_NOT_EXPORTED, but on pre-API-33 platforms
// older Android versions historically defaulted to
// exported. Also enforce the package check here so an
// explicit-intent attack from another app on the device
// is rejected even if the OS treats us as exported.
if (intent.`package` != null && intent.`package` != ourPackage) {
Log.w(
TAG,
"Ignoring USB permission broadcast from " +
"package='${intent.`package`}' (not us)",
)
return
}
val granted = intent.getBooleanExtra( val granted = intent.getBooleanExtra(
UsbManager.EXTRA_PERMISSION_GRANTED, UsbManager.EXTRA_PERMISSION_GRANTED,
false, false,
@@ -69,13 +85,16 @@ object UsbSerialBridge {
} }
} }
} }
// Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts. // ContextCompat handles the RECEIVER_NOT_EXPORTED flag correctly
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // across all supported API levels (it's a no-op on platforms
app.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) // where the flag doesn't exist, and explicit on API ≥33 where
} else { // Android enforces it).
@Suppress("UnspecifiedRegisterReceiverFlag") ContextCompat.registerReceiver(
app.registerReceiver(receiver, filter) app,
} receiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)
} }
private fun ctx(): Context = private fun ctx(): Context =
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Android TV launcher banner: 320x180 landscape.
Shown on the leanback home row. The previous build reused the square
launcher icon, which letterboxed badly. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<!-- Background -->
<path
android:fillColor="#0d1117"
android:pathData="M0,0 L320,0 L320,180 L0,180 Z" />
<!-- Subtle teal glow top-left -->
<path
android:fillColor="#1A64ffda"
android:pathData="M0,0 L160,0 L160,90 L0,90 Z" />
<!-- Subtle purple glow bottom-right -->
<path
android:fillColor="#15bb86fc"
android:pathData="M160,90 L320,90 L320,180 L160,180 Z" />
<!-- TV body, centered -->
<path
android:fillColor="#1c2333"
android:pathData="M88,56 L196,56 Q204,56 204,64 L204,116 Q204,124 196,124 L88,124 Q80,124 80,116 L80,64 Q80,56 88,56 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M92,60 L192,60 Q196,60 196,64 L196,116 Q196,120 192,120 L92,120 Q88,120 88,116 L88,64 Q88,60 92,60 Z" />
<!-- LED glow strips -->
<path
android:fillColor="#64ffda"
android:fillAlpha="0.8"
android:pathData="M94,50 L190,50 L190,54 L94,54 Z" />
<path
android:fillColor="#bb86fc"
android:fillAlpha="0.7"
android:pathData="M72,62 L76,62 L76,118 L72,118 Z" />
<path
android:fillColor="#ff6b6b"
android:fillAlpha="0.7"
android:pathData="M208,62 L212,62 L212,118 L208,118 Z" />
<path
android:fillColor="#ffd93d"
android:fillAlpha="0.7"
android:pathData="M94,126 L190,126 L190,130 L94,130 Z" />
<!-- Wordmark "LedGrab" — drawn as paths so we don't depend on the
system font cache being warm at TV launch. -->
<!-- L -->
<path android:fillColor="#64ffda"
android:pathData="M222,72 L228,72 L228,100 L240,100 L240,106 L222,106 Z" />
<!-- e -->
<path android:fillColor="#e6edf3"
android:pathData="M244,82 L260,82 Q264,82 264,86 L264,94 L250,94 L250,100 L262,100 L262,106 L246,106 Q244,106 244,104 Z M250,86 L250,90 L258,90 L258,86 Z" />
<!-- d -->
<path android:fillColor="#e6edf3"
android:pathData="M266,72 L272,72 L272,82 L284,82 Q286,82 286,84 L286,106 L268,106 Q266,106 266,104 Z M272,88 L272,100 L280,100 L280,88 Z" />
</vector>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static fallback for the status dot. The animated version
(animated_status_dot.xml) is used at runtime; this is what
XML rendering tools show in the editor. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval"> android:shape="oval">
<solid android:color="@color/green_status" /> <solid android:color="@color/green_status" />
<size android:width="18dp" android:height="18dp" />
</shape> </shape>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome status-bar icon. Android requires white-on-transparent for
notification icons since API 21 - reusing the colored launcher would
render as a gray blob. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<!-- TV body -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5,7 L19,7 Q20,7 20,8 L20,16 Q20,17 19,17 L5,17 Q4,17 4,16 L4,8 Q4,7 5,7 Z M5.5,8.5 L5.5,15.5 L18.5,15.5 L18.5,8.5 Z" />
<!-- TV stand -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M10,17 L10,18.5 L14,18.5 L14,17 Z M9,19 L15,19 L15,20 L9,20 Z" />
<!-- LED glow strips around the TV (bright dots) -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M6,5.5 L18,5.5 L18,6.5 L6,6.5 Z" />
</vector>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Splash screen icon (API 31+ uses a 1:1 vector inside a 240dp circle).
The SplashScreen API masks this with a circle automatically. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="240dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- TV body -->
<path
android:fillColor="#1c2333"
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
<!-- LED glow strips, brighter on splash for impact -->
<path
android:fillColor="#64ffda"
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
<path
android:fillColor="#bb86fc"
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
<path
android:fillColor="#ff6b6b"
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
<path
android:fillColor="#ffd93d"
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
<!-- TV stand -->
<path
android:fillColor="#1c2333"
android:pathData="M44,72 L44,78 L64,78 L64,72" />
<path
android:fillColor="#1c2333"
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
</vector>
+27 -115
View File
@@ -32,16 +32,28 @@
android:textStyle="bold" android:textStyle="bold"
android:letterSpacing="0.08" android:letterSpacing="0.08"
android:layout_marginBottom="12dp" android:layout_marginBottom="12dp"
android:fontFamily="sans-serif-light" /> android:fontFamily="sans-serif" />
<TextView <TextView
android:id="@+id/status_text" android:id="@+id/tagline_text"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/tagline" android:text="@string/tagline"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="28sp" android:textSize="28sp"
android:layout_marginBottom="64dp" /> android:layout_marginBottom="24dp" />
<!-- Transient status (root probing / permission denial). Always
present so the layout doesn't reflow when text appears. -->
<TextView
android:id="@+id/status_text"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="20sp"
android:layout_marginBottom="32dp"
tools:text="Checking root access…" />
<Button <Button
android:id="@+id/toggle_button" android:id="@+id/toggle_button"
@@ -51,7 +63,8 @@
android:text="@string/btn_start" android:text="@string/btn_start"
android:textSize="22sp" android:textSize="22sp"
android:focusable="true" android:focusable="true"
android:focusableInTouchMode="true" /> android:focusableInTouchMode="true"
android:nextFocusDown="@+id/autostart_check" />
<CheckBox <CheckBox
android:id="@+id/autostart_check" android:id="@+id/autostart_check"
@@ -63,10 +76,11 @@
android:textSize="20sp" android:textSize="20sp"
android:buttonTint="@color/teal_accent" android:buttonTint="@color/teal_accent"
android:focusable="true" android:focusable="true"
android:focusableInTouchMode="true" /> android:focusableInTouchMode="true"
android:nextFocusUp="@id/toggle_button" />
</LinearLayout> </LinearLayout>
<!-- Version at bottom --> <!-- Version at bottom (always visible — looks polished on TV idle). -->
<TextView <TextView
android:id="@+id/version_text" android:id="@+id/version_text"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -77,115 +91,13 @@
android:textSize="18sp" android:textSize="18sp"
tools:text="v0.1.0" /> tools:text="v0.1.0" />
<!-- RUNNING STATE --> <!-- RUNNING STATE — deferred-inflate via ViewStub so first paint is
<LinearLayout cheaper and the inflater doesn't measure two competing layouts. -->
android:id="@+id/running_panel" <ViewStub
android:id="@+id/running_panel_stub"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="horizontal" android:inflatedId="@+id/running_panel"
android:gravity="center_vertical" android:layout="@layout/panel_running"
android:paddingStart="120dp" android:visibility="gone" />
android:paddingEnd="120dp"
android:paddingTop="80dp"
android:paddingBottom="80dp"
android:visibility="gone">
<!-- Left: status + URL + stop -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="start|center_vertical"
android:paddingEnd="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<View
android:layout_width="18dp"
android:layout_height="18dp"
android:background="@drawable/bg_status_dot"
android:layout_marginEnd="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_running"
android:textColor="@color/green_status"
android:textSize="28sp"
android:textStyle="bold"
android:letterSpacing="0.05" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_web_ui"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/url_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/teal_accent"
android:textSize="30sp"
android:maxLines="1"
android:textStyle="bold"
android:background="@drawable/bg_url_chip"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:layout_marginBottom="56dp"
tools:text="http://192.168.1.5:8080" />
<Button
android:id="@+id/stop_button_running"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="240dp"
android:layout_height="64dp"
android:text="@string/btn_stop"
android:textSize="20sp"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Right: QR code -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_qr_container"
android:padding="20dp"
android:layout_marginBottom="20dp">
<ImageView
android:id="@+id/qr_image"
android:layout_width="280dp"
android:layout_height="280dp"
android:contentDescription="@string/qr_description"
android:scaleType="fitXY" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_to_configure"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:gravity="center" />
</LinearLayout>
</LinearLayout>
</FrameLayout> </FrameLayout>
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- RUNNING STATE -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="120dp"
android:paddingEnd="120dp"
android:paddingTop="80dp"
android:paddingBottom="80dp">
<!-- Left: status + URL + stop -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="start|center_vertical"
android:paddingEnd="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<View
android:id="@+id/status_dot"
android:layout_width="18dp"
android:layout_height="18dp"
android:background="@drawable/bg_status_dot"
android:layout_marginEnd="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_running"
android:textColor="@color/green_status"
android:textSize="28sp"
android:textStyle="bold"
android:letterSpacing="0.05" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_web_ui"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/url_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/teal_accent"
android:textSize="30sp"
android:maxLines="1"
android:textStyle="bold"
android:background="@drawable/bg_url_chip"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:layout_marginBottom="56dp"
tools:text="http://192.168.1.5:8080" />
<Button
android:id="@+id/stop_button_running"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="240dp"
android:layout_height="64dp"
android:text="@string/btn_stop"
android:textSize="20sp"
android:focusable="true"
android:focusableInTouchMode="true"
android:nextFocusUp="@id/stop_button_running"
android:nextFocusDown="@id/stop_button_running" />
</LinearLayout>
<!-- Right: QR code + fallback hint -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_qr_container"
android:padding="20dp"
android:layout_marginBottom="20dp">
<ImageView
android:id="@+id/qr_image"
android:layout_width="280dp"
android:layout_height="280dp"
android:contentDescription="@string/qr_description"
android:scaleType="fitXY" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_to_configure"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:gravity="center" />
<TextView
android:layout_width="280dp"
android:layout_height="wrap_content"
android:text="@string/scan_fallback_hint"
android:textColor="@color/text_hint"
android:textSize="14sp"
android:gravity="center"
android:layout_marginTop="6dp" />
</LinearLayout>
</LinearLayout>
@@ -3,12 +3,26 @@
<string name="app_name">LedGrab</string> <string name="app_name">LedGrab</string>
<string name="tagline">Фоновая подсветка для телевизора</string> <string name="tagline">Фоновая подсветка для телевизора</string>
<string name="btn_start">Начать захват</string> <string name="btn_start">Начать захват</string>
<string name="btn_starting">Запуск…</string>
<string name="btn_stop">Стоп</string> <string name="btn_stop">Стоп</string>
<string name="status_running">Работает</string> <string name="status_running">Работает</string>
<string name="status_checking_root">Проверка root-доступа…</string>
<string name="status_permission_denied">Доступ запрещён — для захвата экрана требуется разрешение</string>
<string name="status_no_network">Нет сети — подключите Wi-Fi или Ethernet</string>
<string name="label_web_ui">Адрес веб-интерфейса</string> <string name="label_web_ui">Адрес веб-интерфейса</string>
<string name="scan_to_configure">Сканируйте для настройки</string> <string name="scan_to_configure">Сканируйте для настройки</string>
<string name="scan_fallback_hint">или откройте этот адрес с любого устройства в сети</string>
<string name="qr_description">QR-код для веб-интерфейса</string> <string name="qr_description">QR-код для веб-интерфейса</string>
<string name="version_prefix">v%1$s</string> <string name="version_prefix">v%1$s</string>
<string name="autostart_label">Запускать при загрузке (только с root)</string> <string name="autostart_label">Запускать при загрузке (только с root)</string>
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string> <string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string>
<string name="fatal_title">Не удалось запустить LedGrab</string>
<string name="fatal_body_prefix">Ошибка инициализации Python:</string>
<string name="fatal_copy_log">Скопировать журнал</string>
<string name="fatal_show_details">Показать подробности</string>
<string name="fatal_hide_details">Скрыть подробности</string>
<string name="notification_channel_name">Захват LedGrab</string>
<string name="notification_channel_description">Отображается, пока LedGrab захватывает экран.</string>
<string name="notification_title">LedGrab работает</string>
<string name="notification_text">Веб-интерфейс: %1$s</string>
</resources> </resources>
@@ -3,12 +3,26 @@
<string name="app_name">LedGrab</string> <string name="app_name">LedGrab</string>
<string name="tagline">电视氛围灯光</string> <string name="tagline">电视氛围灯光</string>
<string name="btn_start">开始捕获</string> <string name="btn_start">开始捕获</string>
<string name="btn_starting">正在启动…</string>
<string name="btn_stop">停止</string> <string name="btn_stop">停止</string>
<string name="status_running">运行中</string> <string name="status_running">运行中</string>
<string name="status_checking_root">正在检查 root 权限…</string>
<string name="status_permission_denied">权限被拒绝 — 屏幕捕获需要授权</string>
<string name="status_no_network">无网络 — 请连接 Wi-Fi 或以太网</string>
<string name="label_web_ui">Web界面地址</string> <string name="label_web_ui">Web界面地址</string>
<string name="scan_to_configure">扫码配置</string> <string name="scan_to_configure">扫码配置</string>
<string name="scan_fallback_hint">或在同一网络的任何设备上访问上方网址</string>
<string name="qr_description">Web界面二维码</string> <string name="qr_description">Web界面二维码</string>
<string name="version_prefix">v%1$s</string> <string name="version_prefix">v%1$s</string>
<string name="autostart_label">开机自启(仅限 root)</string> <string name="autostart_label">开机自启(仅限 root)</string>
<string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string> <string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string>
<string name="fatal_title">LedGrab 启动失败</string>
<string name="fatal_body_prefix">Python 运行时初始化失败:</string>
<string name="fatal_copy_log">复制日志</string>
<string name="fatal_show_details">显示详情</string>
<string name="fatal_hide_details">隐藏详情</string>
<string name="notification_channel_name">LedGrab 屏幕捕获</string>
<string name="notification_channel_description">LedGrab 捕获屏幕时显示。</string>
<string name="notification_title">LedGrab 运行中</string>
<string name="notification_text">Web界面:%1$s</string>
</resources> </resources>
@@ -3,12 +3,26 @@
<string name="app_name">LedGrab</string> <string name="app_name">LedGrab</string>
<string name="tagline">Ambient lighting for your TV</string> <string name="tagline">Ambient lighting for your TV</string>
<string name="btn_start">Start Capture</string> <string name="btn_start">Start Capture</string>
<string name="btn_starting">Starting…</string>
<string name="btn_stop">Stop</string> <string name="btn_stop">Stop</string>
<string name="status_running">Running</string> <string name="status_running">Running</string>
<string name="status_checking_root">Checking root access…</string>
<string name="status_permission_denied">Permission denied — screen capture requires authorization</string>
<string name="status_no_network">No network — connect Wi-Fi or Ethernet</string>
<string name="label_web_ui">Web UI address</string> <string name="label_web_ui">Web UI address</string>
<string name="scan_to_configure">Scan to configure</string> <string name="scan_to_configure">Scan to configure</string>
<string name="scan_fallback_hint">or visit the URL above on any device on this network</string>
<string name="qr_description">QR code for web UI</string> <string name="qr_description">QR code for web UI</string>
<string name="version_prefix">v%1$s</string> <string name="version_prefix">v%1$s</string>
<string name="autostart_label">Start on boot (root only)</string> <string name="autostart_label">Start on boot (root only)</string>
<string name="autostart_unavailable">Start on boot — unavailable (root required)</string> <string name="autostart_unavailable">Start on boot — unavailable (root required)</string>
<string name="fatal_title">LedGrab failed to start</string>
<string name="fatal_body_prefix">Python runtime initialization failed:</string>
<string name="fatal_copy_log">Copy log</string>
<string name="fatal_show_details">Show details</string>
<string name="fatal_hide_details">Hide details</string>
<string name="notification_channel_name">LedGrab capture</string>
<string name="notification_channel_description">Shows while LedGrab is capturing the screen.</string>
<string name="notification_title">LedGrab Running</string>
<string name="notification_text">Web UI: %1$s</string>
</resources> </resources>
@@ -12,6 +12,16 @@
<item name="android:colorControlActivated">@color/teal_accent</item> <item name="android:colorControlActivated">@color/teal_accent</item>
</style> </style>
<!-- Splash screen theme. Compatible across API levels via the
androidx.core:core-splashscreen library. On API 31+ the system
splash uses the foreground icon; on older versions the launch
theme just paints the navy background, which is harmless. -->
<style name="Theme.LedGrab.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/bg_navy</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
<item name="postSplashScreenTheme">@style/Theme.LedGrab</item>
</style>
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button"> <style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
<item name="android:background">@drawable/bg_button_primary</item> <item name="android:background">@drawable/bg_button_primary</item>
<item name="android:textColor">@color/bg_navy</item> <item name="android:textColor">@color/bg_navy</item>
@@ -1,8 +1,28 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
LedGrab communicates with WLED controllers, Home Assistant, and MQTT LedGrab is a LAN-only app:
brokers on the local network via plain HTTP/UDP. Cleartext traffic - Inbound: web UI / API on the device (HTTP, port 8080)
must be allowed for these connections to work on Android 9+. - Outbound: WLED HTTP/UDP, Home Assistant, MQTT brokers, mDNS
All of these are plaintext on the local network. Android's network
security config doesn't support CIDR allowlists, so we cannot
restrict cleartext to RFC1918 ranges declaratively — we have to
permit cleartext base-wide.
Defence-in-depth that ACTUALLY mitigates this:
1. Inbound: the FastAPI server in this app rejects non-loopback
requests when no API key is configured (see ledgrab.api.auth).
The Android launcher auto-generates an API key on first run
(see ApiKeyManager.kt) and injects it via the
LEDGRAB_AUTH__API_KEYS env var before uvicorn starts. The
user's phone receives the key by scanning the QR, which
embeds the key as a URL fragment (never logged server-side).
2. Outbound: targets are validated by net_classify in the Python
layer (LAN-only HTTP, SSRF-safe).
DO NOT remove the cleartext permission without first migrating
every LAN peer to HTTPS — most WLED firmware, mDNS, and the LAN
HTTP server itself rely on this flag.
--> -->
<network-security-config> <network-security-config>
<base-config cleartextTrafficPermitted="true" /> <base-config cleartextTrafficPermitted="true" />
+17 -12
View File
@@ -1,16 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Cross-compile pydantic-core for Android across all three ABIs: # Cross-compile pydantic-core for Android across all supported ABIs:
# arm64-v8a (primary — real TV hardware) # arm64-v8a (primary — modern TV hardware)
# x86_64 (modern emulators) # x86_64 (modern emulators)
# x86 (legacy emulators) # x86 (legacy emulators)
# armeabi-v7a (32-bit ARMv7 — older cheap TV boxes like X96 mini, MeCool)
# #
# Outputs wheels into android/wheels/. Wheels are linked against the real # Outputs wheels into android/wheels/. Wheels are linked against the real
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see # libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
# memory/project_android_app.md for the incident notes). # memory/project_android_app.md for the incident notes).
# #
# Prerequisites (on host): # Prerequisites (on host):
# - Rust + cargo (rustup) with targets: aarch64/x86_64/i686-linux-android # - Rust + cargo (rustup) with targets:
# aarch64/x86_64/i686/armv7a-linux-android(eabi)
# - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*) # - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*)
# - Python 3.11 (matches Chaquopy's embedded version) # - Python 3.11 (matches Chaquopy's embedded version)
# - maturin (pip install maturin) # - maturin (pip install maturin)
@@ -19,9 +21,10 @@
# core dependency version changes. # core dependency version changes.
# #
# Usage: # Usage:
# ./build-pydantic-core.sh # build all three ABIs # ./build-pydantic-core.sh # build all 4 ABIs
# ./build-pydantic-core.sh arm64 # build a single ABI # ./build-pydantic-core.sh arm64 # build a single ABI
# ./build-pydantic-core.sh arm64 x86_64 # build a subset # ./build-pydantic-core.sh arm64 x86_64 # build a subset
# ./build-pydantic-core.sh armv7 # 32-bit ARM only
# #
set -euo pipefail set -euo pipefail
@@ -91,21 +94,23 @@ fi
# ── ABI table ─────────────────────────────────────────────────────── # ── ABI table ───────────────────────────────────────────────────────
# Columns: short_name rust_target clang_prefix sysconfig_dir # Columns: short_name rust_target clang_prefix sysconfig_dir
ABI_TABLE=( ABI_TABLE=(
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig" "arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64" "x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86" "x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
"armv7 armv7-linux-androideabi armv7a-linux-androideabi${API_LEVEL} cross-sysconfig-armv7"
) )
declare -A ABI_TAG_MAP=( declare -A ABI_TAG_MAP=(
[arm64]="arm64_v8a" [arm64]="arm64_v8a"
[x86_64]="x86_64" [x86_64]="x86_64"
[x86]="x86" [x86]="x86"
[armv7]="armeabi_v7a"
) )
# ── Select which ABIs to build ────────────────────────────────────── # ── Select which ABIs to build ──────────────────────────────────────
SELECTED=("$@") SELECTED=("$@")
if [ ${#SELECTED[@]} -eq 0 ]; then if [ ${#SELECTED[@]} -eq 0 ]; then
SELECTED=(arm64 x86_64 x86) SELECTED=(arm64 x86_64 x86 armv7)
fi fi
# ── Ensure rust targets are installed ─────────────────────────────── # ── Ensure rust targets are installed ───────────────────────────────
+327
View File
@@ -0,0 +1,327 @@
"""Generate LedGrab app icon assets.
Concept: "Spectrum Aperture" — a rounded-square frame (the screen/display)
traced by a continuous RGB color-wheel stroke (the bias-light LED strip),
on a near-black canvas with a soft chromatic bloom behind it.
Outputs:
server/src/ledgrab/static/icons/icon-512.png (standard, opaque vignette bg)
server/src/ledgrab/static/icons/icon-192.png (downscale of 512)
server/src/ledgrab/static/icons/icon-512-maskable.png (safe-area padded, opaque)
server/src/ledgrab/static/icons/icon-tray.png (256, transparent bg, frame + glow)
server/src/ledgrab/static/icons/icon.ico (16/24/32/48/64/128/256)
Run from repo root:
py -3.13 build/generate_icon.py
"""
from __future__ import annotations
import colorsys
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
# ── Tunables ────────────────────────────────────────────────────────────
SUPERSAMPLE = 4 # render at 4x and downsample for crispness
BASE = 1024 # logical canvas size
HQ = BASE * SUPERSAMPLE # render canvas
BG_TOP = (12, 14, 22) # near-black, faint cool tint
BG_BOTTOM = (6, 7, 12) # darker at edges (vignette feel)
FRAME_INSET = 0.18 # margin from canvas edge to frame (fraction)
FRAME_RADIUS = 0.22 # corner radius (fraction of frame side)
FRAME_STROKE = 0.085 # stroke width (fraction of canvas)
BLOOM_OPACITY = 0.62 # outer bloom strength (01)
INNER_GLOW_OPACITY = 0.38 # inner chromatic reflection strength
# Hue rotation offset so red sits at the top
HUE_OFFSET = -90.0 # degrees (negative = counter-clockwise shift)
def lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def hue_to_rgb(hue_deg: float) -> tuple[int, int, int]:
"""Bright, slightly desaturated spectral color (LED-like)."""
h = (hue_deg % 360) / 360.0
r, g, b = colorsys.hls_to_rgb(h, 0.58, 0.92)
return int(r * 255), int(g * 255), int(b * 255)
def vignette_background(size: int) -> Image.Image:
"""Dark canvas with a soft radial vignette + faint scanline noise."""
img = Image.new("RGB", (size, size), BG_TOP)
px = img.load()
cx, cy = size / 2, size / 2
max_r = math.hypot(cx, cy)
for y in range(size):
for x in range(size):
d = math.hypot(x - cx, y - cy) / max_r
t = min(1.0, d**1.6)
px[x, y] = (
int(lerp(BG_TOP[0], BG_BOTTOM[0], t)),
int(lerp(BG_TOP[1], BG_BOTTOM[1], t)),
int(lerp(BG_TOP[2], BG_BOTTOM[2], t)),
)
return img
def draw_chromatic_bloom(size: int) -> Image.Image:
"""Soft, large chromatic glow behind the frame — the bias-light effect."""
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
cx, cy = size / 2, size / 2
radius = size * 0.36
blob_r = int(size * 0.30)
n_blobs = 24
for i in range(n_blobs):
a = i / n_blobs * 360.0
bx = cx + math.cos(math.radians(a - 90)) * radius
by = cy + math.sin(math.radians(a - 90)) * radius
r, g, b = hue_to_rgb(a + HUE_OFFSET)
alpha = int(255 * BLOOM_OPACITY * 0.55)
draw.ellipse(
(bx - blob_r, by - blob_r, bx + blob_r, by + blob_r),
fill=(r, g, b, alpha),
)
# Heavy blur → continuous, dreamy halo
layer = layer.filter(ImageFilter.GaussianBlur(radius=size * 0.10))
return layer
def rounded_rect_mask(size: int, inset: int, radius: int, stroke: int) -> Image.Image:
"""L-mode mask of a rounded-rect ring (the frame stroke region)."""
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
box_outer = (inset, inset, size - inset, size - inset)
box_inner = (
inset + stroke,
inset + stroke,
size - inset - stroke,
size - inset - stroke,
)
r_outer = radius
r_inner = max(0, radius - stroke)
draw.rounded_rectangle(box_outer, radius=r_outer, fill=255)
draw.rounded_rectangle(box_inner, radius=r_inner, fill=0)
return mask
def draw_spectrum_frame(size: int) -> Image.Image:
"""Draw the rounded-square frame stroke filled with a hue-rotation gradient.
Strategy: paint a full-canvas angular hue gradient (centered), then
clip it with the rounded-ring mask. This guarantees a continuous,
seam-free color flow around the entire frame.
"""
cx, cy = size / 2, size / 2
gradient = Image.new("RGB", (size, size), (0, 0, 0))
gpx = gradient.load()
for y in range(size):
dy = y - cy
for x in range(size):
dx = x - cx
ang = math.degrees(math.atan2(dy, dx)) + 90.0 # 0° = top
r, g, b = hue_to_rgb(ang + HUE_OFFSET)
gpx[x, y] = (r, g, b)
inset = int(size * FRAME_INSET)
frame_side = size - 2 * inset
stroke = int(size * FRAME_STROKE)
radius = int(frame_side * FRAME_RADIUS)
mask = rounded_rect_mask(size, inset, radius, stroke)
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
out.paste(gradient, (0, 0), mask)
return out
def draw_inner_screen(size: int) -> Image.Image:
"""Subtle dark rounded square inside the frame, with faint chromatic
inner reflection along the edges — like a screen catching ambient light."""
inset = int(size * FRAME_INSET)
stroke = int(size * FRAME_STROKE)
frame_side = size - 2 * inset
radius = int(frame_side * FRAME_RADIUS)
pad = int(stroke * 0.35)
box = (
inset + stroke + pad,
inset + stroke + pad,
size - inset - stroke - pad,
size - inset - stroke - pad,
)
r_inner = max(0, radius - stroke - pad)
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
# Dark fill, very slight cool tint
draw.rounded_rectangle(box, radius=r_inner, fill=(10, 12, 18, 255))
# Inner chromatic glow: same spectrum, very soft, clipped to the screen
bloom = draw_chromatic_bloom(size)
screen_mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(screen_mask).rounded_rectangle(box, radius=r_inner, fill=255)
bloom_alpha = bloom.split()[-1].point(lambda v: int(v * INNER_GLOW_OPACITY))
bloom.putalpha(bloom_alpha)
masked_bloom = Image.new("RGBA", (size, size), (0, 0, 0, 0))
masked_bloom.paste(bloom, (0, 0), screen_mask)
layer.alpha_composite(masked_bloom)
# Faint highlight glint top-left
glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
gdraw = ImageDraw.Draw(glint)
glint_box = (
box[0] + int(frame_side * 0.04),
box[1] + int(frame_side * 0.04),
box[0] + int(frame_side * 0.42),
box[1] + int(frame_side * 0.18),
)
gdraw.rounded_rectangle(glint_box, radius=int(frame_side * 0.05), fill=(255, 255, 255, 22))
glint = glint.filter(ImageFilter.GaussianBlur(radius=size * 0.012))
masked_glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
masked_glint.paste(glint, (0, 0), screen_mask)
layer.alpha_composite(masked_glint)
return layer
def add_outer_frame_glow(frame_rgba: Image.Image) -> Image.Image:
"""Take the spectrum frame and produce a blurred, brightened copy for glow."""
glow = frame_rgba.copy()
# Slightly inflate brightness for glow
r, g, b, a = glow.split()
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.85)))))
glow = glow.filter(ImageFilter.GaussianBlur(radius=glow.width * 0.025))
return glow
def render_tray(size: int) -> Image.Image:
"""Render a tray-optimised icon: transparent background, bolder frame,
tight outer glow. Designed to read clearly at 1632 px on top of any
taskbar color."""
hq = size * SUPERSAMPLE
# Pull the frame inward a touch and beef up the stroke so it reads at 16 px.
global FRAME_INSET, FRAME_STROKE
saved_inset, saved_stroke = FRAME_INSET, FRAME_STROKE
FRAME_INSET = 0.13
FRAME_STROKE = 0.115
try:
frame = draw_spectrum_frame(hq)
finally:
FRAME_INSET, FRAME_STROKE = saved_inset, saved_stroke
# Tight, bright glow that doesn't bleed past the tray cell.
glow = frame.copy()
r, g, b, a = glow.split()
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.95)))))
glow = glow.filter(ImageFilter.GaussianBlur(radius=hq * 0.012))
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
canvas.alpha_composite(glow)
canvas.alpha_composite(frame)
return canvas.resize((size, size), Image.LANCZOS)
def render(size: int, *, maskable: bool = False) -> Image.Image:
"""Render the full icon at the given size."""
hq = size * SUPERSAMPLE
if maskable:
# Maskable: pad inward so the entire icon survives a circular crop.
# We render the standard composition at 80% of canvas size, centered.
bg = Image.new("RGB", (hq, hq), BG_BOTTOM).convert("RGBA")
bg.paste(vignette_background(hq), (0, 0))
inner = render(size, maskable=False).resize((int(hq * 0.78), int(hq * 0.78)), Image.LANCZOS)
# Strip the bg from the inner render: composite the spectrum
# parts on top of our maskable background.
ox = (hq - inner.width) // 2
oy = (hq - inner.height) // 2
bg.alpha_composite(inner, (ox, oy))
return bg.resize((size, size), Image.LANCZOS)
bg = vignette_background(hq).convert("RGBA")
bloom = draw_chromatic_bloom(hq)
frame = draw_spectrum_frame(hq)
frame_glow = add_outer_frame_glow(frame)
inner_screen = draw_inner_screen(hq)
# Composite order: bg → bloom → frame_glow → inner_screen → frame
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
canvas.alpha_composite(bg)
canvas.alpha_composite(bloom)
canvas.alpha_composite(frame_glow)
canvas.alpha_composite(inner_screen)
canvas.alpha_composite(frame)
return canvas.resize((size, size), Image.LANCZOS)
def main() -> None:
repo_root = Path(__file__).resolve().parent.parent
targets = [
repo_root / "server" / "src" / "ledgrab" / "static" / "icons",
repo_root
/ "android"
/ "app"
/ "build"
/ "python"
/ "sources"
/ "debug"
/ "ledgrab"
/ "static"
/ "icons",
]
print("Rendering 1024 master...")
master = render(1024, maskable=False)
print("Rendering maskable 1024 master...")
maskable_master = render(1024, maskable=True)
print("Rendering tray 512 master (transparent bg)...")
tray_master = render_tray(512)
for icons_dir in targets:
if not icons_dir.exists():
print(f" skip (missing): {icons_dir}")
continue
out_512 = icons_dir / "icon-512.png"
out_192 = icons_dir / "icon-192.png"
out_mask = icons_dir / "icon-512-maskable.png"
out_tray = icons_dir / "icon-tray.png"
out_ico = icons_dir / "icon.ico"
master.resize((512, 512), Image.LANCZOS).save(out_512, "PNG", optimize=True)
master.resize((192, 192), Image.LANCZOS).save(out_192, "PNG", optimize=True)
maskable_master.resize((512, 512), Image.LANCZOS).save(out_mask, "PNG", optimize=True)
tray_master.save(out_tray, "PNG", optimize=True)
# Pre-resize each frame from the 1024 master for maximum crispness.
# Pass them via the `sizes` arg so Pillow embeds every variant.
ico_sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
# Use the tray (transparent-bg) variant for ICO frames so the file/
# taskbar icon doesn't show a dark tile against light backgrounds.
ico_source = tray_master.resize((256, 256), Image.LANCZOS)
ico_source.save(out_ico, format="ICO", sizes=ico_sizes)
print(f" wrote: {icons_dir}")
if __name__ == "__main__":
main()
+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
+7 -4
View File
@@ -6,15 +6,18 @@ server:
# For LAN access, add your machine's IP, e.g. "http://192.168.1.100:8080" # For LAN access, add your machine's IP, e.g. "http://192.168.1.100:8080"
cors_origins: cors_origins:
- "http://localhost:8080" - "http://localhost:8080"
- "http://192.168.2.100:8080"
auth: auth:
# API keys — required for any non-loopback (LAN) request. # API keys — required for any non-loopback (LAN) request.
# When empty: # When empty (default):
# - loopback (127.0.0.1, ::1, localhost) requests are allowed anonymously # - loopback (127.0.0.1, ::1, localhost) requests are allowed anonymously
# - LAN requests are REJECTED with 401 (security default) # - LAN requests are REJECTED with 401 (security default)
# To enable LAN access, add one or more label: "api-key" entries below # To enable LAN access, uncomment the example below and replace the value
# and send `Authorization: Bearer <api-key>` with each request. # with a secret you generated yourself (e.g. `openssl rand -hex 32`).
# Generate secure keys: openssl rand -hex 32 # The previous default `dev: "development-key-change-in-production"` has
# been removed — it shipped as a publicly-known token and any deployment
# that still uses it grants full LAN access to anyone on the network.
api_keys: api_keys:
dev: "development-key-change-in-production" dev: "development-key-change-in-production"
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"
}, },
+9 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "ledgrab" name = "ledgrab"
version = "0.4.2" version = "0.8.0"
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"}
@@ -117,3 +117,11 @@ target-version = ['py311']
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py311" target-version = "py311"
[tool.ruff.lint]
# E + F are ruff's defaults; UP007 + UP045 enforce PEP-604 `X | Y` and
# `T | None` style so we don't drift back to the legacy `Union[X, Y]` /
# `Optional[T]` imports the REVIEW_TODO mechanical sweep removed.
# Recent ruff versions split the rule — UP007 covers `Union`, UP045
# covers `Optional`.
extend-select = ["UP007", "UP045"]
+86 -28
View File
@@ -288,23 +288,72 @@ $pythonExe = $resolvedPython
Write-Info "Starting $Module on port $Port..." Write-Info "Starting $Module on port $Port..."
if ($SkipBrowser) { $env:LEDGRAB_RESTART = '1' } if ($SkipBrowser) { $env:LEDGRAB_RESTART = '1' }
# Redirect the child's stdout/stderr to a log file. Without this, inheriting # Launch python.exe directly with no parent-handle inheritance. We used to
# the parent shell's handles via Start-Process -WindowStyle Hidden can cause # wrap it in `cmd /c python ... 1>log 2>err` so the parent powershell could
# the child to exit immediately when those handles aren't real console fds # tail crash logs, but that left an empty cmd.exe window hanging around for
# (e.g. when restart.ps1 is driven from WSL/Git-Bash). # the full server lifetime (cmd had to live to hold the redirect handles).
$logPath = Join-Path $env:TEMP ("ledgrab-{0}-{1}.log" -f $Module, $Port) # Instead, let python claim its own console window — the user sees the live
$errPath = "$logPath.err" # server log there, and there's no spurious cmd window.
#
# Why WMI Win32_Process.Create rather than Start-Process or
# [Diagnostics.Process]::Start? Both of those go through CreateProcess with
# bInheritHandles=true, which leaks the parent shell's pipe handles into
# the new Python process. When the caller is Git-Bash (`restart.ps1 |
# tail -10`), the bash pipe then stays open for the full server lifetime,
# hanging the bash invocation even after powershell exits. WMI's
# Win32_Process.Create uses CreateProcess with bInheritHandles=FALSE.
$argList = @() $argList = @()
$argList += $launchArgs $argList += $launchArgs
$argList += @('-m', $Module) $argList += @('-m', $Module)
$startedProc = Start-Process -FilePath $pythonExe `
-ArgumentList $argList ` # Quote each arg defensively in case a future caller adds whitespace.
-WorkingDirectory $ServerRoot ` function Quote-CmdArg {
-WindowStyle Hidden ` param([string]$Arg)
-RedirectStandardOutput $logPath ` if ($Arg -match '[\s"]') {
-RedirectStandardError $errPath ` return '"' + ($Arg -replace '"', '\"') + '"'
-PassThru }
$startedPid = $startedProc.Id return $Arg
}
$quotedArgs = ($argList | ForEach-Object { Quote-CmdArg $_ }) -join ' '
$pyQ = Quote-CmdArg $pythonExe
$cmdLine = $pyQ + ' ' + $quotedArgs
# Win32_Process.Create starts detached with no parent-handle inheritance.
# Returns @{ ProcessId; ReturnValue (0 = success) }.
# Title sets the visible console-window title so the user can tell at a
# glance which server the window belongs to (useful when running real +
# demo side by side on different ports).
$startupInfo = New-CimInstance -ClassName Win32_ProcessStartup `
-ClientOnly `
-Property @{ Title = "LedGrab - $Module (port $Port)" }
$wmiResult = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
CommandLine = $cmdLine
CurrentDirectory = $ServerRoot
ProcessStartupInformation = $startupInfo
} -ErrorAction SilentlyContinue
if (-not $wmiResult -or $wmiResult.ReturnValue -ne 0) {
Write-Warning "WMI Win32_Process.Create failed (ReturnValue=$($wmiResult.ReturnValue)); falling back to Start-Process"
# Fallback path — Start-Process inherits parent handles, so a piped
# caller may hang. Acceptable here because this branch only runs when
# WMI itself is broken (very rare).
$startedProc = Start-Process -FilePath $pythonExe `
-ArgumentList $argList `
-WorkingDirectory $ServerRoot -PassThru
$startedPid = if ($startedProc) { $startedProc.Id } else { 0 }
} else {
$startedPid = [int]$wmiResult.ProcessId
}
# Confirm the process is actually our server (defensive — WMI sometimes
# returns a PID for a transient ancestor on heavily loaded boxes).
Start-Sleep -Milliseconds 250
if (-not (Get-Process -Id $startedPid -ErrorAction SilentlyContinue)) {
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
if ($rescanned) { $startedPid = $rescanned.ProcessId } else { $startedPid = 0 }
}
# ---- Poll readiness -------------------------------------------------------- # ---- Poll readiness --------------------------------------------------------
@@ -316,28 +365,37 @@ $deadline = (Get-Date).AddSeconds($StartupTimeoutSec)
$ready = $false $ready = $false
while ((Get-Date) -lt $deadline) { while ((Get-Date) -lt $deadline) {
# Bail early if the process has already exited — something went wrong. # Bail early if the process has already exited — something went wrong.
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue if ($startedPid -gt 0) {
if (-not $proc) { break } $proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
if (-not $proc) {
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
if ($rescanned) { $startedPid = $rescanned.ProcessId } else { break }
}
} else {
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
if ($rescanned) { $startedPid = $rescanned.ProcessId }
}
if (Test-PortOpen -Port $Port) { $ready = $true; break } if (Test-PortOpen -Port $Port) { $ready = $true; break }
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
} }
if ($ready) { if ($ready) {
Write-Info "Server ready on port $Port (PID $startedPid)" if ($startedPid -gt 0) {
Write-Info "Server ready on port $Port (PID $startedPid)"
} else {
Write-Info "Server ready on port $Port"
}
exit 0 exit 0
} }
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue if ($startedPid -gt 0) {
if (-not $proc) { $proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
Write-Warning "Server process $startedPid exited before binding port $Port" if (-not $proc) {
} else { Write-Warning "Server process $startedPid exited before binding port $Port (check the server console window for the error)"
Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s" } else {
} Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s"
if (Test-Path $errPath) {
$tail = Get-Content $errPath -Tail 20 -ErrorAction SilentlyContinue
if ($tail) {
Write-Warning "Last stderr lines from $errPath :"
$tail | ForEach-Object { Write-Warning " $_" }
} }
} else {
Write-Warning "Could not locate server process; port $Port did not bind within ${StartupTimeoutSec}s"
} }
exit 1 exit 1
+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"
+44 -12
View File
@@ -1,20 +1,52 @@
"""LED Grab - Ambient lighting based on screen content.""" """LED Grab - Ambient lighting based on screen content."""
from importlib.metadata import version, PackageNotFoundError from importlib.metadata import version, PackageNotFoundError
from pathlib import Path
# Fallback version — kept in sync with pyproject.toml. MUST match the # Fallback version — patched at build time by build/build-dist.ps1 so the
# version declared there on every release. The Windows installer build # bundled Windows distribution reports the release version (the installer
# (build/build-dist.ps1) also patches this literal to the resolved build # strips ledgrab-*.dist-info, so importlib.metadata fails there).
# version, so any drift here is corrected for bundled distributions. # In dev (running from source without `pip install -e .`) and on Android
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy # (Chaquopy embeds the source directly with no dist-info), we additionally
# on Android, where the source is included directly via source sets, or # read pyproject.toml so the version is always correct without manual sync.
# in the Windows bundle where the installed dist-info is stripped). _FALLBACK_VERSION = "0.8.0"
_FALLBACK_VERSION = "0.4.2"
try:
__version__ = version("ledgrab") def _read_pyproject_version() -> str | None:
except PackageNotFoundError: """Read version from pyproject.toml (server/pyproject.toml relative to this file).
__version__ = _FALLBACK_VERSION
Returns None if the file is absent (typical for installed/bundled distributions
where pyproject.toml isn't shipped) or unreadable.
"""
try:
# __init__.py -> ledgrab/ -> src/ -> server/
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
if not pyproject.is_file():
return None
try:
import tomllib # Python 3.11+
except ImportError:
return None
with pyproject.open("rb") as f:
data = tomllib.load(f)
v = data.get("project", {}).get("version")
return v if isinstance(v, str) else None
except Exception:
return None
# Prefer pyproject.toml when it sits next to the source (dev checkout). This
# avoids stale `pip install -e .` dist-info pinning an older version after a
# bump. When pyproject.toml isn't shipped (installed packages, Windows bundle,
# Android), fall back to importlib.metadata, then the patched literal.
_live = _read_pyproject_version()
if _live:
__version__ = _live
else:
try:
__version__ = version("ledgrab")
except PackageNotFoundError:
__version__ = _FALLBACK_VERSION
__author__ = "Alexei Dolgolyov" __author__ = "Alexei Dolgolyov"
__email__ = "dolgolyov.alexei@gmail.com" __email__ = "dolgolyov.alexei@gmail.com"
+114 -10
View File
@@ -6,12 +6,15 @@ shows a system-tray icon with **Show UI** / **Exit** actions.
import asyncio import asyncio
import os import os
import signal
import socket import socket
import sys import sys
import threading 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:
@@ -40,11 +43,14 @@ from ledgrab.config import get_config # noqa: E402
from ledgrab.server_ref import set_server, set_tray # noqa: E402 from ledgrab.server_ref import set_server, set_tray # noqa: E402
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402 from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
from ledgrab.utils import setup_logging, get_logger # noqa: E402 from ledgrab.utils import setup_logging, get_logger # noqa: E402
from ledgrab.utils.platform import is_windows # noqa: E402
from ledgrab.utils.win_shutdown import WindowsShutdownGuard # noqa: E402
setup_logging() setup_logging()
logger = get_logger(__name__) logger = get_logger(__name__)
_ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png" _ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-tray.png"
_ICON_FALLBACK_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png"
def _run_server(server: uvicorn.Server) -> None: def _run_server(server: uvicorn.Server) -> None:
@@ -54,9 +60,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 +87,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:
@@ -89,10 +121,22 @@ def main() -> None:
server = uvicorn.Server(uv_config) server = uvicorn.Server(uv_config)
set_server(server) set_server(server)
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
# ``shutdown_complete`` once it has stopped targets and checkpointed the
# DB; the Windows guard waits on that event before letting the OS finish
# ending the session. Without this, the entire shutdown lifespan never
# runs on PC reboot — devices stay on and the SQLite WAL is lost.
guard = _install_os_shutdown_guard(server)
use_tray = PYSTRAY_AVAILABLE and (sys.platform == "win32" or _force_tray()) use_tray = PYSTRAY_AVAILABLE and (sys.platform == "win32" or _force_tray())
if use_tray: if use_tray:
logger.info("Starting with system tray icon") logger.info("Starting with system tray icon")
# Install signal handlers BEFORE starting the uvicorn thread so a
# SIGINT/SIGBREAK during startup still triggers a clean shutdown.
# We do NOT install them on the no-tray path because uvicorn's
# ``server.run()`` overwrites SIGINT/SIGTERM with its own handlers.
_install_signal_handlers(server)
# Uvicorn in a background thread # Uvicorn in a background thread
server_thread = threading.Thread( server_thread = threading.Thread(
@@ -102,8 +146,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,),
@@ -111,20 +155,29 @@ def main() -> None:
).start() ).start()
# Tray on main thread (blocking) # Tray on main thread (blocking)
tray_icon = _ICON_PATH if _ICON_PATH.exists() else _ICON_FALLBACK_PATH
tray = TrayManager( tray = TrayManager(
icon_path=_ICON_PATH, icon_path=tray_icon,
port=config.server.port, port=config.server.port,
on_exit=lambda: _request_shutdown(server), on_exit=lambda: _request_shutdown(server),
) )
set_tray(tray) set_tray(tray)
tray.run() tray.run()
# Tray exited — wait for server to finish its graceful shutdown # Tray exited — wait for server to finish its graceful shutdown.
server_thread.join(timeout=10) # Use a longer join than the lifespan's own ~18 s budget so we don't
# cut the DB checkpoint short on a slow disk.
server_thread.join(timeout=20)
if guard is not None:
guard.stop()
else: else:
if not PYSTRAY_AVAILABLE: if not PYSTRAY_AVAILABLE:
logger.info("System tray not available (install pystray for tray support)") logger.info("System tray not available (install pystray for tray support)")
server.run() try:
server.run()
finally:
if guard is not None:
guard.stop()
def _request_shutdown(server: uvicorn.Server) -> None: def _request_shutdown(server: uvicorn.Server) -> None:
@@ -132,6 +185,57 @@ def _request_shutdown(server: uvicorn.Server) -> None:
server.should_exit = True server.should_exit = True
def _install_os_shutdown_guard(server: uvicorn.Server) -> "WindowsShutdownGuard | None":
"""Install the OS-shutdown safety net (Windows only).
Returns the guard so the caller can ``stop()`` it on normal exit, or
``None`` on platforms where no guard is needed.
"""
if not is_windows():
return None
# ``shutdown_state`` is a leaf module — importing it does NOT pull in
# ``ledgrab.main`` and its global stores. uvicorn loads ``main`` lazily
# via the import string ``"ledgrab.main:app"`` once it starts serving.
from ledgrab.shutdown_state import shutdown_complete
guard = WindowsShutdownGuard(
on_shutdown=lambda: _request_shutdown(server),
shutdown_complete=shutdown_complete,
)
if guard.start():
logger.info("Windows shutdown guard installed")
else:
logger.warning("Windows shutdown guard failed to start")
return guard
def _install_signal_handlers(server: uvicorn.Server) -> None:
"""Catch terminal/admin shutdown signals and trigger graceful exit.
Uvicorn already installs SIGINT/SIGTERM handlers when ``server.run()``
is called on the main thread (the no-tray path). For the tray path,
uvicorn runs on a background thread and skips signal installation, so
we install our own here. SIGBREAK is Windows-specific and fires on
Ctrl-Break and in some service-stop scenarios.
"""
def _handler(signum, frame): # noqa: ANN001 - signal handler signature
logger.warning("Signal %s received — requesting shutdown", signum)
_request_shutdown(server)
candidates = ["SIGINT", "SIGTERM", "SIGBREAK"]
for name in candidates:
sig = getattr(signal, name, None)
if sig is None:
continue
try:
signal.signal(sig, _handler)
except (ValueError, OSError) as e:
# ValueError: not on main thread; OSError: signal not supported here.
logger.debug("Could not install handler for %s: %s", name, e)
def _force_tray() -> bool: def _force_tray() -> bool:
"""Allow forcing tray on non-Windows via LEDGRAB_TRAY=1.""" """Allow forcing tray on non-Windows via LEDGRAB_TRAY=1."""
import os import os
+34 -5
View File
@@ -6,16 +6,17 @@ inside an Android application. Sets up Android-specific paths
""" """
import asyncio import asyncio
import json
import os import os
import threading import threading
from typing import Any, Optional from typing import Any
_server_thread: Optional[threading.Thread] = None _server_thread: threading.Thread | None = None
_server: Optional[Any] = None # uvicorn.Server _server: Any | None = None # uvicorn.Server
_loop: Optional[asyncio.AbstractEventLoop] = None _loop: asyncio.AbstractEventLoop | None = None
def start_server(data_dir: str, port: int = 8080) -> None: def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) -> None:
"""Start the LedGrab uvicorn server. """Start the LedGrab uvicorn server.
Called from Kotlin's ``PythonBridge.startServer()``. This function Called from Kotlin's ``PythonBridge.startServer()``. This function
@@ -26,6 +27,11 @@ def start_server(data_dir: str, port: int = 8080) -> None:
data_dir: Android app-private files directory data_dir: Android app-private files directory
(e.g. ``/data/data/com.ledgrab.android/files``). (e.g. ``/data/data/com.ledgrab.android/files``).
port: HTTP port for the web UI / API. port: HTTP port for the web UI / API.
api_key: Optional Bearer token to enable LAN auth. When set,
published as ``LEDGRAB_AUTH__API_KEYS={"android":<key>}``
so the server's auth gate accepts LAN requests carrying
``Authorization: Bearer <key>``. When None, the server
falls back to its default (loopback-only).
""" """
# ── Configure paths before any LedGrab imports ────────────── # ── Configure paths before any LedGrab imports ──────────────
os.makedirs(os.path.join(data_dir, "data"), exist_ok=True) os.makedirs(os.path.join(data_dir, "data"), exist_ok=True)
@@ -41,6 +47,14 @@ def start_server(data_dir: str, port: int = 8080) -> None:
os.environ["LEDGRAB_SERVER__HOST"] = "0.0.0.0" os.environ["LEDGRAB_SERVER__HOST"] = "0.0.0.0"
os.environ["LEDGRAB_SERVER__PORT"] = str(port) os.environ["LEDGRAB_SERVER__PORT"] = str(port)
# Provision LAN auth when the Kotlin launcher supplied a key. The
# config layer (pydantic-settings) parses ``LEDGRAB_AUTH__API_KEYS``
# as JSON when the value starts with `{`. We use a dict so the
# rest of the codebase sees a labelled key just like the YAML
# config form (api_keys: {android: ...}).
if api_key:
os.environ["LEDGRAB_AUTH__API_KEYS"] = json.dumps({"android": api_key})
# ── Now safe to import LedGrab ────────────────────────────── # ── Now safe to import LedGrab ──────────────────────────────
import uvicorn # noqa: E402 import uvicorn # noqa: E402
@@ -50,10 +64,25 @@ def start_server(data_dir: str, port: int = 8080) -> None:
logger = get_logger(__name__) logger = get_logger(__name__)
logger.info("LedGrab Android: starting server on port %d", port) logger.info("LedGrab Android: starting server on port %d", port)
logger.info("Data directory: %s", data_dir) logger.info("Data directory: %s", data_dir)
if api_key:
logger.info("LedGrab Android: API key auth enabled (label=android)")
else:
logger.warning("LedGrab Android: no API key — LAN requests will be rejected")
from ledgrab.config import get_config # noqa: E402 from ledgrab.config import get_config # noqa: E402
config = get_config() config = get_config()
# Defensive: confirm the env var actually landed in the parsed config.
# If pydantic-settings ever changes how it deserialises dict[str, str]
# from env, the LAN auth would silently break (server would 401 every
# phone scan). Logging the mismatch makes the failure mode obvious in
# adb logcat.
if api_key and config.auth.api_keys.get("android") != api_key:
logger.error(
"LedGrab Android: API key did NOT land in config — LAN auth will "
"reject all requests. Check pydantic-settings dict parsing for "
"LEDGRAB_AUTH__API_KEYS."
)
uv_config = uvicorn.Config( uv_config = uvicorn.Config(
"ledgrab.main:app", "ledgrab.main:app",
+4
View File
@@ -27,10 +27,12 @@ from .routes.update import router as update_router
from .routes.assets import router as assets_router from .routes.assets import router as assets_router
from .routes.home_assistant import router as home_assistant_router from .routes.home_assistant import router as home_assistant_router
from .routes.mqtt import router as mqtt_router from .routes.mqtt import router as mqtt_router
from .routes.http_endpoints import router as http_endpoints_router
from .routes.game_integration import router as game_integration_router from .routes.game_integration import router as game_integration_router
from .routes.audio_processing_templates import router as audio_processing_templates_router from .routes.audio_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)
@@ -58,9 +60,11 @@ router.include_router(update_router)
router.include_router(assets_router) router.include_router(assets_router)
router.include_router(home_assistant_router) router.include_router(home_assistant_router)
router.include_router(mqtt_router) router.include_router(mqtt_router)
router.include_router(http_endpoints_router)
router.include_router(game_integration_router) 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"]
+73 -18
View File
@@ -11,13 +11,25 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
from ledgrab.config import get_config from ledgrab.config import get_config
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
logger = get_logger(__name__) logger = get_logger(__name__)
# Security scheme for Bearer token # Security scheme for Bearer token
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
# Exceptions that legitimately fire when we try to send / close a WebSocket
# that is already shutting down: the peer dropped, the connect-state moved
# under us, the underlying socket is gone, the JSON encoder choked, etc.
# Keeping this tuple narrow means a genuine programming error (AttributeError,
# TypeError) bubbles up to the caller instead of silently disappearing.
_WS_SEND_BENIGN_EXC: tuple[type[BaseException], ...] = (
WebSocketDisconnect,
RuntimeError,
ConnectionError,
OSError,
)
def is_auth_enabled() -> bool: def is_auth_enabled() -> bool:
@@ -26,15 +38,15 @@ def is_auth_enabled() -> bool:
def _is_loopback(host: str | None) -> bool: def _is_loopback(host: str | None) -> bool:
"""Return True when *host* is a loopback address.""" """Return True when *host* is a loopback address.
Delegates to :func:`ledgrab.utils.net_classify.is_loopback` so this
auth gate, the SSRF guard in ``safe_source``, and the LAN-default
inference in ``url_scheme`` share one classification source.
"""
if not host: if not host:
return False return False
# Strip IPv6 brackets and zone IDs return _classify_is_loopback(host)
h = host.strip().lower()
if h.startswith("[") and h.endswith("]"):
h = h[1:-1]
h = h.split("%", 1)[0]
return h in _LOOPBACK_HOSTS
def verify_api_key( def verify_api_key(
@@ -142,6 +154,23 @@ def require_authenticated(label: str) -> None:
WS_AUTH_CLOSE_CODE = 4401 WS_AUTH_CLOSE_CODE = 4401
WS_ORIGIN_CLOSE_CODE = 4403
"""Close code sent when a WebSocket request fails the Origin allowlist."""
def _is_origin_allowed(origin: str | None, allowed: list[str]) -> bool:
"""Return True when *origin* matches one of the configured CORS origins.
Non-browser clients (Python scripts, curl) don't send Origin — those are
allowed through; the Bearer-token check on the auth handshake is the
primary defence in that case. Browsers always set Origin, so this only
blocks cross-site WebSocket connection attempts (CSWSH).
"""
if not origin:
return True
return origin in set(allowed or [])
async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) -> str | None: async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) -> str | None:
"""Accept the WebSocket, then perform first-message auth handshake. """Accept the WebSocket, then perform first-message auth handshake.
@@ -152,12 +181,29 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
Returns the caller label on success, ``None`` on failure (connection Returns the caller label on success, ``None`` on failure (connection
already closed). already closed).
""" """
# Reject cross-site WebSocket attempts before accepting — a browser-based
# attacker page cannot forge the Origin header, so an Origin mismatch is
# a strong signal even before the token check. Non-browser clients
# legitimately omit Origin; those fall through to the auth handshake.
config = get_config()
origin = websocket.headers.get("origin")
if not _is_origin_allowed(origin, config.server.cors_origins):
logger.warning(
"Rejected WebSocket from origin %r (not in cors_origins)",
origin,
)
try:
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
except _WS_SEND_BENIGN_EXC:
pass
return None
await websocket.accept() await websocket.accept()
label = await verify_ws_auth(websocket, timeout=timeout) label = await verify_ws_auth(websocket, timeout=timeout)
if label is None: if label is None:
try: try:
await websocket.close(code=WS_AUTH_CLOSE_CODE) await websocket.close(code=WS_AUTH_CLOSE_CODE)
except Exception: except _WS_SEND_BENIGN_EXC:
pass pass
return None return None
return label return label
@@ -221,20 +267,29 @@ async def verify_ws_auth(
# Loopback anonymous: no auth message arrived, but none is required. # Loopback anonymous: no auth message arrived, but none is required.
try: try:
await websocket.send_json({"type": "auth_ok"}) await websocket.send_json({"type": "auth_ok"})
except Exception: except _WS_SEND_BENIGN_EXC:
return None return None
return "anonymous" return "anonymous"
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host) logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
try: try:
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"}) await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
except Exception: except _WS_SEND_BENIGN_EXC:
pass pass
return None return None
except WebSocketDisconnect: except WebSocketDisconnect:
return None return None
except Exception as exc: except (RuntimeError, ConnectionError, OSError) as exc:
# The peer hung up mid-handshake or the underlying socket is gone.
# Promote anything outside this set to a hard failure with a stack
# trace so we can see real bugs (decode errors, type errors, …).
logger.debug("WebSocket auth receive error: %s", exc) logger.debug("WebSocket auth receive error: %s", exc)
return None return None
except Exception:
# Unexpected — log the full traceback so we can see what we missed
# without leaving the connection half-open. Re-raise nothing; the
# caller will close on the None return.
logger.exception("Unexpected error during WebSocket auth handshake")
return None
# Parse the auth message. # Parse the auth message.
try: try:
@@ -244,7 +299,7 @@ async def verify_ws_auth(
await websocket.send_json( await websocket.send_json(
{"type": "auth_error", "reason": "invalid JSON in auth message"} {"type": "auth_error", "reason": "invalid JSON in auth message"}
) )
except Exception: except _WS_SEND_BENIGN_EXC:
pass pass
return None return None
@@ -253,7 +308,7 @@ async def verify_ws_auth(
await websocket.send_json( await websocket.send_json(
{"type": "auth_error", "reason": "first message must be {type:'auth'}"} {"type": "auth_error", "reason": "first message must be {type:'auth'}"}
) )
except Exception: except _WS_SEND_BENIGN_EXC:
pass pass
return None return None
@@ -263,7 +318,7 @@ async def verify_ws_auth(
await websocket.send_json( await websocket.send_json(
{"type": "auth_error", "reason": "token must be a string or null"} {"type": "auth_error", "reason": "token must be a string or null"}
) )
except Exception: except _WS_SEND_BENIGN_EXC:
pass pass
return None return None
@@ -280,7 +335,7 @@ async def verify_ws_auth(
"reason": "LAN access requires an API key", "reason": "LAN access requires an API key",
} }
) )
except Exception: except _WS_SEND_BENIGN_EXC:
pass pass
return None return None
@@ -290,13 +345,13 @@ async def verify_ws_auth(
logger.warning("Invalid WebSocket auth attempt from %s", client_host) logger.warning("Invalid WebSocket auth attempt from %s", client_host)
try: try:
await websocket.send_json({"type": "auth_error", "reason": "invalid token"}) await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
except Exception: except _WS_SEND_BENIGN_EXC:
pass pass
return None return None
try: try:
await websocket.send_json({"type": "auth_ok"}) await websocket.send_json({"type": "auth_ok"})
except Exception: except _WS_SEND_BENIGN_EXC:
return None return None
logger.debug("WebSocket authenticated as: %s", label) logger.debug("WebSocket authenticated as: %s", label)
return label return label
+7
View File
@@ -37,6 +37,7 @@ from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.storage.mqtt_source_store import MQTTSourceStore from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.core.mqtt.mqtt_manager import MQTTManager from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.pattern_template_store import PatternTemplateStore from ledgrab.storage.pattern_template_store import PatternTemplateStore
@@ -165,6 +166,10 @@ def get_mqtt_manager() -> MQTTManager:
return _get("mqtt_manager", "MQTT manager") return _get("mqtt_manager", "MQTT manager")
def get_http_endpoint_store() -> HTTPEndpointStore:
return _get("http_endpoint_store", "HTTP endpoint store")
def get_audio_processing_template_store() -> AudioProcessingTemplateStore: def get_audio_processing_template_store() -> AudioProcessingTemplateStore:
return _get("audio_processing_template_store", "Audio processing template store") return _get("audio_processing_template_store", "Audio processing template store")
@@ -237,6 +242,7 @@ def init_dependencies(
game_event_bus: GameEventBus | None = None, game_event_bus: GameEventBus | None = None,
mqtt_store: MQTTSourceStore | None = None, mqtt_store: MQTTSourceStore | None = None,
mqtt_manager: MQTTManager | None = None, mqtt_manager: MQTTManager | None = None,
http_endpoint_store: HTTPEndpointStore | None = None,
audio_processing_template_store: AudioProcessingTemplateStore | None = None, audio_processing_template_store: AudioProcessingTemplateStore | None = None,
pattern_template_store: PatternTemplateStore | None = None, pattern_template_store: PatternTemplateStore | None = None,
): ):
@@ -272,6 +278,7 @@ def init_dependencies(
"game_event_bus": game_event_bus, "game_event_bus": game_event_bus,
"mqtt_store": mqtt_store, "mqtt_store": mqtt_store,
"mqtt_manager": mqtt_manager, "mqtt_manager": mqtt_manager,
"http_endpoint_store": http_endpoint_store,
"audio_processing_template_store": audio_processing_template_store, "audio_processing_template_store": audio_processing_template_store,
"pattern_template_store": pattern_template_store, "pattern_template_store": pattern_template_store,
} }
@@ -3,7 +3,7 @@
import asyncio import asyncio
import threading import threading
import time import time
from typing import Callable, Optional from typing import Callable
import numpy as np import numpy as np
from starlette.websockets import WebSocket from starlette.websockets import WebSocket
@@ -61,8 +61,8 @@ async def stream_capture_test(
websocket: WebSocket, websocket: WebSocket,
engine_factory: Callable, engine_factory: Callable,
duration: float, duration: float,
pp_filters: Optional[list] = None, pp_filters: list | None = None,
preview_width: Optional[int] = None, preview_width: int | None = None,
) -> None: ) -> None:
"""Run a capture test, streaming intermediate thumbnails and a final full-res frame. """Run a capture test, streaming intermediate thumbnails and a final full-res frame.
+7 -4
View File
@@ -15,7 +15,7 @@ from ledgrab.api.schemas.assets import (
from ledgrab.config import get_config from ledgrab.config import get_config
from ledgrab.storage.asset_store import AssetStore from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger from ledgrab.utils import get_logger, read_upload_capped
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -93,10 +93,11 @@ async def upload_asset(
config = get_config() config = get_config()
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024 max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
data = await file.read() try:
if len(data) > max_size: data = await read_upload_capped(file, max_size)
except ValueError:
raise HTTPException( raise HTTPException(
status_code=400, status_code=413,
detail=f"File too large (max {max_size // (1024 * 1024)} MB)", detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
) )
@@ -142,6 +143,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
@@ -1,7 +1,7 @@
"""Audio source routes: CRUD for audio sources + real-time test WebSocket.""" """Audio source routes: CRUD for audio sources + real-time test WebSocket."""
import asyncio import asyncio
from typing import Annotated, Optional from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from starlette.websockets import WebSocket, WebSocketDisconnect from starlette.websockets import WebSocket, WebSocketDisconnect
@@ -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),
@@ -85,7 +91,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"]) @router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
async def list_audio_sources( async def list_audio_sources(
_auth: AuthRequired, _auth: AuthRequired,
source_type: Optional[str] = Query( source_type: str | None = Query(
None, description="Filter by source_type: capture or processed" None, description="Filter by source_type: capture or processed"
), ),
store: AudioSourceStore = Depends(get_audio_source_store), store: AudioSourceStore = Depends(get_audio_source_store),
@@ -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))
@@ -23,6 +23,7 @@ from ledgrab.storage.automation import (
ApplicationRule, ApplicationRule,
DisplayStateRule, DisplayStateRule,
HomeAssistantRule, HomeAssistantRule,
HTTPPollRule,
MQTTRule, MQTTRule,
Rule, Rule,
StartupRule, StartupRule,
@@ -75,6 +76,11 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
state=s.state or "", state=s.state or "",
match_mode=s.match_mode or "exact", match_mode=s.match_mode or "exact",
), ),
"http_poll": lambda: HTTPPollRule(
value_source_id=s.value_source_id or "",
operator=s.operator or "equals",
value=s.value or "",
),
} }
factory = _SCHEMA_TO_RULE.get(s.rule_type) factory = _SCHEMA_TO_RULE.get(s.rule_type)
if factory is None: if factory is None:
@@ -122,6 +128,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 +199,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 +295,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
+54 -20
View File
@@ -11,6 +11,7 @@ import sys
import threading import threading
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -28,7 +29,7 @@ from ledgrab.config import get_config
from ledgrab.core.backup.auto_backup import AutoBackupEngine from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.asset_store import AssetStore from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.database import Database, freeze_writes from ledgrab.storage.database import Database, freeze_writes
from ledgrab.utils import get_logger from ledgrab.utils import get_logger, read_upload_capped
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -38,28 +39,59 @@ _SERVER_DIR = Path(__file__).resolve().parents[4]
def _schedule_restart() -> None: def _schedule_restart() -> None:
"""Spawn a restart script after a short delay so the HTTP response completes.""" """Spawn a restart script after a short delay so the HTTP response completes.
def _restart(): stdout/stderr of the spawned script are redirected to ``<server>/restart.log``
so a silent failure (PowerShell not on PATH, restart.ps1 erroring, etc.)
leaves evidence on disk instead of vanishing into a detached child.
"""
def _restart() -> None:
import time import time
time.sleep(1) time.sleep(1)
# Annotated as ``dict[str, Any]`` because the value union spans
# int flags (Windows ``creationflags``) and bool (POSIX
# ``start_new_session``); a narrower union confuses ``**`` unpacking.
popen_kwargs: dict[str, Any]
if sys.platform == "win32": if sys.platform == "win32":
subprocess.Popen( script = _SERVER_DIR / "restart.ps1"
[ cmd = ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(script)]
"powershell", popen_kwargs = {
"-ExecutionPolicy", "creationflags": (
"Bypass", subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
"-File", ),
str(_SERVER_DIR / "restart.ps1"), }
],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else: else:
subprocess.Popen( script = _SERVER_DIR / "restart.sh"
["bash", str(_SERVER_DIR / "restart.sh")], cmd = ["bash", str(script)]
start_new_session=True, popen_kwargs = {"start_new_session": True}
)
if not script.is_file():
logger.error("Restart script missing: %s", script)
return
log_path = _SERVER_DIR / "restart.log"
try:
# Open in append mode so multiple restarts accumulate; the child
# owns its own duped handle, so closing here in the parent is safe.
with open(log_path, "ab") as log_file:
log_file.write(
f"\n--- restart spawned at {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode()
)
log_file.flush()
proc = subprocess.Popen(
cmd,
stdout=log_file,
stderr=subprocess.STDOUT,
**popen_kwargs,
)
logger.info("Restart script launched: %s (PID %s, log %s)", cmd[0], proc.pid, log_path)
except OSError as e:
logger.error("Failed to launch restart script %s: %s", script, e, exc_info=True)
except Exception as e:
logger.error("Unexpected error launching restart script: %s", e, exc_info=True)
threading.Thread(target=_restart, daemon=True).start() threading.Thread(target=_restart, daemon=True).start()
@@ -133,9 +165,11 @@ async def restore_config(
because restore replaces all configuration including secrets). because restore replaces all configuration including secrets).
""" """
require_authenticated(auth) require_authenticated(auth)
raw = await file.read() _MAX_BACKUP_BYTES = 200 * 1024 * 1024 # 200 MB (ZIP may contain assets)
if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets) try:
raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)") raw = await read_upload_capped(file, _MAX_BACKUP_BYTES)
except ValueError:
raise HTTPException(status_code=413, detail="Backup file too large (max 200 MB)")
if len(raw) < 100: if len(raw) < 100:
raise HTTPException(status_code=400, detail="File too small to be a valid backup") raise HTTPException(status_code=400, detail="File too small to be a valid backup")
@@ -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,12 +4,12 @@ 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,
DaylightCSSResponse, DaylightCSSResponse,
EffectCSSResponse, EffectCSSResponse,
GameEventCSSResponse,
GradientCSSResponse, GradientCSSResponse,
KeyColorsCSSResponse, KeyColorsCSSResponse,
MappedCSSResponse, MappedCSSResponse,
@@ -18,7 +18,7 @@ from ledgrab.api.schemas.color_strip_sources import (
PictureAdvancedCSSResponse, PictureAdvancedCSSResponse,
PictureCSSResponse, PictureCSSResponse,
ProcessedCSSResponse, ProcessedCSSResponse,
StaticCSSResponse, SingleColorCSSResponse,
WeatherCSSResponse, WeatherCSSResponse,
) )
from ledgrab.api.schemas.devices import Calibration as CalibrationSchema from ledgrab.api.schemas.devices import Calibration as CalibrationSchema
@@ -27,23 +27,7 @@ from ledgrab.core.capture.calibration import (
calibration_to_dict, calibration_to_dict,
) )
from ledgrab.storage.color_strip_source import ( from ledgrab.storage.color_strip_source import (
AdvancedPictureColorStripSource, _SOURCE_TYPE_MAP as _STORAGE_TYPE_MAP,
ApiInputColorStripSource,
AudioColorStripSource,
CandlelightColorStripSource,
ColorCycleColorStripSource,
CompositeColorStripSource,
DaylightColorStripSource,
EffectColorStripSource,
GradientColorStripSource,
KeyColorsColorStripSource,
MappedColorStripSource,
MathWaveColorStripSource,
NotificationColorStripSource,
PictureColorStripSource,
ProcessedColorStripSource,
StaticColorStripSource,
WeatherColorStripSource,
) )
from ledgrab.storage.picture_source import ( from ledgrab.storage.picture_source import (
ProcessedPictureSource, ProcessedPictureSource,
@@ -67,6 +51,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 "",
) )
@@ -94,38 +80,46 @@ def _stops_schema(source) -> list[ColorStopSchema] | None:
return None return None
# Maps storage class → response builder lambda. # Maps ``source_type`` string → response builder.
#
# Keying by source_type (rather than type(source)) lets the import-time
# coverage check use the storage registry's keys directly, with no
# inversion or duplicate-class handling for legacy aliases.
_RESPONSE_MAP: dict = { _RESPONSE_MAP: dict = {
PictureColorStripSource: lambda s, kw: PictureCSSResponse( "picture": lambda s, kw: PictureCSSResponse(
**kw, **kw,
picture_source_id=s.picture_source_id, picture_source_id=s.picture_source_id,
smoothing=s.smoothing.to_dict(), smoothing=s.smoothing.to_dict(),
interpolation_mode=s.interpolation_mode, interpolation_mode=s.interpolation_mode,
calibration=_calibration_schema(s), calibration=_calibration_schema(s),
), ),
AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse( "picture_advanced": lambda s, kw: PictureAdvancedCSSResponse(
**kw, **kw,
smoothing=s.smoothing.to_dict(), smoothing=s.smoothing.to_dict(),
interpolation_mode=s.interpolation_mode, interpolation_mode=s.interpolation_mode,
calibration=_calibration_schema(s), calibration=_calibration_schema(s),
), ),
StaticColorStripSource: lambda s, kw: StaticCSSResponse( "single_color": lambda s, kw: SingleColorCSSResponse(
**kw, **kw,
color=s.color.to_dict(), color=s.color.to_dict(),
animation=s.animation, animation=s.animation,
), ),
GradientColorStripSource: lambda s, kw: GradientCSSResponse( # Legacy alias: pre-rename rows used "static"; the data migration rewrites
# them on first store load but a stale in-flight instance would still
# carry source_type='static' until the next reload.
"static": lambda s, kw: SingleColorCSSResponse(
**kw,
color=s.color.to_dict(),
animation=s.animation,
),
"gradient": lambda s, kw: GradientCSSResponse(
**kw, **kw,
stops=_stops_schema(s), stops=_stops_schema(s),
animation=s.animation, animation=s.animation,
easing=s.easing, easing=s.easing,
gradient_id=s.gradient_id, gradient_id=s.gradient_id,
), ),
ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse( "effect": lambda s, kw: EffectCSSResponse(
**kw,
colors=[list(c) for c in s.colors],
),
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
**kw, **kw,
effect_type=s.effect_type, effect_type=s.effect_type,
palette=s.palette, palette=s.palette,
@@ -136,15 +130,15 @@ _RESPONSE_MAP: dict = {
mirror=s.mirror, mirror=s.mirror,
custom_palette=s.custom_palette, custom_palette=s.custom_palette,
), ),
CompositeColorStripSource: lambda s, kw: CompositeCSSResponse( "composite": lambda s, kw: CompositeCSSResponse(
**kw, **kw,
layers=[dict(layer) for layer in s.layers], layers=[dict(layer) for layer in s.layers],
), ),
MappedColorStripSource: lambda s, kw: MappedCSSResponse( "mapped": lambda s, kw: MappedCSSResponse(
**kw, **kw,
zones=[dict(z) for z in s.zones], zones=[dict(z) for z in s.zones],
), ),
AudioColorStripSource: lambda s, kw: AudioCSSResponse( "audio": lambda s, kw: AudioCSSResponse(
**kw, **kw,
visualization_mode=s.visualization_mode, visualization_mode=s.visualization_mode,
audio_source_id=s.audio_source_id, audio_source_id=s.audio_source_id,
@@ -157,13 +151,13 @@ _RESPONSE_MAP: dict = {
mirror=s.mirror, mirror=s.mirror,
beat_decay=s.beat_decay.to_dict(), beat_decay=s.beat_decay.to_dict(),
), ),
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse( "api_input": lambda s, kw: ApiInputCSSResponse(
**kw, **kw,
fallback_color=s.fallback_color.to_dict(), fallback_color=s.fallback_color.to_dict(),
timeout=s.timeout.to_dict(), timeout=s.timeout.to_dict(),
interpolation=s.interpolation, interpolation=s.interpolation,
), ),
NotificationColorStripSource: lambda s, kw: NotificationCSSResponse( "notification": lambda s, kw: NotificationCSSResponse(
**kw, **kw,
notification_effect=s.notification_effect, notification_effect=s.notification_effect,
duration_ms=s.duration_ms.to_dict(), duration_ms=s.duration_ms.to_dict(),
@@ -176,14 +170,14 @@ _RESPONSE_MAP: dict = {
sound_volume=s.sound_volume.to_dict(), sound_volume=s.sound_volume.to_dict(),
app_sounds=dict(s.app_sounds), app_sounds=dict(s.app_sounds),
), ),
DaylightColorStripSource: lambda s, kw: DaylightCSSResponse( "daylight": lambda s, kw: DaylightCSSResponse(
**kw, **kw,
speed=s.speed.to_dict(), speed=s.speed.to_dict(),
use_real_time=s.use_real_time, use_real_time=s.use_real_time,
latitude=s.latitude, latitude=s.latitude,
longitude=s.longitude, longitude=s.longitude,
), ),
CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse( "candlelight": lambda s, kw: CandlelightCSSResponse(
**kw, **kw,
color=s.color.to_dict(), color=s.color.to_dict(),
intensity=s.intensity.to_dict(), intensity=s.intensity.to_dict(),
@@ -192,18 +186,18 @@ _RESPONSE_MAP: dict = {
wind_strength=s.wind_strength.to_dict(), wind_strength=s.wind_strength.to_dict(),
candle_type=s.candle_type, candle_type=s.candle_type,
), ),
ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse( "processed": lambda s, kw: ProcessedCSSResponse(
**kw, **kw,
input_source_id=s.input_source_id, input_source_id=s.input_source_id,
processing_template_id=s.processing_template_id, processing_template_id=s.processing_template_id,
), ),
WeatherColorStripSource: lambda s, kw: WeatherCSSResponse( "weather": lambda s, kw: WeatherCSSResponse(
**kw, **kw,
weather_source_id=s.weather_source_id, weather_source_id=s.weather_source_id,
speed=s.speed.to_dict(), speed=s.speed.to_dict(),
temperature_influence=s.temperature_influence.to_dict(), temperature_influence=s.temperature_influence.to_dict(),
), ),
KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse( "key_colors": lambda s, kw: KeyColorsCSSResponse(
**kw, **kw,
picture_source_id=s.picture_source_id, picture_source_id=s.picture_source_id,
rectangles=[r.to_dict() for r in s.rectangles], rectangles=[r.to_dict() for r in s.rectangles],
@@ -211,28 +205,67 @@ _RESPONSE_MAP: dict = {
smoothing=s.smoothing.to_dict(), smoothing=s.smoothing.to_dict(),
brightness=s.brightness.to_dict(), brightness=s.brightness.to_dict(),
), ),
MathWaveColorStripSource: lambda s, kw: MathWaveCSSResponse( "math_wave": lambda s, kw: MathWaveCSSResponse(
**kw, **kw,
waves=s.waves, waves=s.waves,
speed=s.speed.to_dict(), speed=s.speed.to_dict(),
gradient_id=s.gradient_id, gradient_id=s.gradient_id,
), ),
"game_event": lambda s, kw: GameEventCSSResponse(
**kw,
game_integration_id=s.game_integration_id,
idle_color=s.idle_color.to_dict(),
event_mappings=[dict(m) for m in s.event_mappings],
),
} }
def _assert_response_map_coverage() -> None:
"""Verify _RESPONSE_MAP has a builder for every kind in storage's registry.
Runs at module import. Surfaces missing builders eagerly instead of
letting a request fall through to a silent / wrong response shape.
Contract note
-------------
This check is **symmetric** (``_RESPONSE_MAP keys == storage_kinds``)
because every kind sharable or not needs a response shape. The
sister assertion in
``core/processing/color_strip_kinds.py::_assert_stream_kind_coverage``
is asymmetric because sharable kinds construct their streams via a
different path. Adding a new kind requires keeping all three registries
aligned: storage's ``_SOURCE_TYPE_MAP``, this ``_RESPONSE_MAP``, and
either ``STREAM_BUILDERS`` or ``SHARABLE_KINDS``.
"""
storage_kinds = set(_STORAGE_TYPE_MAP.keys())
builder_kinds = set(_RESPONSE_MAP.keys())
missing = storage_kinds - builder_kinds
extra = builder_kinds - storage_kinds
if missing or extra:
problems = []
if missing:
problems.append(f"missing builders for: {sorted(missing)}")
if extra:
problems.append(f"unregistered kinds in _RESPONSE_MAP: {sorted(extra)}")
raise RuntimeError(
"_RESPONSE_MAP is out of sync with storage._SOURCE_TYPE_MAP: " + "; ".join(problems)
)
_assert_response_map_coverage()
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse: def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
"""Convert a ColorStripSource to the matching per-type response schema.""" """Convert a ColorStripSource to the matching per-type response schema."""
kw = _common_response_kwargs(source, overlay_active) kw = _common_response_kwargs(source, overlay_active)
builder = _RESPONSE_MAP.get(type(source)) builder = _RESPONSE_MAP.get(source.source_type)
if builder is None: if builder is None:
# Fallback: use to_dict() and build a PictureCSSResponse # Coverage is asserted at import time, so reaching this branch means a
logger.warning("No response builder for %s, falling back", type(source).__name__) # source was loaded with a source_type that is not registered.
return PictureCSSResponse( # Surface the bug instead of silently returning a wrong-shaped response.
**kw, raise RuntimeError(
picture_source_id="", f"No CSS response builder registered for source_type "
smoothing=0.3, f"{source.source_type!r} (class={type(source).__name__})"
interpolation_mode="average",
calibration=None,
) )
return builder(source, kw) return builder(source, kw)
@@ -29,13 +29,20 @@ router = APIRouter()
_PREVIEW_ALLOWED_TYPES = { _PREVIEW_ALLOWED_TYPES = {
"static", "single_color",
"gradient", "gradient",
"color_cycle",
"effect", "effect",
"daylight", "daylight",
"candlelight", "candlelight",
"notification", "notification",
"audio",
"math_wave",
"weather",
"game_event",
"api_input",
"mapped",
"composite",
"processed",
} }
@@ -90,13 +97,65 @@ async def preview_color_strip_ws(
return ColorStripSource.from_dict(config) return ColorStripSource.from_dict(config)
def _create_stream(source): def _create_stream(source):
"""Instantiate and start the appropriate stream class for *source*.""" """Instantiate and start the appropriate stream class for *source*.
from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) Delegates the per-kind dispatch to ``color_strip_kinds.build_stream``
if not stream_cls: so this preview path and the production ``ColorStripStreamManager``
raise ValueError(f"Unsupported preview source_type: {source.source_type}") share a single registry. Per-kind dependencies (CSPT store, audio
s = stream_cls(source) stores, weather manager, ) are gathered into a ``StreamDeps`` bag.
FastAPI-DI providers raise ``RuntimeError`` when they aren't wired,
so we resolve each one through ``_safe`` and pass ``None`` on
failure. The per-kind builder will still see a clear error if a
truly-required dep is missing for that kind, but unrelated previews
(e.g. a ``single_color`` preview on a fresh install where the CSPT
store isn't initialized yet) keep working.
"""
from ledgrab.api.dependencies import (
get_audio_processing_template_store,
get_audio_source_store,
get_audio_template_store,
get_cspt_store,
)
from ledgrab.core.processing.color_strip_kinds import StreamDeps, build_stream
def _safe(getter):
try:
return getter()
except RuntimeError as e:
logger.debug("Preview dep not available (%s): %s", getter.__name__, e)
return None
mgr = get_processor_manager()
csm = mgr.color_strip_stream_manager
# The game-event bus is optional in preview contexts.
try:
from ledgrab.api.dependencies import get_game_event_bus
game_event_bus = get_game_event_bus()
except RuntimeError as e:
logger.debug("Preview: no game event bus available: %s", e)
game_event_bus = None
deps = StreamDeps(
css_manager=csm,
value_stream_manager=mgr.value_stream_manager,
cspt_store=_safe(get_cspt_store),
weather_manager=mgr.weather_manager,
audio_capture_manager=mgr.audio_capture_manager,
audio_source_store=_safe(get_audio_source_store),
audio_template_store=_safe(get_audio_template_store),
audio_processing_template_store=_safe(get_audio_processing_template_store),
game_event_bus=game_event_bus,
depth=0,
)
try:
s = build_stream(source, deps)
except ValueError as e:
# Preserve the registry's original detail so the API consumer
# sees which kind was rejected, not just a generic message.
raise ValueError(f"Unsupported preview source_type: {e}") from e
# Inject gradient store for palette resolution # Inject gradient store for palette resolution
if hasattr(s, "set_gradient_store"): if hasattr(s, "set_gradient_store"):
try: try:
@@ -122,7 +181,24 @@ async def preview_color_strip_ws(
cid = None cid = None
else: else:
cid = None cid = None
s.start() # Start the stream; if start() raises, release any resources we
# already acquired (clock + anything the stream itself grabbed in
# its __init__) so we don't leak refs across failed previews.
try:
s.start()
except Exception:
try:
s.stop()
except Exception as e_stop:
logger.exception("unexpected in start-failure rollback s.stop: %s", e_stop)
if cid:
scm = _get_sync_clock_manager()
if scm:
try:
scm.release(cid)
except Exception as e_rel:
logger.exception("unexpected in start-failure clock release: %s", e_rel)
raise
return s, cid return s, cid
def _stop_stream(s, cid): def _stop_stream(s, cid):
@@ -223,10 +299,24 @@ async def preview_color_strip_ws(
continue continue
new_source = _build_source(new_config) new_source = _build_source(new_config)
if new_type != current_source_type: if new_type != current_source_type:
# Source type changed — recreate stream # Source type changed — stop the old stream first, then
# build the new one. If the rebuild fails, drop the
# reference so the frame loop doesn't keep polling a
# stopped stream and the finally-block doesn't double-stop.
_stop_stream(stream, clock_id) _stop_stream(stream, clock_id)
stream, clock_id = _create_stream(new_source) stream, clock_id = None, None
current_source_type = new_type try:
stream, clock_id = _create_stream(new_source)
current_source_type = new_type
except Exception as rebuild_err:
logger.error(
f"Preview WS: failed to rebuild stream for new type {new_type}: {rebuild_err}"
)
await websocket.send_text(
_json.dumps({"type": "error", "detail": str(rebuild_err)})
)
await websocket.close(code=4003, reason=str(rebuild_err))
return
else: else:
stream.update_source(new_source) stream.update_source(new_source)
if hasattr(stream, "configure"): if hasattr(stream, "configure"):
@@ -237,12 +327,15 @@ async def preview_color_strip_ws(
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)})) await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
# Send frame # Send frame
colors = stream.get_latest_colors() if stream is None:
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else:
# Stream hasn't produced a frame yet — send black
await websocket.send_bytes(b"\x00" * led_count * 3) await websocket.send_bytes(b"\x00" * led_count * 3)
else:
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else:
# Stream hasn't produced a frame yet — send black
await websocket.send_bytes(b"\x00" * led_count * 3)
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
@@ -335,8 +428,17 @@ async def css_api_input_ws(
continue continue
elif "bytes" in message: elif "bytes" in message:
# Binary frame: raw RGBRGB... bytes (3 bytes per LED) # Binary frame: raw RGBRGB... bytes (3 bytes per LED).
# Cap to a generous upper bound on the LED count — a hostile
# client could otherwise stream 100 MB frames and OOM the
# server before any application logic ran.
raw_bytes = message["bytes"] raw_bytes = message["bytes"]
_MAX_BINARY_LEDS = 8192
if len(raw_bytes) > _MAX_BINARY_LEDS * 3:
await websocket.send_json(
{"error": f"Binary frame too large (max {_MAX_BINARY_LEDS} LEDs)"}
)
continue
if len(raw_bytes) % 3 != 0: if len(raw_bytes) % 3 != 0:
await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"}) await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"})
continue continue
@@ -476,13 +578,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
+222 -17
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
from ledgrab.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from ledgrab.core.devices.led_client import ( from ledgrab.core.devices.led_client import (
PairingNotReady,
get_all_providers, get_all_providers,
get_device_capabilities, get_device_capabilities,
get_provider, get_provider,
@@ -26,18 +27,45 @@ from ledgrab.api.schemas.devices import (
DiscoverDevicesResponse, DiscoverDevicesResponse,
OpenRGBZoneResponse, OpenRGBZoneResponse,
OpenRGBZonesResponse, OpenRGBZonesResponse,
PairDeviceRequest,
PairDeviceResponse,
PowerRequest, PowerRequest,
) )
from ledgrab.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore from ledgrab.storage import DeviceStore
from ledgrab.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.url_scheme import infer_http_scheme
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
def _sanitize_url_for_log(url: str) -> str:
"""Strip userinfo + fragment from a URL so secrets don't reach logs.
The pair endpoint receives a user-supplied URL on every call; if a
future driver ever accepts ``scheme://user:pass@host`` form the
credentials would land in logs without this guard.
"""
if not url:
return ""
try:
from urllib.parse import urlparse, urlunparse
parsed = urlparse(url)
# urlparse stores userinfo in `netloc`; rebuild without it.
if parsed.hostname:
netloc = parsed.hostname
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
return urlunparse((parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, ""))
except ValueError:
pass
return url
def _device_to_response(device) -> DeviceResponse: def _device_to_response(device) -> DeviceResponse:
"""Convert a Device to DeviceResponse.""" """Convert a Device to DeviceResponse."""
return DeviceResponse( return DeviceResponse(
@@ -57,11 +85,20 @@ def _device_to_response(device) -> DeviceResponse:
dmx_protocol=device.dmx_protocol, dmx_protocol=device.dmx_protocol,
dmx_start_universe=device.dmx_start_universe, dmx_start_universe=device.dmx_start_universe,
dmx_start_channel=device.dmx_start_channel, dmx_start_channel=device.dmx_start_channel,
ddp_port=device.ddp_port,
ddp_destination_id=device.ddp_destination_id,
ddp_color_order=device.ddp_color_order,
espnow_peer_mac=device.espnow_peer_mac, espnow_peer_mac=device.espnow_peer_mac,
espnow_channel=device.espnow_channel, espnow_channel=device.espnow_channel,
hue_username=device.hue_username, hue_paired=bool(device.hue_username and device.hue_client_key),
hue_client_key=device.hue_client_key,
hue_entertainment_group_id=device.hue_entertainment_group_id, hue_entertainment_group_id=device.hue_entertainment_group_id,
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
wiz_min_interval_ms=device.wiz_min_interval_ms,
lifx_min_interval_ms=device.lifx_min_interval_ms,
govee_min_interval_ms=device.govee_min_interval_ms,
opc_channel=device.opc_channel,
nanoleaf_paired=bool(device.nanoleaf_token),
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
spi_speed_hz=device.spi_speed_hz, spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type, spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type, chroma_device_type=device.chroma_device_type,
@@ -71,6 +108,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,
) )
@@ -132,6 +171,8 @@ async def create_device(
detail="URL is required for non-group device types.", detail="URL is required for non-group device types.",
) )
device_url = device_data.url.rstrip("/") device_url = device_data.url.rstrip("/")
if device_type == "wled":
device_url = infer_http_scheme(device_url)
# ── Non-group: validate via provider ── # ── Non-group: validate via provider ──
if device_type != "group": if device_type != "group":
@@ -166,9 +207,19 @@ async def create_device(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
# Don't leak the raw exception text — it can carry stack
# frames, host headers, or other internals that aren't safe
# to echo. Log with full context, return a generic message.
logger.warning(
"Failed to validate %s device at %s: %s",
device_type,
device_url,
e,
exc_info=True,
)
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=f"Failed to connect to {device_type} device at {device_url}: {e}", detail=f"Failed to connect to {device_type} device at {device_url}.",
) )
# Resolve auto_shutdown default: False for all types # Resolve auto_shutdown default: False for all types
@@ -179,7 +230,7 @@ async def create_device(
# Create device in storage # Create device in storage
device = store.create_device( device = store.create_device(
name=device_data.name, name=device_data.name,
url=device_data.url, url=device_url,
led_count=led_count, led_count=led_count,
device_type=device_type, device_type=device_type,
baud_rate=device_data.baud_rate, baud_rate=device_data.baud_rate,
@@ -191,11 +242,45 @@ async def create_device(
dmx_protocol=device_data.dmx_protocol or "artnet", dmx_protocol=device_data.dmx_protocol or "artnet",
dmx_start_universe=device_data.dmx_start_universe or 0, dmx_start_universe=device_data.dmx_start_universe or 0,
dmx_start_channel=device_data.dmx_start_channel or 1, dmx_start_channel=device_data.dmx_start_channel or 1,
ddp_port=device_data.ddp_port or 0,
ddp_destination_id=(
device_data.ddp_destination_id if device_data.ddp_destination_id is not None else 1
),
ddp_color_order=(
device_data.ddp_color_order if device_data.ddp_color_order is not None else 1
),
espnow_peer_mac=device_data.espnow_peer_mac or "", espnow_peer_mac=device_data.espnow_peer_mac or "",
espnow_channel=device_data.espnow_channel or 1, espnow_channel=device_data.espnow_channel or 1,
hue_username=device_data.hue_username or "", hue_username=device_data.hue_username or "",
hue_client_key=device_data.hue_client_key or "", hue_client_key=device_data.hue_client_key or "",
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "", hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
yeelight_min_interval_ms=(
device_data.yeelight_min_interval_ms
if device_data.yeelight_min_interval_ms is not None
else 500
),
wiz_min_interval_ms=(
device_data.wiz_min_interval_ms
if device_data.wiz_min_interval_ms is not None
else 50
),
lifx_min_interval_ms=(
device_data.lifx_min_interval_ms
if device_data.lifx_min_interval_ms is not None
else 50
),
govee_min_interval_ms=(
device_data.govee_min_interval_ms
if device_data.govee_min_interval_ms is not None
else 50
),
opc_channel=(device_data.opc_channel if device_data.opc_channel is not None else 0),
nanoleaf_token=device_data.nanoleaf_token or "",
nanoleaf_min_interval_ms=(
device_data.nanoleaf_min_interval_ms
if device_data.nanoleaf_min_interval_ms is not None
else 100
),
spi_speed_hz=device_data.spi_speed_hz or 800000, spi_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B", spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink", chroma_device_type=device_data.chroma_device_type or "chromalink",
@@ -231,6 +316,79 @@ async def create_device(
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
@router.post(
"/api/v1/devices/pair",
response_model=PairDeviceResponse,
tags=["Devices"],
)
async def pair_device(
body: PairDeviceRequest,
_auth: AuthRequired,
):
"""Run a pairing handshake against a device before creating it.
The frontend opens this endpoint after the user has performed the
device's physical pairing action (e.g. held the power button for 5s).
The response carries provider-specific fields the caller must include
in the subsequent ``POST /api/v1/devices`` body.
Status codes:
200 paired fields returned
400 unknown device type, or device type does not support pairing
409 device not ready user must perform the physical action
(or retry, e.g. the pairing window timed out)
422 invalid URL or device configuration
"""
try:
provider = get_provider(body.device_type)
except ValueError:
raise HTTPException(status_code=400, detail=f"Unknown device type: {body.device_type}")
try:
fields = await provider.pair_device(body.url)
except NotImplementedError:
raise HTTPException(
status_code=400,
detail=f"Device type {body.device_type!r} does not support pairing",
)
except PairingNotReady as exc:
raise HTTPException(status_code=409, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except Exception as exc:
# Strip userinfo before logging so a `scheme://user:pass@host` URL
# never lands in the logs (no shipped driver uses userinfo today,
# but the pattern is a foot-gun for the next driver author --
# caught by review MEDIUM #9). Also keep exc_info=False so a
# provider stack trace that may include response bytes from a
# hostile receiver doesn't end up in the file either.
safe_url = _sanitize_url_for_log(body.url)
logger.warning(
"Pairing failed for %s at %s: %s: %s",
body.device_type,
safe_url,
type(exc).__name__,
exc,
)
raise HTTPException(
status_code=502,
detail=f"Pairing failed for {body.device_type} at {safe_url}.",
)
if not isinstance(fields, dict):
logger.warning(
"Provider %s.pair_device returned %r (expected dict)",
body.device_type,
type(fields).__name__,
)
raise HTTPException(
status_code=500,
detail=f"Provider {body.device_type!r} returned malformed pairing result",
)
return PairDeviceResponse(fields=fields)
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"]) @router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
async def list_devices( async def list_devices(
_auth: AuthRequired, _auth: AuthRequired,
@@ -264,11 +422,20 @@ async def discover_devices(
raise HTTPException(status_code=400, detail=f"Unknown device type: {device_type}") raise HTTPException(status_code=400, detail=f"Unknown device type: {device_type}")
discovered = await provider.discover(timeout=capped_timeout) discovered = await provider.discover(timeout=capped_timeout)
else: else:
# Discover from all providers in parallel # Discover from all providers in parallel. Discovery is best-effort:
# one provider failing (firewall, missing dep, mDNS race) must not
# take the entire scan down, so collect exceptions instead of
# raising and log them individually.
providers = get_all_providers() providers = get_all_providers()
discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()] provider_items = list(providers.items())
all_results = await asyncio.gather(*discover_tasks) discover_tasks = [p.discover(timeout=capped_timeout) for _, p in provider_items]
discovered = [d for batch in all_results for d in batch] all_results = await asyncio.gather(*discover_tasks, return_exceptions=True)
discovered = []
for (name, _), result in zip(provider_items, all_results):
if isinstance(result, BaseException):
logger.warning("Discovery failed for provider %s: %s", name, result)
continue
discovered.extend(result)
elapsed_ms = (time.time() - start) * 1000 elapsed_ms = (time.time() - start) * 1000
existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()} existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()}
@@ -383,6 +550,26 @@ async def update_device(
existing = store.get_device(device_id) existing = store.get_device(device_id)
is_group = existing.device_type == "group" is_group = existing.device_type == "group"
# Normalize URL the same way we do on create:
# * always rstrip trailing slashes (so PUT-with-trailing-/ matches
# POST-with-trailing-/ in the stored value -- caught by review HIGH #6)
# * only WLED gets http/https scheme inference; other schemes
# (yeelight://, lifx://, opc://, ddp://, …) pass through.
# Done via a local rather than mutating the request DTO so the
# input is preserved for any future caller that inspects it.
normalized_url = update_data.url
if update_data.url:
normalized_url = update_data.url.rstrip("/")
if existing.device_type == "wled":
inferred = infer_http_scheme(normalized_url)
if inferred != normalized_url:
logger.debug("Inferred WLED URL scheme: %r -> %r", normalized_url, inferred)
normalized_url = inferred
# Group-only field overrides (led_count auto-recompute) are accumulated
# here too so the update_data Pydantic model is not mutated in place.
normalized_led_count = update_data.led_count
if is_group: if is_group:
new_children = update_data.group_device_ids new_children = update_data.group_device_ids
new_mode = update_data.group_mode or existing.group_mode new_mode = update_data.group_mode or existing.group_mode
@@ -403,20 +590,20 @@ async def update_device(
# Auto-recompute led_count for sequence mode # Auto-recompute led_count for sequence mode
if effective_mode == "sequence": if effective_mode == "sequence":
update_data.led_count = store.resolve_group_led_count(effective_children) normalized_led_count = store.resolve_group_led_count(effective_children)
elif ( elif (
update_data.led_count is None normalized_led_count is None
and new_mode == "independent" and new_mode == "independent"
and new_children is not None and new_children is not None
): ):
update_data.led_count = store.resolve_group_max_led_count(effective_children) normalized_led_count = store.resolve_group_max_led_count(effective_children)
device = store.update_device( device = store.update_device(
device_id=device_id, device_id=device_id,
name=update_data.name, name=update_data.name,
url=update_data.url, url=normalized_url,
enabled=update_data.enabled, enabled=update_data.enabled,
led_count=update_data.led_count, led_count=normalized_led_count,
baud_rate=update_data.baud_rate, baud_rate=update_data.baud_rate,
auto_shutdown=update_data.auto_shutdown, auto_shutdown=update_data.auto_shutdown,
send_latency_ms=update_data.send_latency_ms, send_latency_ms=update_data.send_latency_ms,
@@ -426,11 +613,21 @@ async def update_device(
dmx_protocol=update_data.dmx_protocol, dmx_protocol=update_data.dmx_protocol,
dmx_start_universe=update_data.dmx_start_universe, dmx_start_universe=update_data.dmx_start_universe,
dmx_start_channel=update_data.dmx_start_channel, dmx_start_channel=update_data.dmx_start_channel,
ddp_port=update_data.ddp_port,
ddp_destination_id=update_data.ddp_destination_id,
ddp_color_order=update_data.ddp_color_order,
espnow_peer_mac=update_data.espnow_peer_mac, espnow_peer_mac=update_data.espnow_peer_mac,
espnow_channel=update_data.espnow_channel, espnow_channel=update_data.espnow_channel,
hue_username=update_data.hue_username, hue_username=update_data.hue_username,
hue_client_key=update_data.hue_client_key, hue_client_key=update_data.hue_client_key,
hue_entertainment_group_id=update_data.hue_entertainment_group_id, hue_entertainment_group_id=update_data.hue_entertainment_group_id,
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
govee_min_interval_ms=update_data.govee_min_interval_ms,
opc_channel=update_data.opc_channel,
nanoleaf_token=update_data.nanoleaf_token,
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
spi_speed_hz=update_data.spi_speed_hz, spi_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type, spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type, chroma_device_type=update_data.chroma_device_type,
@@ -439,19 +636,27 @@ 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.
#
# When a PATCH omits `url` (rename / icon-only edit) `normalized_url`
# is None — fall back to the existing record's URL so the processor
# is always told the current address, otherwise it silently keeps
# whatever it had cached (or worse, treats None as "unconfigured"
# and refuses to re-sync).
effective_url = normalized_url if normalized_url is not None else existing.url
try: try:
manager.update_device_info( manager.update_device_info(
device_id, device_id,
device_url=update_data.url, device_url=effective_url,
led_count=update_data.led_count, led_count=normalized_led_count,
baud_rate=update_data.baud_rate, baud_rate=update_data.baud_rate,
) )
except ValueError as e: except ValueError as e:
logger.debug("Processor manager device update skipped for %s: %s", device_id, e) logger.debug("Processor manager device update skipped for %s: %s", device_id, e)
pass
# Sync auto_shutdown and zone_mode in runtime state # Sync auto_shutdown and zone_mode in runtime state
ds = manager.find_device_state(device_id) ds = manager.find_device_state(device_id)
@@ -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 "",
) )
) )
@@ -0,0 +1,270 @@
"""HTTP endpoint routes: CRUD + one-shot test."""
import json
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_http_endpoint_store,
)
from ledgrab.api.schemas.http_endpoints import (
HTTPEndpointCreate,
HTTPEndpointListResponse,
HTTPEndpointResponse,
HTTPEndpointUpdate,
HTTPTestRequest,
HTTPTestResponse,
)
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.http_endpoint import HTTPEndpoint
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.utils import get_logger
from ledgrab.utils.safe_source import safe_request_bounded, validate_polling_url
logger = get_logger(__name__)
router = APIRouter()
def _warn_if_plaintext_token(url: str, auth_token: str, *, action: str) -> None:
"""Log a warning when an auth token would be sent over plaintext http://."""
if auth_token and url.lower().startswith("http://"):
logger.warning(
"HTTP endpoint %s: auth_token will be sent over plaintext http:// to %s. "
"Anyone on the network path can read it. Consider https:// if the "
"target supports TLS.",
action,
url,
)
def _to_response(endpoint: HTTPEndpoint) -> HTTPEndpointResponse:
return HTTPEndpointResponse(
id=endpoint.id,
name=endpoint.name,
url=endpoint.url,
method=endpoint.method,
auth_token_set=bool(endpoint.auth_token),
headers=dict(endpoint.headers),
timeout_s=endpoint.timeout_s,
description=endpoint.description,
tags=endpoint.tags,
icon=getattr(endpoint, "icon", "") or "",
icon_color=getattr(endpoint, "icon_color", "") or "",
created_at=endpoint.created_at,
updated_at=endpoint.updated_at,
)
@router.get(
"/api/v1/http/endpoints",
response_model=HTTPEndpointListResponse,
tags=["HTTP"],
)
async def list_http_endpoints(
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
endpoints = store.get_all_endpoints()
return HTTPEndpointListResponse(
endpoints=[_to_response(e) for e in endpoints],
count=len(endpoints),
)
@router.post(
"/api/v1/http/endpoints",
response_model=HTTPEndpointResponse,
status_code=201,
tags=["HTTP"],
)
async def create_http_endpoint(
data: HTTPEndpointCreate,
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
validate_polling_url(data.url)
_warn_if_plaintext_token(data.url, data.auth_token, action="create")
try:
endpoint = store.create_endpoint(
name=data.name,
url=data.url,
method=data.method,
auth_token=data.auth_token,
headers=data.headers,
timeout_s=data.timeout_s,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("http_endpoint", "created", endpoint.id)
return _to_response(endpoint)
@router.get(
"/api/v1/http/endpoints/{endpoint_id}",
response_model=HTTPEndpointResponse,
tags=["HTTP"],
)
async def get_http_endpoint(
endpoint_id: str,
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
try:
endpoint = store.get_endpoint(endpoint_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
return _to_response(endpoint)
@router.put(
"/api/v1/http/endpoints/{endpoint_id}",
response_model=HTTPEndpointResponse,
tags=["HTTP"],
)
async def update_http_endpoint(
endpoint_id: str,
data: HTTPEndpointUpdate,
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
if data.url is not None:
validate_polling_url(data.url)
final_url = data.url
final_token = data.auth_token
if final_url is None or final_token is None:
try:
existing = store.get_endpoint(endpoint_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
if final_url is None:
final_url = existing.url
if final_token is None:
final_token = existing.auth_token
_warn_if_plaintext_token(final_url, final_token, action="update")
try:
endpoint = store.update_endpoint(
endpoint_id,
name=data.name,
url=data.url,
method=data.method,
auth_token=data.auth_token,
headers=data.headers,
timeout_s=data.timeout_s,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("http_endpoint", "updated", endpoint.id)
return _to_response(endpoint)
@router.delete(
"/api/v1/http/endpoints/{endpoint_id}",
status_code=204,
tags=["HTTP"],
)
async def delete_http_endpoint(
endpoint_id: str,
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
try:
store.delete_endpoint(endpoint_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
fire_entity_event("http_endpoint", "deleted", endpoint_id)
async def _run_http_test(
method: str,
url: str,
headers: dict[str, str],
timeout_s: float,
) -> HTTPTestResponse:
"""Shared one-shot fetch + response shaping for both test endpoints."""
try:
status, body_bytes, error = await safe_request_bounded(
method, url, headers=headers, timeout=timeout_s
)
except HTTPException:
raise
except Exception as exc:
return HTTPTestResponse(success=False, error=f"Unexpected error: {type(exc).__name__}")
if error and status == 0:
return HTTPTestResponse(success=False, error=error)
try:
body_text = body_bytes.decode("utf-8")
except UnicodeDecodeError:
body_text = body_bytes.decode("utf-8", errors="replace")
try:
body_json = json.loads(body_text) if body_text else None
except (json.JSONDecodeError, ValueError):
body_json = None
preview = body_text[:500] if body_text else None
is_success = 200 <= status < 300
return HTTPTestResponse(
success=is_success,
status_code=status,
body_preview=preview,
body_json=body_json,
error=None if is_success else f"HTTP {status}",
)
@router.post(
"/api/v1/http/endpoints/test",
response_model=HTTPTestResponse,
tags=["HTTP"],
)
async def test_http_endpoint(
data: HTTPTestRequest,
_auth: AuthRequired,
):
"""One-shot fetch to validate URL + auth before saving."""
headers = dict(data.headers)
if data.auth_token and not any(k.lower() == "authorization" for k in headers):
headers["Authorization"] = f"Bearer {data.auth_token}"
return await _run_http_test(data.method, data.url, headers, data.timeout_s)
@router.post(
"/api/v1/http/endpoints/{endpoint_id}/test",
response_model=HTTPTestResponse,
tags=["HTTP"],
)
async def test_saved_http_endpoint(
endpoint_id: str,
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
"""Run the stored endpoint configuration (URL + auth + headers + timeout).
Useful for the "test" button on the endpoint card: avoids the user
having to open the editor and re-enter the auth token (which is
never returned to the client).
"""
try:
endpoint = store.get_endpoint(endpoint_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
return await _run_http_test(
endpoint.method,
endpoint.url,
endpoint.build_request_headers(),
endpoint.timeout_s,
)
+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")
+398 -128
View File
@@ -9,17 +9,27 @@ from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_device_store, get_device_store,
get_mqtt_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,
HALightOutputTargetCreate,
HALightOutputTargetResponse, HALightOutputTargetResponse,
HALightOutputTargetUpdate,
LedOutputTargetCreate,
LedOutputTargetResponse, LedOutputTargetResponse,
LedOutputTargetUpdate,
OutputTargetCreate, OutputTargetCreate,
OutputTargetListResponse, OutputTargetListResponse,
OutputTargetResponse, OutputTargetResponse,
OutputTargetUpdate, OutputTargetUpdate,
Z2MLightMappingSchema,
Z2MLightOutputTargetCreate,
Z2MLightOutputTargetResponse,
Z2MLightOutputTargetUpdate,
) )
from ledgrab.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore from ledgrab.storage import DeviceStore
@@ -29,7 +39,13 @@ from ledgrab.storage.ha_light_output_target import (
HALightMapping, HALightMapping,
HALightOutputTarget, HALightOutputTarget,
) )
from ledgrab.storage.z2m_light_output_target import (
Z2MLightMapping,
Z2MLightOutputTarget,
)
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
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 +70,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 +84,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,34 +103,183 @@ 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 _target_to_response(target) -> OutputTargetResponse: def _z2m_light_target_to_response(
"""Convert any OutputTarget to the appropriate typed response.""" target: Z2MLightOutputTarget,
if isinstance(target, WledOutputTarget): ) -> Z2MLightOutputTargetResponse:
return _led_target_to_response(target) """Convert a Z2MLightOutputTarget to Z2MLightOutputTargetResponse."""
elif isinstance(target, HALightOutputTarget): return Z2MLightOutputTargetResponse(
return _ha_light_target_to_response(target) id=target.id,
else: name=target.name,
# Fallback for unknown types — use LED response with defaults mqtt_source_id=target.mqtt_source_id or "",
return LedOutputTargetResponse( source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
id=target.id, color_strip_source_id=target.color_strip_source_id or "",
name=target.name, color_value_source_id=target.color_value_source_id or "",
description=target.description, brightness=target.brightness.to_dict(),
tags=target.tags, z2m_light_mappings=[
created_at=target.created_at, Z2MLightMappingSchema(
updated_at=target.updated_at, friendly_name=m.friendly_name,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale.to_dict(),
)
for m in target.light_mappings
],
base_topic=target.base_topic,
update_rate=target.update_rate.to_dict(),
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
stop_action=target.stop_action if target.stop_action in ("none", "turn_off") else "none",
description=target.description,
tags=target.tags,
icon=getattr(target, "icon", "") or "",
icon_color=getattr(target, "icon_color", "") or "",
created_at=target.created_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')"
),
) )
_TARGET_RESPONSE_BUILDERS: dict = {
WledOutputTarget: _led_target_to_response,
HALightOutputTarget: _ha_light_target_to_response,
Z2MLightOutputTarget: _z2m_light_target_to_response,
}
def _assert_target_response_coverage() -> None:
"""Verify the response registry covers every concrete OutputTarget subclass.
Runs at module import. Surfaces a missing builder eagerly instead of
letting a request fall through to the previous silent fallback (which
used to return a defaults-filled LedOutputTargetResponse and quietly
misshape the payload for unknown target types).
"""
expected = {WledOutputTarget, HALightOutputTarget, Z2MLightOutputTarget}
registered = set(_TARGET_RESPONSE_BUILDERS.keys())
missing = expected - registered
extra = registered - expected
if missing or extra:
problems = []
if missing:
problems.append(f"missing builders: {sorted(c.__name__ for c in missing)}")
if extra:
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
raise RuntimeError(
"_TARGET_RESPONSE_BUILDERS is out of sync with the OutputTarget "
"subclass set: " + "; ".join(problems)
)
_assert_target_response_coverage()
def _target_to_response(target) -> OutputTargetResponse:
"""Convert any OutputTarget to the appropriate typed response.
Dispatches via :data:`_TARGET_RESPONSE_BUILDERS` keyed by concrete
subclass. Raises ``RuntimeError`` for an unregistered subclass
coverage is asserted at import, so this should never fire in
practice; if it does, the storage layer added a new OutputTarget
subclass without a matching response builder here.
"""
builder = _TARGET_RESPONSE_BUILDERS.get(type(target))
if builder is None:
raise RuntimeError(
f"No response builder registered for OutputTarget subclass " f"{type(target).__name__}"
)
return builder(target)
# ===== CRUD ENDPOINTS ===== # ===== CRUD ENDPOINTS =====
def _build_ha_mappings(
payload: list[HALightMappingSchema] | None,
) -> list[HALightMapping] | None:
if not payload:
return None
return [
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in payload
]
def _build_z2m_mappings(
payload: list[Z2MLightMappingSchema] | None,
) -> list[Z2MLightMapping] | None:
if not payload:
return None
return [
Z2MLightMapping(
friendly_name=m.friendly_name,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in payload
]
def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
if not device_id:
return
try:
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
def _validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str) -> None:
"""Ensure the referenced MQTT source exists. Empty id is allowed (unconfigured)."""
if not mqtt_source_id:
return
try:
mqtt_store.get(mqtt_source_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
@router.post( @router.post(
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201 "/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
) )
@@ -119,53 +289,70 @@ 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),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
): ):
"""Create a new output target.""" """Create a new output target."""
try: try:
# Validate device exists if provided match data:
device_id = getattr(data, "device_id", "") case LedOutputTargetCreate():
if device_id: _validate_device_exists(device_store, data.device_id)
try: target = target_store.create_wled_target(
device_store.get_device(device_id) name=data.name,
except ValueError: description=data.description,
raise HTTPException(status_code=422, detail=f"Device {device_id} not found") tags=data.tags,
device_id=data.device_id,
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None) color_strip_source_id=data.color_strip_source_id,
ha_mappings = ( brightness=data.brightness,
[ fps=data.fps,
HALightMapping( keepalive_interval=data.keepalive_interval,
entity_id=m.entity_id, state_check_interval=data.state_check_interval,
led_start=m.led_start, min_brightness_threshold=data.min_brightness_threshold,
led_end=m.led_end, adaptive_fps=data.adaptive_fps,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0), protocol=data.protocol,
) )
for m in ha_light_mappings_raw case HALightOutputTargetCreate():
] if data.source_kind == "color_vs":
if ha_light_mappings_raw _validate_color_value_source(value_source_store, data.color_value_source_id)
else None target = target_store.create_ha_light_target(
) name=data.name,
description=data.description,
# Create in store tags=data.tags,
target = target_store.create_target( ha_source_id=data.ha_source_id,
name=data.name, source_kind=data.source_kind,
target_type=data.target_type, color_strip_source_id=data.color_strip_source_id,
device_id=device_id, color_value_source_id=data.color_value_source_id,
color_strip_source_id=getattr(data, "color_strip_source_id", ""), brightness=data.brightness,
brightness=getattr(data, "brightness", 1.0), ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
fps=getattr(data, "fps", 30), update_rate=data.update_rate,
keepalive_interval=getattr(data, "keepalive_interval", 1.0), transition=data.transition,
state_check_interval=getattr(data, "state_check_interval", 30), min_brightness_threshold=data.min_brightness_threshold,
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0), color_tolerance=data.color_tolerance,
adaptive_fps=getattr(data, "adaptive_fps", False), stop_action=data.stop_action,
protocol=getattr(data, "protocol", "ddp"), )
description=data.description, case Z2MLightOutputTargetCreate():
tags=data.tags, if data.source_kind == "color_vs":
ha_source_id=getattr(data, "ha_source_id", ""), _validate_color_value_source(value_source_store, data.color_value_source_id)
ha_light_mappings=ha_mappings, _validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
update_rate=getattr(data, "update_rate", 2.0), target = target_store.create_z2m_light_target(
transition=getattr(data, "transition", 0.5), name=data.name,
color_tolerance=getattr(data, "color_tolerance", 5), description=data.description,
) tags=data.tags,
mqtt_source_id=data.mqtt_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
base_topic=data.base_topic,
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
raise HTTPException(status_code=400, detail="Unknown target_type")
# Register in processor manager # Register in processor manager
try: try:
@@ -233,6 +420,18 @@ async def get_target(
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
def _resolve_effective_color_vs_id(
target_store: OutputTargetStore, target_id: str, payload_id: str | None
) -> str:
if payload_id is not None:
return payload_id
try:
existing = target_store.get_target(target_id)
except ValueError:
return ""
return getattr(existing, "color_value_source_id", "") or ""
@router.put( @router.put(
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"] "/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
) )
@@ -243,90 +442,161 @@ 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),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
): ):
"""Update a output target.""" """Update a output target."""
try: try:
# Validate device exists if changing css_changed = False
device_id = getattr(data, "device_id", None) brightness_changed = False
if device_id is not None and device_id: settings_changed = False
try: device_changed = False
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
# Build HA light mappings if provided match data:
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None) case LedOutputTargetUpdate():
ha_mappings = None if data.device_id:
if ha_light_mappings_raw is not None: _validate_device_exists(device_store, data.device_id)
ha_mappings = [ target = target_store.update_wled_target(
HALightMapping( target_id,
entity_id=m.entity_id, name=data.name,
led_start=m.led_start, description=data.description,
led_end=m.led_end, tags=data.tags,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0), icon=data.icon,
icon_color=data.icon_color,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
) )
for m in ha_light_mappings_raw css_changed = data.color_strip_source_id is not None
] brightness_changed = data.brightness is not None
settings_changed = any(
# Update in store v is not None
target = target_store.update_target( for v in (
target_id=target_id, data.fps,
name=data.name, data.keepalive_interval,
device_id=device_id, data.state_check_interval,
color_strip_source_id=getattr(data, "color_strip_source_id", None), data.min_brightness_threshold,
brightness=getattr(data, "brightness", None), data.adaptive_fps,
fps=getattr(data, "fps", None), data.brightness,
keepalive_interval=getattr(data, "keepalive_interval", None), )
state_check_interval=getattr(data, "state_check_interval", None), )
min_brightness_threshold=getattr(data, "min_brightness_threshold", None), device_changed = data.device_id is not None
adaptive_fps=getattr(data, "adaptive_fps", None), case HALightOutputTargetUpdate():
protocol=getattr(data, "protocol", None), # Validate color VS when switching into / staying in color_vs mode
description=data.description, if data.source_kind == "color_vs" or (
tags=data.tags, data.source_kind is None and data.color_value_source_id
ha_source_id=getattr(data, "ha_source_id", None), ):
ha_light_mappings=ha_mappings, effective_id = _resolve_effective_color_vs_id(
update_rate=getattr(data, "update_rate", None), target_store, target_id, data.color_value_source_id
transition=getattr(data, "transition", None), )
color_tolerance=getattr(data, "color_tolerance", None), _validate_color_value_source(value_source_store, effective_id)
) target = target_store.update_ha_light_target(
target_id,
name=data.name,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
ha_source_id=data.ha_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None
settings_changed = any(
v is not None
for v in (
data.source_kind,
data.color_value_source_id,
data.brightness,
data.update_rate,
data.transition,
data.min_brightness_threshold,
data.color_tolerance,
data.ha_light_mappings,
data.stop_action,
)
)
case Z2MLightOutputTargetUpdate():
if data.source_kind == "color_vs" or (
data.source_kind is None and data.color_value_source_id
):
effective_id = _resolve_effective_color_vs_id(
target_store, target_id, data.color_value_source_id
)
_validate_color_value_source(value_source_store, effective_id)
if data.mqtt_source_id:
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.update_z2m_light_target(
target_id,
name=data.name,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
mqtt_source_id=data.mqtt_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
base_topic=data.base_topic,
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None
settings_changed = any(
v is not None
for v in (
data.source_kind,
data.color_value_source_id,
data.mqtt_source_id,
data.brightness,
data.base_topic,
data.update_rate,
data.transition,
data.min_brightness_threshold,
data.color_tolerance,
data.z2m_light_mappings,
data.stop_action,
)
)
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
raise HTTPException(status_code=400, detail="Unknown target_type")
# Sync processor manager (run in thread — css release/acquire can block) # Sync processor manager (run in thread — css release/acquire can block)
color_strip_source_id = getattr(data, "color_strip_source_id", None)
fps = getattr(data, "fps", None)
keepalive_interval = getattr(data, "keepalive_interval", None)
state_check_interval = getattr(data, "state_check_interval", None)
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
adaptive_fps = getattr(data, "adaptive_fps", None)
update_rate = getattr(data, "update_rate", None)
transition = getattr(data, "transition", None)
color_tolerance = getattr(data, "color_tolerance", None)
brightness = getattr(data, "brightness", None)
try: try:
await asyncio.to_thread( await asyncio.to_thread(
target.sync_with_manager, target.sync_with_manager,
manager, manager,
settings_changed=( settings_changed=settings_changed,
fps is not None css_changed=css_changed,
or keepalive_interval is not None brightness_changed=brightness_changed,
or state_check_interval is not None
or min_brightness_threshold is not None
or adaptive_fps is not None
or update_rate is not None
or transition is not None
or color_tolerance is not None
or ha_light_mappings_raw is not None
or brightness is not None
),
css_changed=color_strip_source_id is not None,
brightness_changed=brightness is not None,
) )
except ValueError as e: except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e) logger.debug("Processor config update skipped for target %s: %s", target_id, e)
pass pass
# Device change requires async stop -> swap -> start cycle # LED-only: device change requires async stop -> swap -> start cycle
if device_id is not None: if device_changed and isinstance(target, WledOutputTarget):
try: try:
await manager.update_target_device(target_id, target.device_id) await manager.update_target_device(target_id, target.device_id)
except ValueError as e: except ValueError as e:
@@ -335,6 +335,35 @@ async def get_overlay_status(
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
# ===== HA LIGHT — MANUAL TURN OFF =====
@router.post("/api/v1/output-targets/{target_id}/ha-light/turn-off", tags=["Processing"])
async def turn_off_ha_light_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Turn off all HA light entities mapped by the target.
Works regardless of whether the target's processor is running. Useful
when ``stop_action`` is ``"none"`` and lights were left on after a stop.
"""
try:
# Verify target exists
target_store.get_target(target_id)
count = await manager.turn_off_ha_light_target(target_id)
return {"status": "ok", "target_id": target_id, "entities": count}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error("Failed to turn off HA lights: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== HA LIGHT COLOR PREVIEW WEBSOCKET ===== # ===== HA LIGHT COLOR PREVIEW WEBSOCKET =====
@@ -377,6 +406,75 @@ async def ha_light_colors_ws(
manager.remove_ha_light_ws_client(target_id, websocket) manager.remove_ha_light_ws_client(target_id, websocket)
# ===== Z2M LIGHT — MANUAL TURN OFF =====
@router.post("/api/v1/output-targets/{target_id}/z2m-light/turn-off", tags=["Processing"])
async def turn_off_z2m_light_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Publish OFF to all Z2M bulbs mapped by the target.
Works regardless of whether the target's processor is running. Useful
when ``stop_action`` is ``"none"`` and bulbs were left on after a stop.
"""
try:
target_store.get_target(target_id)
count = await manager.turn_off_z2m_light_target(target_id)
return {"status": "ok", "target_id": target_id, "entities": count}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error("Failed to turn off Z2M lights: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== Z2M LIGHT COLOR PREVIEW WEBSOCKET =====
@router.websocket("/api/v1/output-targets/{target_id}/z2m-light/ws")
async def z2m_light_colors_ws(
websocket: WebSocket,
target_id: str,
):
"""WebSocket for live Z2M bulb colour preview.
Streams: {"type":"colors_update","colors":{friendly_name:{r,g,b,hex},...}}
at the target's update_rate. Auth via first-message handshake.
"""
from ledgrab.api.auth import accept_and_authenticate_ws
if await accept_and_authenticate_ws(websocket) is None:
return
manager: ProcessorManager = get_processor_manager()
try:
proc = manager._processors.get(target_id)
if not proc or not proc.is_running:
await websocket.close(code=4003, reason="Target not running")
return
except Exception as e:
await websocket.close(code=4004, reason=str(e))
return
try:
manager.add_z2m_light_ws_client(target_id, websocket)
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
except (RuntimeError, ConnectionError) as e:
logger.debug("ws closed in z2m-light client: %s", e)
finally:
manager.remove_z2m_light_ws_client(target_id, websocket)
# ===== LED PREVIEW WEBSOCKET ===== # ===== LED PREVIEW WEBSOCKET =====
@@ -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)
+30 -13
View File
@@ -7,8 +7,8 @@ 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
import os import os
@@ -24,6 +24,7 @@ from ledgrab.api.dependencies import (
get_device_store, get_device_store,
get_ha_manager, get_ha_manager,
get_ha_store, get_ha_store,
get_mqtt_manager,
get_output_target_store, get_output_target_store,
get_picture_source_store, get_picture_source_store,
get_pp_template_store, get_pp_template_store,
@@ -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,
) )
@@ -180,7 +189,7 @@ async def list_all_tags(_: AuthRequired):
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"]) @router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
async def get_displays( async def get_displays(
_: AuthRequired, _: AuthRequired,
engine_type: Optional[str] = Query(None, description="Engine type to get displays for"), engine_type: str | None = Query(None, description="Engine type to get displays for"),
): ):
"""Get list of available displays. """Get list of available displays.
@@ -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),
) )
@@ -361,22 +380,20 @@ async def get_integrations_status(
_: AuthRequired, _: AuthRequired,
ha_store=Depends(get_ha_store), ha_store=Depends(get_ha_store),
ha_manager=Depends(get_ha_manager), ha_manager=Depends(get_ha_manager),
mqtt_manager=Depends(get_mqtt_manager),
): ):
"""Return connection status for external integrations (MQTT, Home Assistant). """Return connection status for external integrations (MQTT, Home Assistant).
Used by the dashboard to show connectivity indicators. Used by the dashboard to show connectivity indicators. MQTT is reported
per-source since the multi-broker refactor no more global "MQTT
enabled" flag.
""" """
from ledgrab.core.devices.mqtt_client import get_mqtt_service # MQTT status — one entry per configured source
mqtt_items = mqtt_manager.get_all_sources_status()
# MQTT status
mqtt_service = get_mqtt_service()
mqtt_config = get_config().mqtt
mqtt_status = { mqtt_status = {
"enabled": mqtt_config.enabled, "sources": mqtt_items,
"connected": mqtt_service.is_connected if mqtt_service else False, "total": len(mqtt_items),
"broker": ( "connected": sum(1 for s in mqtt_items if s.get("connected")),
f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None
),
} }
# Home Assistant status # Home Assistant status
@@ -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),
) )
+50 -2
View File
@@ -1,7 +1,7 @@
"""Value source routes: CRUD for value sources.""" """Value source routes: CRUD for value sources."""
import asyncio import asyncio
from typing import Annotated, Optional from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
@@ -23,6 +23,7 @@ from ledgrab.api.schemas.value_sources import (
DaylightValueSourceResponse, DaylightValueSourceResponse,
GradientMapValueSourceResponse, GradientMapValueSourceResponse,
HAEntityValueSourceResponse, HAEntityValueSourceResponse,
HTTPValueSourceResponse,
StaticColorValueSourceResponse, StaticColorValueSourceResponse,
StaticValueSourceResponse, StaticValueSourceResponse,
SystemMetricsValueSourceResponse, SystemMetricsValueSourceResponse,
@@ -41,6 +42,7 @@ from ledgrab.storage.value_source import (
DaylightValueSource, DaylightValueSource,
GradientMapValueSource, GradientMapValueSource,
HAEntityValueSource, HAEntityValueSource,
HTTPValueSource,
StaticColorValueSource, StaticColorValueSource,
StaticValueSource, StaticValueSource,
SystemMetricsValueSource, SystemMetricsValueSource,
@@ -64,6 +66,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 +77,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 +91,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 +108,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 +124,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 +135,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 +160,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 +176,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 +189,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 +202,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,
@@ -189,6 +215,22 @@ _RESPONSE_MAP = {
poll_interval=s.poll_interval, poll_interval=s.poll_interval,
smoothing=s.smoothing, smoothing=s.smoothing,
), ),
HTTPValueSource: lambda s: HTTPValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
http_endpoint_id=s.http_endpoint_id,
json_path=s.json_path,
interval_s=s.interval_s,
min_value=s.min_value,
max_value=s.max_value,
smoothing=s.smoothing,
),
} }
@@ -202,6 +244,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 +260,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 +277,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),
@@ -241,7 +289,7 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"]) @router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
async def list_value_sources( async def list_value_sources(
_auth: AuthRequired, _auth: AuthRequired,
source_type: Optional[str] = Query( source_type: str | None = Query(
None, None,
description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene", description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene",
), ),
@@ -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")
+14 -1
View File
@@ -30,6 +30,9 @@ _RATE_WINDOW = 60.0 # seconds
_rate_hits: dict[str, list[float]] = defaultdict(list) _rate_hits: dict[str, list[float]] = defaultdict(list)
_RATE_HITS_HARD_CAP = 1024
def _check_rate_limit(client_ip: str) -> None: def _check_rate_limit(client_ip: str) -> None:
"""Raise 429 if *client_ip* exceeded the webhook rate limit.""" """Raise 429 if *client_ip* exceeded the webhook rate limit."""
now = time.time() now = time.time()
@@ -44,11 +47,21 @@ def _check_rate_limit(client_ip: str) -> None:
) )
_rate_hits[client_ip].append(now) _rate_hits[client_ip].append(now)
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth # Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth.
if len(_rate_hits) > 100: if len(_rate_hits) > 100:
stale = [ip for ip, ts in _rate_hits.items() if not ts or ts[-1] < window_start] stale = [ip for ip, ts in _rate_hits.items() if not ts or ts[-1] < window_start]
for ip in stale: for ip in stale:
del _rate_hits[ip] del _rate_hits[ip]
# Hard cap as a final defence against an attacker spraying many distinct
# X-Forwarded-For values to drive memory growth past the soft cleanup
# threshold. Drop the oldest-touched IPs (by their latest timestamp).
if len(_rate_hits) > _RATE_HITS_HARD_CAP:
ordered = sorted(
_rate_hits.items(),
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
)
for ip, _ in ordered[: len(ordered) - _RATE_HITS_HARD_CAP]:
_rate_hits.pop(ip, None)
class WebhookPayload(BaseModel): class WebhookPayload(BaseModel):
+25 -5
View File
@@ -1,7 +1,7 @@
"""Asset schemas (CRUD).""" """Asset schemas (CRUD)."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -9,9 +9,19 @@ from pydantic import BaseModel, Field
class AssetUpdate(BaseModel): class AssetUpdate(BaseModel):
"""Request to update asset metadata.""" """Request to update asset metadata."""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name") name: str | None = Field(None, min_length=1, max_length=100, description="Display name")
description: Optional[str] = Field(None, max_length=500, description="Optional description") description: str | None = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags") tags: List[str] | None = Field(None, description="User-defined tags")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -23,9 +33,19 @@ class AssetResponse(BaseModel):
mime_type: str = Field(description="MIME type") mime_type: str = Field(description="MIME type")
asset_type: str = Field(description="Asset type: sound, image, video, other") asset_type: str = Field(description="Asset type: sound, image, video, other")
size_bytes: int = Field(description="File size in bytes") size_bytes: int = Field(description="File size in bytes")
description: Optional[str] = Field(None, description="Description") description: str | None = 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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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")
@@ -1,7 +1,7 @@
"""Audio processing template schemas.""" """Audio processing template schemas."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -15,19 +15,39 @@ class AudioProcessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field( filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of audio filter instances" default_factory=list, description="Ordered list of audio filter instances"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = 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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
"""Request to update an audio processing template.""" """Request to update an audio processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field( filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of audio filter instances" None, description="Ordered list of audio filter instances"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -41,7 +61,17 @@ class AudioProcessingTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description") description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
+49 -25
View File
@@ -1,7 +1,7 @@
"""Audio source schemas — discriminated unions per source type.""" """Audio source schemas — discriminated unions per source type."""
from datetime import datetime from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union from typing import Annotated, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag from pydantic import BaseModel, Discriminator, Field, Tag
@@ -15,17 +15,27 @@ class _AudioSourceResponseBase(BaseModel):
id: str = Field(description="Source ID") id: str = Field(description="Source ID")
name: str = Field(description="Source name") name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description") description: str | None = 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")
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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
source_type: Literal["capture"] = "capture" source_type: Literal["capture"] = "capture"
device_index: int = Field(description="Audio device index (-1 = default)") device_index: int = Field(description="Audio device index (-1 = default)")
is_loopback: bool = Field(description="WASAPI loopback mode") is_loopback: bool = Field(description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID") audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceResponse(_AudioSourceResponseBase): class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
@@ -35,10 +45,8 @@ class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
AudioSourceResponse = Annotated[ AudioSourceResponse = Annotated[
Union[ Annotated[CaptureAudioSourceResponse, Tag("capture")]
Annotated[CaptureAudioSourceResponse, Tag("capture")], | Annotated[ProcessedAudioSourceResponse, Tag("processed")],
Annotated[ProcessedAudioSourceResponse, Tag("processed")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -51,15 +59,25 @@ class _AudioSourceCreateBase(BaseModel):
"""Shared fields for all audio source create requests.""" """Shared fields for all audio source create requests."""
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: str | None = 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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
source_type: Literal["capture"] = "capture" source_type: Literal["capture"] = "capture"
device_index: int = Field(-1, description="Audio device index (-1 = default)") device_index: int = Field(-1, description="Audio device index (-1 = default)")
is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)") is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID") audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceCreate(_AudioSourceCreateBase): class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
@@ -69,10 +87,8 @@ class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
AudioSourceCreate = Annotated[ AudioSourceCreate = Annotated[
Union[ Annotated[CaptureAudioSourceCreate, Tag("capture")]
Annotated[CaptureAudioSourceCreate, Tag("capture")], | Annotated[ProcessedAudioSourceCreate, Tag("processed")],
Annotated[ProcessedAudioSourceCreate, Tag("processed")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -84,31 +100,39 @@ AudioSourceCreate = Annotated[
class _AudioSourceUpdateBase(BaseModel): class _AudioSourceUpdateBase(BaseModel):
"""Shared fields for all audio source update requests.""" """Shared fields for all audio source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
source_type: Literal["capture"] = "capture" source_type: Literal["capture"] = "capture"
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)") device_index: int | None = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)") is_loopback: bool | None = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID") audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceUpdate(_AudioSourceUpdateBase): class ProcessedAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["processed"] = "processed" source_type: Literal["processed"] = "processed"
audio_source_id: Optional[str] = Field(None, description="Input audio source ID") audio_source_id: str | None = Field(None, description="Input audio source ID")
audio_processing_template_id: Optional[str] = Field( audio_processing_template_id: str | None = Field(
None, description="Audio processing template ID" None, description="Audio processing template ID"
) )
AudioSourceUpdate = Annotated[ AudioSourceUpdate = Annotated[
Union[ Annotated[CaptureAudioSourceUpdate, Tag("capture")]
Annotated[CaptureAudioSourceUpdate, Tag("capture")], | Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -1,7 +1,7 @@
"""Audio capture template and engine schemas.""" """Audio capture template and engine schemas."""
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -14,18 +14,38 @@ class AudioTemplateCreate(BaseModel):
description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1 description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1
) )
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: str | None = 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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
"""Request to update an audio template.""" """Request to update an audio template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: Optional[str] = Field(None, description="Audio engine type") engine_type: str | None = Field(None, description="Audio engine type")
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") engine_config: Dict | None = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -38,7 +58,17 @@ class AudioTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description") description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
+78 -36
View File
@@ -1,7 +1,7 @@
"""Automation-related schemas.""" """Automation-related schemas."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -11,41 +11,55 @@ class RuleSchema(BaseModel):
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')") rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
# Application rule fields # Application rule fields
apps: Optional[List[str]] = Field(None, description="Process names (for application rule)") apps: List[str] | None = Field(None, description="Process names (for application rule)")
match_type: Optional[str] = Field( match_type: str | None = Field(
None, description="'running' or 'topmost' (for application rule)" None, description="'running' or 'topmost' (for application rule)"
) )
# Time-of-day rule fields # Time-of-day rule fields
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day rule)") start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day rule)") end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
# System idle rule fields # System idle rule fields
idle_minutes: Optional[int] = Field( idle_minutes: int | None = Field(
None, description="Idle timeout in minutes (for system_idle rule)" None, description="Idle timeout in minutes (for system_idle rule)"
) )
when_idle: Optional[bool] = Field( when_idle: bool | None = Field(None, description="True=active when idle (for system_idle rule)")
None, description="True=active when idle (for system_idle rule)"
)
# Display state rule fields # Display state rule fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)") state: str | None = Field(None, description="'on' or 'off' (for display_state rule)")
# MQTT rule fields # MQTT rule fields
mqtt_source_id: Optional[str] = Field(None, description="MQTT source ID (for mqtt rule)") mqtt_source_id: str | None = Field(None, description="MQTT source ID (for mqtt rule)")
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt rule)") topic: str | None = Field(None, description="MQTT topic to watch (for mqtt rule)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)") payload: str | None = Field(None, description="Expected payload value (for mqtt rule)")
match_mode: Optional[str] = Field( match_mode: str | None = Field(
None, description="'exact', 'contains', or 'regex' (for mqtt rule)" None, description="'exact', 'contains', or 'regex' (for mqtt rule)"
) )
# Webhook rule fields # Webhook rule fields
token: Optional[str] = Field( token: str | None = Field(None, description="Secret token for webhook URL (for webhook rule)")
None, description="Secret token for webhook URL (for webhook rule)"
)
# Home Assistant rule fields # Home Assistant rule fields
ha_source_id: Optional[str] = Field( ha_source_id: str | None = Field(
None, description="Home Assistant source ID (for home_assistant rule)" None, description="Home Assistant source ID (for home_assistant rule)"
) )
entity_id: Optional[str] = Field( entity_id: str | None = Field(
None, None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)", description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
) )
# HTTP poll rule fields
value_source_id: str | None = Field(
None,
description=(
"Value source ID (for http_poll rule). The referenced "
"ValueSource must be of source_type='http'."
),
)
operator: str | None = Field(
None,
description=(
"Comparison operator for http_poll rule: "
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
),
)
value: str | None = Field(
None, description="Expected value (for http_poll rule; ignored for 'exists')"
)
# Backward-compatible alias # Backward-compatible alias
@@ -59,31 +73,49 @@ class AutomationCreate(BaseModel):
enabled: bool = Field(default=True, description="Whether the automation is enabled") enabled: bool = Field(default=True, description="Whether the automation is enabled")
rule_logic: str = Field(default="or", description="How rules combine: 'or' or 'and'") rule_logic: str = Field(default="or", description="How rules combine: 'or' or 'and'")
rules: List[RuleSchema] = Field(default_factory=list, description="List of rules") rules: List[RuleSchema] = Field(default_factory=list, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field( deactivation_mode: str = Field(
default="none", description="'none', 'revert', or 'fallback_scene'" default="none", description="'none', 'revert', or 'fallback_scene'"
) )
deactivation_scene_preset_id: Optional[str] = Field( deactivation_scene_preset_id: str | None = Field(
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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
"""Request to update an automation.""" """Request to update an automation."""
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100) name: str | None = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled") enabled: bool | None = Field(None, description="Whether the automation is enabled")
rule_logic: Optional[str] = Field(None, description="How rules combine: 'or' or 'and'") rule_logic: str | None = Field(None, description="How rules combine: 'or' or 'and'")
rules: Optional[List[RuleSchema]] = Field(None, description="List of rules") rules: List[RuleSchema] | None = Field(None, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: Optional[str] = Field( deactivation_mode: str | None = Field(None, description="'none', 'revert', or 'fallback_scene'")
None, description="'none', 'revert', or 'fallback_scene'" deactivation_scene_preset_id: str | None = Field(
)
deactivation_scene_preset_id: Optional[str] = Field(
None, description="Scene preset for fallback deactivation" None, description="Scene preset for fallback deactivation"
) )
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -94,20 +126,30 @@ class AutomationResponse(BaseModel):
enabled: bool = Field(description="Whether the automation is enabled") enabled: bool = Field(description="Whether the automation is enabled")
rule_logic: str = Field(description="Rule combination logic") rule_logic: str = Field(description="Rule combination logic")
rules: List[RuleSchema] = Field(description="List of rules") rules: List[RuleSchema] = Field(description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="Deactivation behavior") deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset") deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
webhook_url: Optional[str] = Field( webhook_url: str | None = Field(
None, description="Webhook URL for the first webhook rule (if any)" None, description="Webhook URL for the first webhook rule (if any)"
) )
is_active: bool = Field(default=False, description="Whether the automation is currently active") is_active: bool = Field(default=False, description="Whether the automation is currently active")
last_activated_at: Optional[datetime] = Field( last_activated_at: datetime | None = Field(
None, description="Last time this automation was activated" None, description="Last time this automation was activated"
) )
last_deactivated_at: Optional[datetime] = Field( last_deactivated_at: datetime | None = Field(
None, description="Last time this automation was deactivated" None, description="Last time this automation was deactivated"
) )
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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")
@@ -1,7 +1,7 @@
"""Color strip processing template schemas.""" """Color strip processing template schemas."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -15,19 +15,39 @@ class ColorStripProcessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field( filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of filter instances" default_factory=list, description="Ordered list of filter instances"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = 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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
"""Request to update a color strip processing template.""" """Request to update a color strip processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field( filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of filter instances" None, description="Ordered list of filter instances"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -39,7 +59,17 @@ class ColorStripProcessingTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description") description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -1,13 +1,12 @@
"""Color strip source schemas — discriminated unions per source type.""" """Color strip source schemas — discriminated unions per source type."""
from datetime import datetime from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union from typing import Annotated, Any, Dict, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag, model_validator from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
from ledgrab.api.schemas.devices import Calibration from ledgrab.api.schemas.devices import Calibration
# ===================================================================== # =====================================================================
# Helper models (unchanged) # Helper models (unchanged)
# ===================================================================== # =====================================================================
@@ -16,10 +15,10 @@ from ledgrab.api.schemas.devices import Calibration
class AppSoundOverride(BaseModel): class AppSoundOverride(BaseModel):
"""Per-application sound override for notification sources.""" """Per-application sound override for notification sources."""
sound_asset_id: Optional[str] = Field( sound_asset_id: str | None = Field(
None, description="Asset ID for the sound (None = mute this app)" None, description="Asset ID for the sound (None = mute this app)"
) )
volume: Optional[float] = Field( volume: float | None = Field(
None, ge=0.0, le=1.0, description="Volume override (None = use global)" None, ge=0.0, le=1.0, description="Volume override (None = use global)"
) )
@@ -28,7 +27,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)")
@@ -39,7 +38,7 @@ class ColorStop(BaseModel):
description="Relative position along the strip (0.0-1.0)", ge=0.0, le=1.0 description="Relative position along the strip (0.0-1.0)", ge=0.0, le=1.0
) )
color: List[int] = Field(description="Primary RGB color [R, G, B] (0-255 each)") color: List[int] = Field(description="Primary RGB color [R, G, B] (0-255 each)")
color_right: Optional[List[int]] = Field( color_right: List[int] | None = Field(
None, None,
description="Optional right-side RGB color for a hard edge (bidirectional stop)", description="Optional right-side RGB color for a hard edge (bidirectional stop)",
) )
@@ -54,10 +53,10 @@ class CompositeLayer(BaseModel):
) )
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0") opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
enabled: bool = Field(default=True, description="Whether this layer is active") enabled: bool = Field(default=True, description="Whether this layer is active")
brightness_source_id: Optional[str] = Field( brightness_source_id: str | None = Field(
None, description="Optional value source ID for dynamic brightness" None, description="Optional value source ID for dynamic brightness"
) )
processing_template_id: Optional[str] = Field( processing_template_id: str | None = Field(
None, description="Optional color strip processing template ID" None, description="Optional color strip processing template ID"
) )
start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)") start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)")
@@ -86,15 +85,25 @@ class _CSSResponseBase(BaseModel):
id: str = Field(description="Source ID") id: str = Field(description="Source ID")
name: str = Field(description="Source name") name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description") description: str | None = Field(None, description="Description")
led_count: int = Field(0, description="Total LED count (0 = auto)") led_count: int = Field(0, description="Total LED count (0 = auto)")
overlay_active: bool = Field( overlay_active: bool = Field(
False, description="Whether the screen overlay is currently active" False, description="Whether the screen overlay is currently active"
) )
clock_id: Optional[str] = Field(None, description="Optional sync clock ID") clock_id: str | None = 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")
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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -102,45 +111,40 @@ class PictureCSSResponse(_CSSResponseBase):
picture_source_id: str = Field(description="Picture source ID") picture_source_id: str = Field(description="Picture source ID")
smoothing: Any = Field(description="Temporal smoothing") smoothing: Any = Field(description="Temporal smoothing")
interpolation_mode: str = Field(description="Interpolation mode") interpolation_mode: str = Field(description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSResponse(_CSSResponseBase): class PictureAdvancedCSSResponse(_CSSResponseBase):
source_type: Literal["picture_advanced"] = "picture_advanced" source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(description="Temporal smoothing") smoothing: Any = Field(description="Temporal smoothing")
interpolation_mode: str = Field(description="Interpolation mode") interpolation_mode: str = Field(description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class StaticCSSResponse(_CSSResponseBase): class SingleColorCSSResponse(_CSSResponseBase):
source_type: Literal["static"] = "static" source_type: Literal["single_color"] = "single_color"
color: Any = Field(description="Static RGB color") color: Any = Field(description="Solid RGB color")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSResponse(_CSSResponseBase): class GradientCSSResponse(_CSSResponseBase):
source_type: Literal["gradient"] = "gradient" source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops") stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: str = Field(description="Gradient interpolation easing") easing: str = Field(description="Gradient interpolation easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = 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")
palette: str = Field(description="Named palette") palette: str = Field(description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(description="Primary color") color: Any = Field(description="Primary color")
intensity: Any = Field(description="Effect intensity") intensity: Any = Field(description="Effect intensity")
scale: Any = Field(description="Spatial scale") scale: Any = Field(description="Spatial scale")
mirror: bool = Field(description="Mirror/bounce mode") mirror: bool = Field(description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops") custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSResponse(_CSSResponseBase): class CompositeCSSResponse(_CSSResponseBase):
@@ -160,7 +164,7 @@ class AudioCSSResponse(_CSSResponseBase):
sensitivity: Any = Field(description="Audio sensitivity") sensitivity: Any = Field(description="Audio sensitivity")
smoothing: Any = Field(description="Temporal smoothing") smoothing: Any = Field(description="Temporal smoothing")
palette: str = Field(description="Named palette") palette: str = Field(description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(description="Primary color") color: Any = Field(description="Primary color")
color_peak: Any = Field(description="Peak color") color_peak: Any = Field(description="Peak color")
mirror: bool = Field(description="Mirror mode") mirror: bool = Field(description="Mirror mode")
@@ -183,7 +187,7 @@ class NotificationCSSResponse(_CSSResponseBase):
app_filter_mode: str = Field(description="App filter mode") app_filter_mode: str = Field(description="App filter mode")
app_filter_list: List[str] = Field(default_factory=list, description="App names for filter") app_filter_list: List[str] = Field(default_factory=list, description="App names for filter")
os_listener: bool = Field(description="Whether to listen for OS notifications") os_listener: bool = Field(description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(description="Global notification sound volume") sound_volume: Any = Field(description="Global notification sound volume")
app_sounds: Dict[str, dict] = Field(default_factory=dict, description="Per-app sound overrides") app_sounds: Dict[str, dict] = Field(default_factory=dict, description="Per-app sound overrides")
@@ -232,29 +236,34 @@ class MathWaveCSSResponse(_CSSResponseBase):
source_type: Literal["math_wave"] = "math_wave" source_type: Literal["math_wave"] = "math_wave"
waves: List[dict] = Field(description="Wave layer definitions") waves: List[dict] = Field(description="Wave layer definitions")
speed: Any = Field(description="Global speed multiplier (bindable)") speed: Any = Field(description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping") gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSResponse(_CSSResponseBase):
source_type: Literal["game_event"] = "game_event"
game_integration_id: str = Field(description="Game integration entity ID")
idle_color: Any = Field(description="Idle RGB color (bindable)")
event_mappings: List[dict] = Field(default_factory=list, description="Event-to-effect mappings")
ColorStripSourceResponse = Annotated[ ColorStripSourceResponse = Annotated[
Union[ Annotated[PictureCSSResponse, Tag("picture")]
Annotated[PictureCSSResponse, Tag("picture")], | Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")]
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")], | Annotated[SingleColorCSSResponse, Tag("single_color")]
Annotated[StaticCSSResponse, Tag("static")], | Annotated[GradientCSSResponse, Tag("gradient")]
Annotated[GradientCSSResponse, Tag("gradient")], | Annotated[EffectCSSResponse, Tag("effect")]
Annotated[ColorCycleCSSResponse, Tag("color_cycle")], | Annotated[CompositeCSSResponse, Tag("composite")]
Annotated[EffectCSSResponse, Tag("effect")], | Annotated[MappedCSSResponse, Tag("mapped")]
Annotated[CompositeCSSResponse, Tag("composite")], | Annotated[AudioCSSResponse, Tag("audio")]
Annotated[MappedCSSResponse, Tag("mapped")], | Annotated[ApiInputCSSResponse, Tag("api_input")]
Annotated[AudioCSSResponse, Tag("audio")], | Annotated[NotificationCSSResponse, Tag("notification")]
Annotated[ApiInputCSSResponse, Tag("api_input")], | Annotated[DaylightCSSResponse, Tag("daylight")]
Annotated[NotificationCSSResponse, Tag("notification")], | Annotated[CandlelightCSSResponse, Tag("candlelight")]
Annotated[DaylightCSSResponse, Tag("daylight")], | Annotated[ProcessedCSSResponse, Tag("processed")]
Annotated[CandlelightCSSResponse, Tag("candlelight")], | Annotated[WeatherCSSResponse, Tag("weather")]
Annotated[ProcessedCSSResponse, Tag("processed")], | Annotated[KeyColorsCSSResponse, Tag("key_colors")]
Annotated[WeatherCSSResponse, Tag("weather")], | Annotated[MathWaveCSSResponse, Tag("math_wave")]
Annotated[KeyColorsCSSResponse, Tag("key_colors")], | Annotated[GameEventCSSResponse, Tag("game_event")],
Annotated[MathWaveCSSResponse, Tag("math_wave")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -269,9 +278,19 @@ class _CSSCreateBase(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)
led_count: int = Field(default=0, description="Total LED count (0 = auto)", ge=0) led_count: int = Field(default=0, description="Total LED count (0 = auto)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID") clock_id: str | None = 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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -279,68 +298,63 @@ class PictureCSSCreate(_CSSCreateBase):
picture_source_id: str = Field(default="", description="Picture source ID") picture_source_id: str = Field(default="", description="Picture source ID")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: str = Field(default="average", description="Interpolation mode") interpolation_mode: str = Field(default="average", description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSCreate(_CSSCreateBase): class PictureAdvancedCSSCreate(_CSSCreateBase):
source_type: Literal["picture_advanced"] = "picture_advanced" source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: str = Field(default="average", description="Interpolation mode") interpolation_mode: str = Field(default="average", description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class StaticCSSCreate(_CSSCreateBase): class SingleColorCSSCreate(_CSSCreateBase):
source_type: Literal["static"] = "static" source_type: Literal["single_color"] = "single_color"
color: Any = Field(default=None, description="Static RGB color [R,G,B]") color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSCreate(_CSSCreateBase): class GradientCSSCreate(_CSSCreateBase):
source_type: Literal["gradient"] = "gradient" source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops") stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing") easing: str | None = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = 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: str | None = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette") palette: str | None = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color") color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)") intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)") scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops") custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSCreate(_CSSCreateBase): class CompositeCSSCreate(_CSSCreateBase):
source_type: Literal["composite"] = "composite" source_type: Literal["composite"] = "composite"
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") layers: List[CompositeLayer] | None = Field(None, description="Layers for composite type")
class MappedCSSCreate(_CSSCreateBase): class MappedCSSCreate(_CSSCreateBase):
source_type: Literal["mapped"] = "mapped" source_type: Literal["mapped"] = "mapped"
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") zones: List[MappedZone] | None = Field(None, description="Zones for mapped type")
class AudioCSSCreate(_CSSCreateBase): class AudioCSSCreate(_CSSCreateBase):
source_type: Literal["audio"] = "audio" source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode") visualization_mode: str | None = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") audio_source_id: str | None = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)") sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette") palette: str | None = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color") color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]") color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode") mirror: bool | None = Field(None, description="Mirror mode")
beat_decay: Any = Field( beat_decay: Any = Field(
default=None, description="Beat pulse decay rate (music modes, 0.01-0.5)" default=None, description="Beat pulse decay rate (music modes, 0.01-0.5)"
) )
@@ -350,23 +364,23 @@ class ApiInputCSSCreate(_CSSCreateBase):
source_type: Literal["api_input"] = "api_input" source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]") fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]")
timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)") timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode") interpolation: str | None = Field(None, description="LED count interpolation mode")
class NotificationCSSCreate(_CSSCreateBase): class NotificationCSSCreate(_CSSCreateBase):
source_type: Literal["notification"] = "notification" source_type: Literal["notification"] = "notification"
notification_effect: Optional[str] = Field(None, description="Notification effect") notification_effect: str | None = Field(None, description="Notification effect")
duration_ms: Any = Field(default=None, description="Effect duration in milliseconds") duration_ms: Any = Field(default=None, description="Effect duration in milliseconds")
default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field( default_color: List[int] | Dict[str, Any] | str | None = Field(
None, description="Default color" None, description="Default color"
) )
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors") app_colors: Dict[str, str] | None = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode") app_filter_mode: str | None = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") app_filter_list: List[str] | None = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications") os_listener: bool | None = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(default=None, description="Global notification sound volume") sound_volume: Any = Field(default=None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field( app_sounds: Dict[str, AppSoundOverride] | None = Field(
None, description="Per-app sound overrides" None, description="Per-app sound overrides"
) )
@@ -374,9 +388,9 @@ class NotificationCSSCreate(_CSSCreateBase):
class DaylightCSSCreate(_CSSCreateBase): class DaylightCSSCreate(_CSSCreateBase):
source_type: Literal["daylight"] = "daylight" source_type: Literal["daylight"] = "daylight"
speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)") speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time") use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0) latitude: float | None = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field( longitude: float | None = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0 None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
) )
@@ -385,23 +399,23 @@ class CandlelightCSSCreate(_CSSCreateBase):
source_type: Literal["candlelight"] = "candlelight" source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]") color: Any = Field(default=None, description="Candle color [R,G,B]")
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)") intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
num_candles: Optional[int] = Field( num_candles: int | None = Field(
None, description="Number of candle sources (1-20)", ge=1, le=20 None, description="Number of candle sources (1-20)", ge=1, le=20
) )
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)") speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)") wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
candle_type: Optional[str] = Field(None, description="Candle type preset") candle_type: str | None = Field(None, description="Candle type preset")
class ProcessedCSSCreate(_CSSCreateBase): class ProcessedCSSCreate(_CSSCreateBase):
source_type: Literal["processed"] = "processed" source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID") input_source_id: str | None = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID") processing_template_id: str | None = Field(None, description="Processing template ID")
class WeatherCSSCreate(_CSSCreateBase): class WeatherCSSCreate(_CSSCreateBase):
source_type: Literal["weather"] = "weather" source_type: Literal["weather"] = "weather"
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID") weather_source_id: str | None = Field(None, description="Weather source entity ID")
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)") speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)") temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
@@ -409,42 +423,47 @@ class WeatherCSSCreate(_CSSCreateBase):
class KeyColorsCSSCreate(_CSSCreateBase): class KeyColorsCSSCreate(_CSSCreateBase):
source_type: Literal["key_colors"] = "key_colors" source_type: Literal["key_colors"] = "key_colors"
picture_source_id: str = Field(default="", description="Picture source ID") picture_source_id: str = Field(default="", description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions") rectangles: List[dict] | None = Field(None, description="Named screen regions")
interpolation_mode: str = Field(default="average", description="Interpolation mode") interpolation_mode: str = Field(default="average", description="Interpolation mode")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)") brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field( brightness_value_source_id: str | None = Field(
None, description="Dynamic brightness value source ID" None, description="Dynamic brightness value source ID"
) )
class MathWaveCSSCreate(_CSSCreateBase): class MathWaveCSSCreate(_CSSCreateBase):
source_type: Literal["math_wave"] = "math_wave" source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions") waves: List[dict] | None = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable, 0.1-10.0)") speed: Any = Field(default=None, description="Global speed multiplier (bindable, 0.1-10.0)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping") gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSCreate(_CSSCreateBase):
source_type: Literal["game_event"] = "game_event"
game_integration_id: str | None = Field(None, description="Game integration entity ID")
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
event_mappings: List[dict] | None = Field(None, description="Event-to-effect mappings")
ColorStripSourceCreate = Annotated[ ColorStripSourceCreate = Annotated[
Union[ Annotated[PictureCSSCreate, Tag("picture")]
Annotated[PictureCSSCreate, Tag("picture")], | Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")]
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")], | Annotated[SingleColorCSSCreate, Tag("single_color")]
Annotated[StaticCSSCreate, Tag("static")], | Annotated[GradientCSSCreate, Tag("gradient")]
Annotated[GradientCSSCreate, Tag("gradient")], | Annotated[EffectCSSCreate, Tag("effect")]
Annotated[ColorCycleCSSCreate, Tag("color_cycle")], | Annotated[CompositeCSSCreate, Tag("composite")]
Annotated[EffectCSSCreate, Tag("effect")], | Annotated[MappedCSSCreate, Tag("mapped")]
Annotated[CompositeCSSCreate, Tag("composite")], | Annotated[AudioCSSCreate, Tag("audio")]
Annotated[MappedCSSCreate, Tag("mapped")], | Annotated[ApiInputCSSCreate, Tag("api_input")]
Annotated[AudioCSSCreate, Tag("audio")], | Annotated[NotificationCSSCreate, Tag("notification")]
Annotated[ApiInputCSSCreate, Tag("api_input")], | Annotated[DaylightCSSCreate, Tag("daylight")]
Annotated[NotificationCSSCreate, Tag("notification")], | Annotated[CandlelightCSSCreate, Tag("candlelight")]
Annotated[DaylightCSSCreate, Tag("daylight")], | Annotated[ProcessedCSSCreate, Tag("processed")]
Annotated[CandlelightCSSCreate, Tag("candlelight")], | Annotated[WeatherCSSCreate, Tag("weather")]
Annotated[ProcessedCSSCreate, Tag("processed")], | Annotated[KeyColorsCSSCreate, Tag("key_colors")]
Annotated[WeatherCSSCreate, Tag("weather")], | Annotated[MathWaveCSSCreate, Tag("math_wave")]
Annotated[KeyColorsCSSCreate, Tag("key_colors")], | Annotated[GameEventCSSCreate, Tag("game_event")],
Annotated[MathWaveCSSCreate, Tag("math_wave")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -457,80 +476,85 @@ ColorStripSourceCreate = Annotated[
class _CSSUpdateBase(BaseModel): class _CSSUpdateBase(BaseModel):
"""Shared fields for all color strip source update requests.""" """Shared fields for all color strip source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto)", ge=0) led_count: int | None = Field(None, description="Total LED count (0 = auto)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID") clock_id: str | None = Field(None, description="Optional sync clock ID")
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
source_type: Literal["picture"] = "picture" source_type: Literal["picture"] = "picture"
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: str | None = Field(None, description="Picture source ID")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") interpolation_mode: str | None = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSUpdate(_CSSUpdateBase): class PictureAdvancedCSSUpdate(_CSSUpdateBase):
source_type: Literal["picture_advanced"] = "picture_advanced" source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") interpolation_mode: str | None = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class StaticCSSUpdate(_CSSUpdateBase): class SingleColorCSSUpdate(_CSSUpdateBase):
source_type: Literal["static"] = "static" source_type: Literal["single_color"] = "single_color"
color: Any = Field(default=None, description="Static RGB color [R,G,B]") color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSUpdate(_CSSUpdateBase): class GradientCSSUpdate(_CSSUpdateBase):
source_type: Literal["gradient"] = "gradient" source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops") stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing") easing: str | None = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = 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: str | None = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette") palette: str | None = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color") color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)") intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)") scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops") custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSUpdate(_CSSUpdateBase): class CompositeCSSUpdate(_CSSUpdateBase):
source_type: Literal["composite"] = "composite" source_type: Literal["composite"] = "composite"
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") layers: List[CompositeLayer] | None = Field(None, description="Layers for composite type")
class MappedCSSUpdate(_CSSUpdateBase): class MappedCSSUpdate(_CSSUpdateBase):
source_type: Literal["mapped"] = "mapped" source_type: Literal["mapped"] = "mapped"
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") zones: List[MappedZone] | None = Field(None, description="Zones for mapped type")
class AudioCSSUpdate(_CSSUpdateBase): class AudioCSSUpdate(_CSSUpdateBase):
source_type: Literal["audio"] = "audio" source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode") visualization_mode: str | None = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") audio_source_id: str | None = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)") sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette") palette: str | None = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color") color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]") color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode") mirror: bool | None = Field(None, description="Mirror mode")
beat_decay: Any = Field(default=None, description="Beat pulse decay rate (music modes)") beat_decay: Any = Field(default=None, description="Beat pulse decay rate (music modes)")
@@ -538,23 +562,23 @@ class ApiInputCSSUpdate(_CSSUpdateBase):
source_type: Literal["api_input"] = "api_input" source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]") fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]")
timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)") timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode") interpolation: str | None = Field(None, description="LED count interpolation mode")
class NotificationCSSUpdate(_CSSUpdateBase): class NotificationCSSUpdate(_CSSUpdateBase):
source_type: Literal["notification"] = "notification" source_type: Literal["notification"] = "notification"
notification_effect: Optional[str] = Field(None, description="Notification effect") notification_effect: str | None = Field(None, description="Notification effect")
duration_ms: Any = Field(default=None, description="Effect duration in milliseconds") duration_ms: Any = Field(default=None, description="Effect duration in milliseconds")
default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field( default_color: List[int] | Dict[str, Any] | str | None = Field(
None, description="Default color" None, description="Default color"
) )
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors") app_colors: Dict[str, str] | None = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode") app_filter_mode: str | None = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") app_filter_list: List[str] | None = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications") os_listener: bool | None = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(default=None, description="Global notification sound volume") sound_volume: Any = Field(default=None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field( app_sounds: Dict[str, AppSoundOverride] | None = Field(
None, description="Per-app sound overrides" None, description="Per-app sound overrides"
) )
@@ -562,9 +586,9 @@ class NotificationCSSUpdate(_CSSUpdateBase):
class DaylightCSSUpdate(_CSSUpdateBase): class DaylightCSSUpdate(_CSSUpdateBase):
source_type: Literal["daylight"] = "daylight" source_type: Literal["daylight"] = "daylight"
speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)") speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time") use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0) latitude: float | None = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field( longitude: float | None = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0 None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
) )
@@ -573,66 +597,71 @@ class CandlelightCSSUpdate(_CSSUpdateBase):
source_type: Literal["candlelight"] = "candlelight" source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]") color: Any = Field(default=None, description="Candle color [R,G,B]")
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)") intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
num_candles: Optional[int] = Field( num_candles: int | None = Field(
None, description="Number of candle sources (1-20)", ge=1, le=20 None, description="Number of candle sources (1-20)", ge=1, le=20
) )
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)") speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)") wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
candle_type: Optional[str] = Field(None, description="Candle type preset") candle_type: str | None = Field(None, description="Candle type preset")
class ProcessedCSSUpdate(_CSSUpdateBase): class ProcessedCSSUpdate(_CSSUpdateBase):
source_type: Literal["processed"] = "processed" source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID") input_source_id: str | None = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID") processing_template_id: str | None = Field(None, description="Processing template ID")
class WeatherCSSUpdate(_CSSUpdateBase): class WeatherCSSUpdate(_CSSUpdateBase):
source_type: Literal["weather"] = "weather" source_type: Literal["weather"] = "weather"
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID") weather_source_id: str | None = Field(None, description="Weather source entity ID")
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)") speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)") temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
class KeyColorsCSSUpdate(_CSSUpdateBase): class KeyColorsCSSUpdate(_CSSUpdateBase):
source_type: Literal["key_colors"] = "key_colors" source_type: Literal["key_colors"] = "key_colors"
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: str | None = Field(None, description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions") rectangles: List[dict] | None = Field(None, description="Named screen regions")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") interpolation_mode: str | None = Field(None, description="Interpolation mode")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)") brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field( brightness_value_source_id: str | None = Field(
None, description="Dynamic brightness value source ID" None, description="Dynamic brightness value source ID"
) )
class MathWaveCSSUpdate(_CSSUpdateBase): class MathWaveCSSUpdate(_CSSUpdateBase):
source_type: Literal["math_wave"] = "math_wave" source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions") waves: List[dict] | None = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable)") speed: Any = Field(default=None, description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping") gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSUpdate(_CSSUpdateBase):
source_type: Literal["game_event"] = "game_event"
game_integration_id: str | None = Field(None, description="Game integration entity ID")
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
event_mappings: List[dict] | None = Field(None, description="Event-to-effect mappings")
ColorStripSourceUpdate = Annotated[ ColorStripSourceUpdate = Annotated[
Union[ Annotated[PictureCSSUpdate, Tag("picture")]
Annotated[PictureCSSUpdate, Tag("picture")], | Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")]
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")], | Annotated[SingleColorCSSUpdate, Tag("single_color")]
Annotated[StaticCSSUpdate, Tag("static")], | Annotated[GradientCSSUpdate, Tag("gradient")]
Annotated[GradientCSSUpdate, Tag("gradient")], | Annotated[EffectCSSUpdate, Tag("effect")]
Annotated[ColorCycleCSSUpdate, Tag("color_cycle")], | Annotated[CompositeCSSUpdate, Tag("composite")]
Annotated[EffectCSSUpdate, Tag("effect")], | Annotated[MappedCSSUpdate, Tag("mapped")]
Annotated[CompositeCSSUpdate, Tag("composite")], | Annotated[AudioCSSUpdate, Tag("audio")]
Annotated[MappedCSSUpdate, Tag("mapped")], | Annotated[ApiInputCSSUpdate, Tag("api_input")]
Annotated[AudioCSSUpdate, Tag("audio")], | Annotated[NotificationCSSUpdate, Tag("notification")]
Annotated[ApiInputCSSUpdate, Tag("api_input")], | Annotated[DaylightCSSUpdate, Tag("daylight")]
Annotated[NotificationCSSUpdate, Tag("notification")], | Annotated[CandlelightCSSUpdate, Tag("candlelight")]
Annotated[DaylightCSSUpdate, Tag("daylight")], | Annotated[ProcessedCSSUpdate, Tag("processed")]
Annotated[CandlelightCSSUpdate, Tag("candlelight")], | Annotated[WeatherCSSUpdate, Tag("weather")]
Annotated[ProcessedCSSUpdate, Tag("processed")], | Annotated[KeyColorsCSSUpdate, Tag("key_colors")]
Annotated[WeatherCSSUpdate, Tag("weather")], | Annotated[MathWaveCSSUpdate, Tag("math_wave")]
Annotated[KeyColorsCSSUpdate, Tag("key_colors")], | Annotated[GameEventCSSUpdate, Tag("game_event")],
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -655,13 +684,25 @@ 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: int | None = Field(
None, ge=0, description="Starting LED index (default 0 = beginning of strip)"
)
length: int | None = 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: List[int] | None = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field( colors: List[List[int]] | None = Field(
None, description="Colors for per_pixel/gradient [[R,G,B],...]" None, description="Colors for per_pixel/gradient [[R,G,B],...]"
) )
@@ -694,12 +735,10 @@ class ColorPushRequest(BaseModel):
At least one must be provided. At least one must be provided.
""" """
colors: Optional[List[List[int]]] = Field( colors: List[List[int]] | None = Field(
None, description="LED color array [[R,G,B], ...] (0-255 each)" None, description="LED color array [[R,G,B], ...] (0-255 each)"
) )
segments: Optional[List[SegmentPayload]] = Field( segments: List[SegmentPayload] | None = Field(None, description="Segment-based color updates")
None, description="Segment-based color updates"
)
@model_validator(mode="after") @model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest": def _require_colors_or_segments(self) -> "ColorPushRequest":
@@ -711,8 +750,8 @@ class ColorPushRequest(BaseModel):
class NotifyRequest(BaseModel): class NotifyRequest(BaseModel):
"""Request to trigger a notification on a notification color strip source.""" """Request to trigger a notification on a notification color strip source."""
app: Optional[str] = Field(None, description="App name for color lookup") app: str | None = Field(None, description="App name for color lookup")
color: Optional[str] = Field(None, description="Hex color override (#RRGGBB)") color: str | None = Field(None, description="Hex color override (#RRGGBB)")
class CSSCalibrationTestRequest(BaseModel): class CSSCalibrationTestRequest(BaseModel):
+6 -6
View File
@@ -1,7 +1,7 @@
"""Shared schemas used across multiple route modules.""" """Shared schemas used across multiple route modules."""
from datetime import datetime from datetime import datetime
from typing import Dict, Optional from typing import Dict
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -11,7 +11,7 @@ class ErrorResponse(BaseModel):
error: str = Field(description="Error type") error: str = Field(description="Error type")
message: str = Field(description="Error message") message: str = Field(description="Error message")
detail: Optional[Dict] = Field(None, description="Additional error details") detail: Dict | None = Field(None, description="Additional error details")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp") timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp")
@@ -19,11 +19,11 @@ class CaptureImage(BaseModel):
"""Captured image with metadata.""" """Captured image with metadata."""
image: str = Field(description="Base64-encoded thumbnail image data") image: str = Field(description="Base64-encoded thumbnail image data")
full_image: Optional[str] = Field(None, description="Base64-encoded full-resolution image data") full_image: str | None = Field(None, description="Base64-encoded full-resolution image data")
width: int = Field(description="Original image width in pixels") width: int = Field(description="Original image width in pixels")
height: int = Field(description="Original image height in pixels") height: int = Field(description="Original image height in pixels")
thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)") thumbnail_width: int | None = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)") thumbnail_height: int | None = Field(None, description="Thumbnail height (if resized)")
class BorderExtraction(BaseModel): class BorderExtraction(BaseModel):
@@ -48,7 +48,7 @@ class TemplateTestResponse(BaseModel):
"""Response from template test.""" """Response from template test."""
full_capture: CaptureImage = Field(description="Full screen capture with thumbnail") full_capture: CaptureImage = Field(description="Full screen capture with thumbnail")
border_extraction: Optional[BorderExtraction] = Field( border_extraction: BorderExtraction | None = Field(
None, description="Extracted border images (deprecated)" None, description="Extracted border images (deprecated)"
) )
performance: PerformanceMetrics = Field(description="Performance metrics") performance: PerformanceMetrics = Field(description="Performance metrics")
+226 -75
View File
@@ -1,7 +1,7 @@
"""Device-related schemas (CRUD, calibration, device state).""" """Device-related schemas (CRUD, calibration, device state)."""
from datetime import datetime from datetime import datetime
from typing import Dict, List, Literal, Optional from typing import Dict, List, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -10,136 +10,258 @@ class DeviceCreate(BaseModel):
"""Request to create/attach an LED device.""" """Request to create/attach an LED device."""
name: str = Field(description="Device name", min_length=1, max_length=100) name: str = Field(description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field( url: str | None = Field(
None, None,
description="Device URL (e.g., http://192.168.1.100 or COM3). Not required for group devices.", description="Device URL (e.g., http://192.168.1.100 or COM3). Not required for group devices.",
) )
device_type: str = Field(default="wled", description="LED device type (e.g., wled, adalight)") device_type: str = Field(default="wled", description="LED device type (e.g., wled, adalight)")
led_count: Optional[int] = Field( led_count: int | None = Field(
None, ge=1, le=10000, description="Number of LEDs (required for adalight)" None, ge=1, le=10000, description="Number of LEDs (required for adalight)"
) )
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)") baud_rate: int | None = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field( auto_shutdown: bool | None = Field(
default=None, default=None,
description="Turn off device when server stops (defaults to true for adalight)", description="Turn off device when server stops (defaults to true for adalight)",
) )
send_latency_ms: Optional[int] = Field( send_latency_ms: int | None = Field(
None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)" None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)"
) )
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)") rgbw: bool | None = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate") zone_mode: str | None = Field(None, description="OpenRGB zone mode: combined or separate")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
# DMX (Art-Net / sACN) fields # DMX (Art-Net / sACN) fields
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn") dmx_protocol: str | None = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field( dmx_start_universe: int | None = Field(None, ge=0, le=32767, description="DMX start universe")
None, ge=0, le=32767, description="DMX start universe" dmx_start_channel: int | None = Field(
)
dmx_start_channel: Optional[int] = Field(
None, ge=1, le=512, description="DMX start channel (1-512)" None, ge=1, le=512, description="DMX start channel (1-512)"
) )
# DDP fields
ddp_port: int | None = Field(
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
)
ddp_destination_id: int | None = Field(
None, ge=0, le=255, description="DDP destination ID (default 1 = display)"
)
ddp_color_order: int | None = Field(
None,
ge=0,
le=5,
description="DDP color order: 0=GRB 1=RGB 2=BRG 3=RBG 4=BGR 5=GBR (most receivers expect RGB)",
)
# ESP-NOW fields # ESP-NOW fields
espnow_peer_mac: Optional[str] = Field( espnow_peer_mac: str | None = Field(
None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)" None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)"
) )
espnow_channel: Optional[int] = Field( espnow_channel: int | None = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)")
None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)"
)
# Philips Hue fields # Philips Hue fields
hue_username: Optional[str] = Field(None, description="Hue bridge username (from pairing)") hue_username: str | None = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key (hex)") hue_client_key: str | None = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: Optional[str] = Field( hue_entertainment_group_id: str | None = Field(
None, description="Hue entertainment group/zone ID" None, description="Hue entertainment group/zone ID"
) )
# Yeelight fields
yeelight_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="Yeelight client-side rate limit between commands in ms (default 500)",
)
# WiZ fields
wiz_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="WiZ client-side rate limit between commands in ms (default 50)",
)
# LIFX fields
lifx_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="LIFX client-side rate limit between commands in ms (default 50)",
)
# Govee fields
govee_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="Govee client-side rate limit between commands in ms (default 50)",
)
# OPC fields
opc_channel: int | None = Field(
None,
ge=0,
le=255,
description="OPC channel (0 = broadcast to all channels on the server)",
)
# Nanoleaf fields
nanoleaf_token: str | None = Field(
None,
max_length=512,
description="Nanoleaf auth token returned by the pairing handshake",
)
nanoleaf_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
)
# SPI Direct fields # SPI Direct fields
spi_speed_hz: Optional[int] = Field( spi_speed_hz: int | None = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz" None, ge=100000, le=4000000, description="SPI clock speed in Hz"
) )
spi_led_type: Optional[str] = Field( spi_led_type: str | None = Field(
None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW" None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW"
) )
# Razer Chroma fields # Razer Chroma fields
chroma_device_type: Optional[str] = Field( chroma_device_type: str | None = Field(
None, None,
description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad", description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad",
) )
# SteelSeries GameSense fields # SteelSeries GameSense fields
gamesense_device_type: Optional[str] = Field( gamesense_device_type: str | None = Field(
None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator" None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator"
) )
# BLE controller fields # BLE controller fields
ble_family: Optional[str] = Field( ble_family: str | None = Field(
None, None,
description="BLE protocol family: sp110e, triones, zengge, govee", description="BLE protocol family: sp110e, triones, zengge, govee",
) )
ble_govee_key: Optional[str] = Field( ble_govee_key: str | None = Field(
None, None,
description="Govee AES key (hex) — required for encrypted Govee firmware", description="Govee AES key (hex) — required for encrypted Govee firmware",
) )
default_css_processing_template_id: Optional[str] = Field( default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID" None, description="Default color strip processing template ID"
) )
# Group device fields # Group device fields
group_device_ids: Optional[List[str]] = Field( group_device_ids: List[str] | None = Field(
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( group_mode: str | None = Field(
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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.",
)
icon_color: str | None = 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):
"""Request to update device information.""" """Request to update device information."""
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100) name: str | None = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="Device URL or serial port") url: str | None = Field(None, description="Device URL or serial port")
enabled: Optional[bool] = Field(None, description="Whether device is enabled") enabled: bool | None = Field(None, description="Whether device is enabled")
led_count: Optional[int] = Field( led_count: int | None = Field(
None, None,
ge=1, ge=1,
le=10000, le=10000,
description="Number of LEDs (for devices with manual_led_count capability)", description="Number of LEDs (for devices with manual_led_count capability)",
) )
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)") baud_rate: int | None = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops") auto_shutdown: bool | None = Field(None, description="Turn off device when server stops")
send_latency_ms: Optional[int] = Field( send_latency_ms: int | None = Field(
None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)" None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)"
) )
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)") rgbw: bool | None = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate") zone_mode: str | None = Field(None, description="OpenRGB zone mode: combined or separate")
tags: Optional[List[str]] = None tags: List[str] | None = None
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn") dmx_protocol: str | None = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field( dmx_start_universe: int | None = Field(None, ge=0, le=32767, description="DMX start universe")
None, ge=0, le=32767, description="DMX start universe" dmx_start_channel: int | None = Field(
)
dmx_start_channel: Optional[int] = Field(
None, ge=1, le=512, description="DMX start channel (1-512)" None, ge=1, le=512, description="DMX start channel (1-512)"
) )
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address") ddp_port: int | None = Field(
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel") None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
hue_username: Optional[str] = Field(None, description="Hue bridge username")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: Optional[str] = Field(
None, description="Hue entertainment group ID"
) )
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed") ddp_destination_id: int | None = Field(None, ge=0, le=255, description="DDP destination ID")
spi_led_type: Optional[str] = Field(None, description="LED chipset type") ddp_color_order: int | None = Field(None, ge=0, le=5, description="DDP color order code")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type") espnow_peer_mac: str | None = Field(None, description="ESP-NOW peer MAC address")
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type") espnow_channel: int | None = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
ble_family: Optional[str] = Field( hue_username: str | None = Field(None, description="Hue bridge username")
hue_client_key: str | None = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
yeelight_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
)
wiz_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="WiZ client-side rate limit in ms"
)
lifx_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
)
govee_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
)
opc_channel: int | None = Field(None, ge=0, le=255, description="OPC channel (0 = broadcast)")
nanoleaf_token: str | None = Field(None, max_length=512, description="Nanoleaf auth token")
nanoleaf_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
)
spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: str | None = Field(None, description="LED chipset type")
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
gamesense_device_type: str | None = Field(None, description="GameSense device type")
ble_family: str | None = Field(
None, description="BLE protocol family: sp110e, triones, zengge, govee" None, description="BLE protocol family: sp110e, triones, zengge, govee"
) )
ble_govee_key: Optional[str] = Field( ble_govee_key: str | None = Field(
None, description="Govee AES key (hex) — required for encrypted Govee firmware" None, description="Govee AES key (hex) — required for encrypted Govee firmware"
) )
default_css_processing_template_id: Optional[str] = Field( default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID" None, description="Default color strip processing template ID"
) )
# Group device fields # Group device fields
group_device_ids: Optional[List[str]] = Field( group_device_ids: List[str] | None = Field(
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: str | None = Field(None, description="Group mode: sequence or independent")
# Custom card icon
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class PairDeviceRequest(BaseModel):
"""Initiate a pairing handshake with a device before creating it.
The caller is expected to have just performed the device's physical
pairing action (e.g. holding the power button on a Nanoleaf for 5 s,
pressing the Hue bridge link button). The response carries any
provider-specific fields the frontend must include in the subsequent
``POST /api/v1/devices`` payload typically an auth token.
"""
device_type: str = Field(description="Device type identifier (e.g. 'nanoleaf')")
url: str = Field(description="Device URL (e.g. 'nanoleaf://192.168.1.50')")
class PairDeviceResponse(BaseModel):
"""Successful pairing result. ``fields`` is merged into the create payload."""
fields: Dict[str, object] = Field(
default_factory=dict,
description=(
"Provider-specific fields to include in the subsequent device-create "
"request (e.g. {'nanoleaf_token': 'abc...'})."
),
)
class CalibrationLineSchema(BaseModel): class CalibrationLineSchema(BaseModel):
@@ -162,7 +284,7 @@ class Calibration(BaseModel):
description="Calibration mode: simple (4-edge) or advanced (multi-source lines)", description="Calibration mode: simple (4-edge) or advanced (multi-source lines)",
) )
# Advanced mode: ordered list of lines # Advanced mode: ordered list of lines
lines: Optional[List[CalibrationLineSchema]] = Field( lines: List[CalibrationLineSchema] | None = Field(
default=None, description="Line list for advanced mode (ignored in simple mode)" default=None, description="Line list for advanced mode (ignored in simple mode)"
) )
# Simple mode fields # Simple mode fields
@@ -256,7 +378,7 @@ class DeviceResponse(BaseModel):
device_type: str = Field(default="wled", description="LED device type") device_type: str = Field(default="wled", description="LED device type")
led_count: int = Field(description="Total number of LEDs") led_count: int = Field(description="Total number of LEDs")
enabled: bool = Field(description="Whether device is enabled") enabled: bool = Field(description="Whether device is enabled")
baud_rate: Optional[int] = Field(None, description="Serial baud rate") baud_rate: int | None = Field(None, description="Serial baud rate")
auto_shutdown: bool = Field( auto_shutdown: bool = Field(
default=False, description="Restore device to idle state when targets stop" default=False, description="Restore device to idle state when targets stop"
) )
@@ -272,11 +394,38 @@ class DeviceResponse(BaseModel):
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn") dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
dmx_start_universe: int = Field(default=0, description="DMX start universe") dmx_start_universe: int = Field(default=0, description="DMX start universe")
dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)") dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)")
ddp_port: int = Field(default=0, description="DDP UDP port (0 = protocol default 4048)")
ddp_destination_id: int = Field(default=1, description="DDP destination ID")
ddp_color_order: int = Field(default=1, description="DDP color order code (1 = RGB)")
espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address") espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address")
espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel") espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel")
hue_username: str = Field(default="", description="Hue bridge username") hue_paired: bool = Field(
hue_client_key: str = Field(default="", description="Hue entertainment client key") default=False,
description=(
"Whether the Hue bridge has been paired (i.e. a username/client_key "
"is on file). The actual credentials are intentionally not exposed "
"in the response -- to re-pair, delete and re-add the device."
),
)
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID") hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
yeelight_min_interval_ms: int = Field(
default=500, description="Yeelight client-side rate limit in ms"
)
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
nanoleaf_paired: bool = Field(
default=False,
description=(
"Whether the Nanoleaf auth token has been issued by the pairing "
"handshake. The token itself is intentionally not exposed in the "
"response -- to re-pair, delete and re-add the device."
),
)
nanoleaf_min_interval_ms: int = Field(
default=100, description="Nanoleaf client-side rate limit in ms"
)
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz") spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type") spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type") chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
@@ -295,6 +444,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")
@@ -312,19 +463,19 @@ class DeviceStateResponse(BaseModel):
device_id: str = Field(description="Device ID") device_id: str = Field(description="Device ID")
device_type: str = Field(default="wled", description="LED device type") device_type: str = Field(default="wled", description="LED device type")
device_online: bool = Field(default=False, description="Whether device is reachable") device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms") device_latency_ms: float | None = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware") device_name: str | None = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version") device_version: str | None = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device") device_led_count: int | None = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs") device_rgbw: bool | None = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field( device_led_type: str | None = Field(
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)" None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
) )
device_fps: Optional[int] = Field( device_fps: int | None = Field(
None, description="Device-reported FPS (WLED internal refresh rate)" None, description="Device-reported FPS (WLED internal refresh rate)"
) )
device_last_checked: Optional[datetime] = Field(None, description="Last health check time") device_last_checked: datetime | None = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error") device_error: str | None = Field(None, description="Last health check error")
test_mode: bool = Field(default=False, description="Whether calibration test mode is active") test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
test_mode_edges: List[str] = Field( test_mode_edges: List[str] = Field(
default_factory=list, description="Currently lit edges in test mode" default_factory=list, description="Currently lit edges in test mode"
@@ -339,9 +490,9 @@ class DiscoveredDeviceResponse(BaseModel):
device_type: str = Field(default="wled", description="Device type") device_type: str = Field(default="wled", description="Device type")
ip: str = Field(description="IP address") ip: str = Field(description="IP address")
mac: str = Field(default="", description="MAC address") mac: str = Field(default="", description="MAC address")
led_count: Optional[int] = Field(None, description="LED count (if reachable)") led_count: int | None = Field(None, description="LED count (if reachable)")
version: Optional[str] = Field(None, description="Firmware version") version: str | None = Field(None, description="Firmware version")
ble_family: Optional[str] = Field( ble_family: str | None = Field(
None, description="Detected BLE protocol family (sp110e/triones/zengge/govee)" None, description="Detected BLE protocol family (sp110e/triones/zengge/govee)"
) )
already_added: bool = Field( already_added: bool = Field(
+3 -3
View File
@@ -1,6 +1,6 @@
"""Filter-related schemas.""" """Filter-related schemas."""
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -22,10 +22,10 @@ class FilterOptionDefSchema(BaseModel):
min_value: Any = Field(description="Minimum value") min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value") max_value: Any = Field(description="Maximum value")
step: Any = Field(description="Step increment") step: Any = Field(description="Step increment")
choices: Optional[List[Dict[str, str]]] = Field( choices: List[Dict[str, str]] | None = Field(
default=None, description="Available choices for select type" default=None, description="Available choices for select type"
) )
max_length: Optional[int] = Field( max_length: int | None = Field(
default=None, description="Maximum string length for string type" default=None, description="Maximum string length for string type"
) )
@@ -1,11 +1,10 @@
"""Pydantic schemas for game integration API endpoints.""" """Pydantic schemas for game integration API endpoints."""
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# ── Event Mapping ────────────────────────────────────────────────────────── # ── Event Mapping ──────────────────────────────────────────────────────────
@@ -40,22 +39,42 @@ class GameIntegrationCreate(BaseModel):
event_mappings: List[EventMappingSchema] = Field( event_mappings: List[EventMappingSchema] = Field(
default_factory=list, description="Event-to-effect mappings" default_factory=list, description="Event-to-effect mappings"
) )
description: Optional[str] = Field(None, description="Integration description", max_length=500) description: str | None = 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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
"""Request to update a game integration config.""" """Request to update a game integration config."""
name: Optional[str] = Field(None, description="Integration name", min_length=1, max_length=100) name: str | None = Field(None, description="Integration name", min_length=1, max_length=100)
adapter_type: Optional[str] = Field(None, description="Adapter type identifier", min_length=1) adapter_type: str | None = Field(None, description="Adapter type identifier", min_length=1)
enabled: Optional[bool] = Field(None, description="Whether integration is active") enabled: bool | None = Field(None, description="Whether integration is active")
adapter_config: Optional[Dict[str, Any]] = Field(None, description="Adapter-specific settings") adapter_config: Dict[str, Any] | None = Field(None, description="Adapter-specific settings")
event_mappings: Optional[List[EventMappingSchema]] = Field( event_mappings: List[EventMappingSchema] | None = Field(
None, description="Event-to-effect mappings" None, description="Event-to-effect mappings"
) )
description: Optional[str] = Field(None, description="Integration description", max_length=500) description: str | None = Field(None, description="Integration description", max_length=500)
tags: Optional[List[str]] = Field(None, description="User-defined tags") tags: List[str] | None = Field(None, description="User-defined tags")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -69,8 +88,18 @@ class GameIntegrationResponse(BaseModel):
event_mappings: List[EventMappingSchema] = Field(description="Event-to-effect mappings") event_mappings: List[EventMappingSchema] = Field(description="Event-to-effect mappings")
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="Integration description") description: str | None = 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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
class GameIntegrationListResponse(BaseModel): class GameIntegrationListResponse(BaseModel):
@@ -128,7 +157,7 @@ class GameIntegrationStatusResponse(BaseModel):
integration_id: str = Field(description="Integration ID") integration_id: str = Field(description="Integration ID")
enabled: bool = Field(description="Whether integration is active") enabled: bool = Field(description="Whether integration is active")
connected: bool = Field(description="Whether adapter is currently receiving data") connected: bool = Field(description="Whether adapter is currently receiving data")
last_event_time: Optional[float] = Field(None, description="Monotonic timestamp of last event") last_event_time: float | None = Field(None, description="Monotonic timestamp of last event")
event_count: int = Field(default=0, description="Total events received") event_count: int = Field(default=0, description="Total events received")
event_counts_by_type: Dict[str, int] = Field( event_counts_by_type: Dict[str, int] = Field(
default_factory=dict, description="Event counts per event type" default_factory=dict, description="Event counts per event type"
+37 -7
View File
@@ -1,7 +1,7 @@
"""Gradient schemas (CRUD).""" """Gradient schemas (CRUD)."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -18,17 +18,37 @@ class GradientCreate(BaseModel):
name: str = Field(description="Gradient name", min_length=1, max_length=100) name: str = Field(description="Gradient name", min_length=1, max_length=100)
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: str | None = 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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
"""Request to update a gradient.""" """Request to update a gradient."""
name: Optional[str] = Field(None, description="Gradient name", min_length=1, max_length=100) name: str | None = Field(None, description="Gradient name", min_length=1, max_length=100)
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2) stops: List[GradientStopSchema] | None = Field(None, description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -38,10 +58,20 @@ class GradientResponse(BaseModel):
name: str = Field(description="Gradient name") name: str = Field(description="Gradient name")
stops: List[GradientStopSchema] = Field(description="Color stops") stops: List[GradientStopSchema] = Field(description="Color stops")
is_builtin: bool = Field(description="Whether this is a built-in gradient") is_builtin: bool = Field(description="Whether this is a built-in gradient")
description: Optional[str] = Field(None, description="Description") description: str | None = 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")
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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -1,7 +1,7 @@
"""Home Assistant source schemas (CRUD + test + entities).""" """Home Assistant source schemas (CRUD + test + entities)."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -16,20 +16,40 @@ class HomeAssistantSourceCreate(BaseModel):
entity_filters: List[str] = Field( entity_filters: List[str] = Field(
default_factory=list, description="Entity ID filter patterns (e.g. ['sensor.*'])" default_factory=list, description="Entity ID filter patterns (e.g. ['sensor.*'])"
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = 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: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
"""Request to update a Home Assistant source.""" """Request to update a Home Assistant source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
host: Optional[str] = Field(None, description="HA host:port", min_length=1) host: str | None = Field(None, description="HA host:port", min_length=1)
token: Optional[str] = Field(None, description="Long-Lived Access Token", min_length=1) token: str | None = Field(None, description="Long-Lived Access Token", min_length=1)
use_ssl: Optional[bool] = Field(None, description="Use wss://") use_ssl: bool | None = Field(None, description="Use wss://")
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns") entity_filters: List[str] | None = Field(None, description="Entity ID filter patterns")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = 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):
@@ -42,11 +62,21 @@ class HomeAssistantSourceResponse(BaseModel):
entity_filters: List[str] = Field(default_factory=list, description="Entity filter patterns") entity_filters: List[str] = Field(default_factory=list, description="Entity filter patterns")
connected: bool = Field(default=False, description="Whether the WebSocket connection is active") connected: bool = Field(default=False, description="Whether the WebSocket connection is active")
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: str | None = 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 | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: str | None = 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: str | None = Field(
None, None,
description=( description=(
"Long-Lived Access Token. Redacted as '***' unless the request " "Long-Lived Access Token. Redacted as '***' unless the request "
@@ -82,9 +112,9 @@ class HomeAssistantTestResponse(BaseModel):
"""Connection test result.""" """Connection test result."""
success: bool = Field(description="Whether connection and auth succeeded") success: bool = Field(description="Whether connection and auth succeeded")
ha_version: Optional[str] = Field(None, description="Home Assistant version") ha_version: str | None = Field(None, description="Home Assistant version")
entity_count: int = Field(default=0, description="Number of entities found") entity_count: int = Field(default=0, description="Number of entities found")
error: Optional[str] = Field(None, description="Error message if connection failed") error: str | None = Field(None, description="Error message if connection failed")
class HomeAssistantConnectionStatus(BaseModel): class HomeAssistantConnectionStatus(BaseModel):
@@ -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):

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