Compare commits

...

76 Commits

Author SHA1 Message Date
alexei.dolgolyov 838c95484d chore: release v0.9.0
Build Release / create-release (push) Successful in 4s
Build Android APK / build-android (push) Failing after 22s
Build Release / build-linux (push) Successful in 2m54s
Build Release / build-windows (push) Successful in 4m50s
Build Release / build-docker (push) Successful in 6m34s
Build Release / publish-release (push) Successful in 4s
2026-06-23 14:48:37 +03:00
alexei.dolgolyov 14822fb6a0 Merge feat/roadmap-2026-06-19: pre-release review fixes (2026-06-23) 2026-06-23 14:21:42 +03:00
alexei.dolgolyov 0c096db639 fix: address pre-release review findings (2026-06-23)
Hardening from the pre-release review of the 06-19/06-23 roadmap batches:
solar timezone crash, webhook header CRLF, MQTT topic-prefix injection,
get_stats thread-safe copy, MQTT discovery lock, reactive_mode Literal,
and calibration modal accessibility. Adds regression coverage in
test_release_review_2026_06_23.py.
2026-06-23 14:21:25 +03:00
alexei.dolgolyov c1eeefcf06 Merge feat/roadmap-2026-06-19: roadmap batches (solar/image-quality/per-pixel + integrations)
Round one (6745e25): SolarRule, arm64 Docker, LoL poller, color-harmony
gradients, reactive palette, linear-light, dithering, Nanoleaf extControl.
Round two (39b0554): LIFX multizone/Tile, Hue segment mapping, outbound
webhook action, HA MQTT auto-discovery.
2026-06-23 00:55:49 +03:00
alexei.dolgolyov 39b0554444 feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations
A) Per-pixel smart-lights
- LIFX multizone (SetExtendedColorZones msg 510, <=82 zones) + Tile
  (SetTileState64 715), auto-detected on connect with single-colour
  fallback; lifx_per_zone threaded like nanoleaf_per_panel
- Hue gradient-lightstrip mapping: Entertainment v2 frame now keyed by
  channel id (was 1 light=1 LED), channels discovered on connect;
  hue_gradient_mode toggle (default on)

B) Integrations bundle
- Outbound webhook automation action (Discord/IFTTT/Zapier/Node-RED),
  SSRF-gated via validate_polling_url at both save and fire time; fires
  on activate/deactivate, best-effort, audited
- Home Assistant MQTT auto-discovery: read-only binary_sensors per
  automation + connectivity, availability via birth/will, cleanup on
  disable/delete, live state from the engine

Shared: pixel_reduce.resample_to_n nearest-neighbour helper.
57 new tests (lifx_multizone, hue_segment, webhook_action, ha_discovery).
Gate: ruff + tsc + build clean, pytest 2719 passed / 2 skipped.
2026-06-23 00:50:22 +03:00
alexei.dolgolyov 6745e25b20 feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations
Eight roadmap features from the 2026-06-19 review, each a full vertical
(backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests:

- automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared
  with the daylight cycle; window logic mirrors TimeOfDayRule)
- ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest
  (release.yml; amd64 path untouched, continue-on-error)
- game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared
  runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown)
- ui: color-harmony gradient generator (complementary/analogous/triadic/...)
- effects: audio-reactive palette modulation (new audio_energy_tap; brightness/
  saturation modulation across all 12 procedural effects)
- capture: linear-light blending + spatio-temporal dithering, opt-in per
  calibration (new utils/linear_light.py, utils/dither.py)
- devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode)

Also bundles the pending 2026-06-18 production-review fixes and other
in-progress work already in the working tree (manual-trigger rule, etc.),
since they share files and could not be cleanly separated.

Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing
test (automation manual_trigger handler coverage) is a separate in-progress
item owned elsewhere, intentionally left as-is.
2026-06-22 23:21:24 +03:00
alexei.dolgolyov 126d8f2449 feat(auth): add auth.expose_docs flag to view API docs without a token
The /docs, /redoc and /openapi.json routes are gated by AuthRequired, so a
browser can't open them on plain navigation (no way to attach a Bearer token).
Add an opt-in auth.expose_docs flag (default off) that relaxes ONLY those three
routes to anonymous access (loopback + LAN) via a new verify_docs_access
dependency. Every real endpoint stays protected, and a startup WARNING fires
when the flag is on.

- config: AuthConfig.expose_docs: bool = False
- auth: verify_docs_access / DocsAccess dependency
- main: docs routes use DocsAccess; startup warning
- default_config.yaml: documented flag
- tests: docs anonymous when exposed; real endpoints still 401
2026-06-11 00:14:48 +03:00
alexei.dolgolyov e584235676 chore(activity-log): post-merge cleanup + graduate context to CLAUDE.md
- remove plans/activity-log/ (feature merged; learnings in CLAUDE.md + git history)
- server/CLAUDE.md: Activity/Audit Log architecture + extension points (recorder, fire_entity_event hook, sanitize_display, events allowlist, retention, API auth posture)
2026-06-10 18:42:15 +03:00
alexei.dolgolyov b43f821046 Merge feature/activity-log: persistent activity/audit log
Adds a persistent, queryable activity/audit log surfaced in the WebUI:

- storage: dedicated indexed activity_log table + repository (keyset pagination, prune, streaming export); migration 002

- recorder + actor ContextVar + retention engine + lifecycle wiring; activity_logged realtime event

- instrumentation across auth / device / entity-CRUD / capture / system (best-effort, no secrets logged)

- REST API: list/filter, CSV/JSON export, retention settings, clear (auth-gated)

- WebUI: Activity tab (smart filtering, live updates, localized descriptions, export), Dashboard widget, Settings retention panel; en/ru/zh

Reviewed via independent per-phase + final + security agents; full suite 2563 passed.

Includes 17dd2e0 (review-findings fixes) that the feature branch was based on.
2026-06-10 15:31:12 +03:00
alexei.dolgolyov 077c99c7d1 fix(activity-log): no spinner flash on instant filtering
- re-query keeps current rows visible instead of clearing + showing the full 'Loading' spinner
- loading affordance is delayed ~180ms: instant responses show nothing; slow ones get a subtle dim (aria-busy)
- full spinner reserved for the genuine first load; append (load-more) shows no indicator
2026-06-10 15:30:48 +03:00
alexei.dolgolyov ae74cca132 fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix
- export button is now a click-toggle menu (aria-haspopup/expanded, role=menu, caret, Escape + click-outside close)
- filter placeholders moved to i18n (actor/entity_type .placeholder keys, en/ru/zh)
- _fetchPage clears _loading before render so a zero-result page doesn't spin forever
- toolbar/entry use elevated card surface (--lux-bg-1); light-theme device badge contrast; mobile message grid
2026-06-10 15:24:45 +03:00
alexei.dolgolyov 77284e8e7b fix(activity-log): dashboard section reconciliation + activity column alignment
- dashboard full re-render now reconciles sections (only replaces changed ones) instead of wholesale .dashboard-dynamic innerHTML swap -> editing an entity no longer jumps the whole dashboard
- Recent Activity widget live DOM + perf strip preserved across re-renders; widget skips re-fetch when already populated (no flash)
- sweep stray non-section nodes so empty->populated doesn't leave an orphan 'no targets' banner (review-caught regression)
- Activity list rows use a CSS grid (fixed badge/actor columns) so message column aligns consistently across rows
2026-06-10 12:28:13 +03:00
alexei.dolgolyov ff1ff06cb5 fix(activity-log): post-test polish - localize descriptions, dashboard widget, ticking time
- localize entry descriptions client-side via localizeMessage (activity_log.msg.* + entity_type.* templates x3 locales); server message kept as fallback/export/search
- remove redundant Activity header banner from tab
- Recent Activity widget is now a first-class dashboard section (Customize Dashboard show/hide/reorder; pre-existing layouts preserved)
- live activity event updates the widget surgically (no full dashboard rebuild); single listener with teardown
- relative-time labels tick via shared ensureRelativeTimeTicker (single 30s interval, visibility-aware)
2026-06-10 12:03:18 +03:00
alexei.dolgolyov 3dd1ac3f0d fix(activity-log): final-review fixes - crosslink keys + sanitize parity
- _ENTITY_NAV map keys corrected to match backend entity_type (device, color_strip_source, audio_source data-id) + scene_playlist crosslink added
- sanitize_display applied uniformly to owner-authored names at remaining record sites (dependencies entity_name, device_health, automation_engine, output_targets_control, scene_presets, scene_playlists)
2026-06-09 21:23:22 +03:00
alexei.dolgolyov 6e1dd2111d feat(activity-log): phase 6 - dashboard widget + settings panel + docs
- Dashboard 'Recent Activity' widget: latest 5 entries, live prepend, 'View all' -> Activity tab
- Settings 'Activity Log' panel: retention (enabled/max_days/max_entries) GET/PUT, clear (confirm + auth-required toast), CSV/JSON export
- audit-log vs ephemeral debug Log Viewer distinction note + cross-links
- public helpers fetchRecentEntries/renderCompactEntry on activity-log.ts (reused, no dup markup)
- README Activity Log section; i18n across en/ru/zh
- review fixes: clear 401 surfaces toast; empty widget transitions on first live event
2026-06-09 21:05:40 +03:00
alexei.dolgolyov 9a0137fa4c feat(activity-log): phase 5 - Activity tab (smart filtering, live updates, export)
- new top-level Activity tab: filter toolbar (category/severity chips, presets, debounced search, actor/entity/date), keyset load-more, expandable detail
- live prepend via server:activity_logged; authed CSV/JSON blob export
- formatTimestamp/formatRelativeTime in core/ui.ts; history+severity SVG icons; Ctrl+7 shortcut
- i18n activity_log.* across en/ru/zh; getting-started tutorial step; activity-log.css (themed)
- review fixes: newest-first ordering, attribute-context XSS hardening (_escapeAttr + event delegation)
2026-06-09 20:42:44 +03:00
alexei.dolgolyov 4a0927521a feat(activity-log): phase 4 - REST API (list/export/settings/clear)
- GET /activity-log: filtered, keyset-paginated list (categories/severities/actor/entity/date/q)
- GET /activity-log/export: streaming CSV/JSON, chunked keyset (releases DB lock per batch), CSV formula-injection guard
- GET/PUT /activity-log/settings: retention config (PUT require_authenticated)
- DELETE /activity-log: clear (require_authenticated, self-audited)
- security: export DoS fix, settings-PUT auth gate, CSV \t/\r guard, metadata-as-JSON
- 122 API tests (auth posture, CSV injection, pagination integrity, filters, settings bounds, clear-audited)
2026-06-09 20:09:46 +03:00
alexei.dolgolyov 25c613c5cb feat(activity-log): phase 3 - event instrumentation (4 categories)
- entity CRUD via fire_entity_event choke point (name resolved/sanitized; deletes pass name explicitly)
- auth: failures + WS session establishment (no tokens logged); per-IP audit-record throttle
- device: online/offline (health), discovered/lost (zeroconf), ADB connect/disconnect
- capture/system: target start-stop, scenes, playlists, automations, backup/restore, update, restart, calibration, settings
- security hardening: sanitize_display strips control/NUL/ANSI/newlines from untrusted strings; malformed-IPv6 origin guard
- 129 instrumentation tests (incl. secret-leak, log-injection, throttle, best-effort) + autouse throttle-reset fixture
2026-06-09 19:20:57 +03:00
alexei.dolgolyov 726f39e2ba feat(activity-log): phase 2 - recorder, actor context, retention, lifecycle
- ActivityRecorder: thread-safe record() (inline on loop, call_soon_threadsafe off-loop), best-effort, fires activity_logged event
- current_actor ContextVar set in verify_api_key (both branches), default system
- ActivityLogRetentionEngine: prune loop (max_days+max_entries), settings persistence, rehydrates recorder.enabled on startup
- lifespan wiring: server.shutting_down recorded first on shutdown, retention stop before db.close
- events-ws.ts allowlist + parity; DI getters + module accessor; 62 new tests
2026-06-09 18:10:27 +03:00
alexei.dolgolyov 1ac4a0f66d feat(activity-log): phase 1 - storage model, migration, repository
- ActivityLogEntry dataclass + ActivityCategory/ActivitySeverity + ActivityLogFilters
- additive idempotent migration 002_add_activity_log (indexed activity_log table, seq keyset tiebreaker)
- ActivityLogRepository (record/query/count/prune/clear/iter_export), keyset pagination, parameterized SQL
- 102 unit + adversarial tests (SQL-injection, pagination, prune, codec, migration idempotency)
2026-06-09 17:40:37 +03:00
alexei.dolgolyov 1afe7d6fcc chore(activity-log): scaffold feature plan and phase subplans 2026-06-09 17:14:50 +03:00
alexei.dolgolyov 17dd2e02ba fix: resolve comprehensive review findings (security, concurrency, perf, Android, UI)
Multi-dimension review of v0.8.2. Excludes the deliberately deferred
default_config.yaml weak-default-key item (C1).

Backend:
- calibration: create_default_calibration no longer exceeds led_count for small
  odd counts (bounded trim + regression test)
- game-integration: generic webhook now requires auth_token; constant-time
  compare_digest in all adapters; per-IP failed-auth rate limit on the ingest
  route; auth_token encrypted at rest via secret_box (migration-safe)
- playlist engine: serialize _state/_task under the lifecycle lock to close a
  delete-mid-play race (+ concurrency tests)
- main: stop the calibration session on shutdown (restore prior target)
- home_assistant: validate HA host via the LAN classifier on create/update
- perf: drop slow preview-WS clients instead of blocking the send loop; cache
  composite full-strip resize linspaces; effect_stream lava reuses scratch

Frontend:
- setup/auto-calibration wizard: guard _state after awaits (cancel-safe), await
  session teardown before output start, busy-gate skip-calibration, manual
  display input keeps focus, move focus on step change
- calibration: destroy EntitySelect on modal close
- color-strips test: dirty-flag-gated render + cached ctx/ImageData
- a11y/TV: focus-visible for new wizard/auto-cal/corner controls, aria-labels on
  the spatial corner/edge picker; theme-aware syntax tokens; dead/undefined CSS
  tokens removed; .modal-error styled; i18n titles (en/ru/zh)

Android:
- ApiKeyManager: EncryptedSharedPreferences with verified, data-safe legacy
  migration that never rotates an existing key
- CaptureService: validate MediaProjection token before promoting; satisfy the
  startForeground 5s contract on the bail path
- NotificationListener: connection-scoped executor with lazy fallback
- BLE: request BLUETOOTH_SCAN/CONNECT at runtime + guard handler-thread
  SecurityExceptions
- Root: cancellation-aware su grant probe

Adds 14 tests. Gate: ruff + tsc 5.9.3 + esbuild + pytest (2185 passed) +
compileDebugKotlin all green.
2026-06-09 16:35:08 +03:00
alexei.dolgolyov 7a12f39f49 chore: release v0.8.2
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 11s
Build Release / build-docker (push) Successful in 3m14s
Build Release / build-linux (push) Successful in 4m3s
Build Release / build-windows (push) Successful in 4m25s
Build Release / publish-release (push) Successful in 1s
2026-06-08 20:18:28 +03:00
alexei.dolgolyov dd43f3836d fix(calibration-wizard): all-provider discovery + spatial corner picker
- Setup wizard discovery now scans every device provider, not just WLED:
  drop the hardcoded device_type=wled filter so the backend's parallel
  all-provider scan runs (Adalight, DDP, OpenRGB, BLE, DMX, etc). The list
  UI was already multi-type aware (per-type icon + badge).
- Auto-calibration corner picker: finish the unfinished step. The empty
  .autocal-corner-glyph + dead --top-left/etc modifier classes now render a
  mini-screen frame with the matching corner lit, so users map the physical
  lit LED to a button at a glance. Hover/focus lights the corner dot.
- Remove dead empty CSS rule for #wizard-rerun-btn.
2026-06-08 17:59:56 +03:00
alexei.dolgolyov d32961085d Merge feature/edge-calibration-wizard: auto edge-calibration + first-run wizard
Auto edge-calibration via on-screen chase (#4) and a guided first-run setup
wizard (#3); spatial model (#11) intentionally excluded. Also adds scene
playlists + cycling state to the /api/v1/snapshot poll.

- calibration solver + chase session (lock, idle-timeout, stop/restore) + /api/v1/calibration/* (phase 1)
- POST /api/v1/setup/scaffold (rollback, registers target with manager) + onboarding flag (phase 2)
- reusable browser-driven auto-calibration flow + calibration-modal entry (phase 3)
- guided first-run wizard with first-run trigger + tour suppression (phase 4)
- snapshot endpoint returns scene_playlists + playlist_state

Full suite 2149 passed / 2 skipped; tsc clean; build passes; ruff clean.
2026-06-08 17:00:41 +03:00
alexei.dolgolyov 6cd5e057da fix(setup): register scaffolded target with ProcessorManager + final-review hardening
Final-review blocker: the setup scaffold created the LED output target in the
store but never registered it with the ProcessorManager, so the wizard's
"Start" step 404'd on a fresh setup (target not found) — the lights never
started despite a success screen. Now the scaffold calls
target.register_with_manager(manager) right after create (mirroring the
canonical POST /output-targets route, same ValueError guard), so
start_processing finds the target. Rollback unregisters via
manager.remove_target before deleting the store entity, so a post-registration
failure leaves no half-registered target.

Also from the final review:
- solve corner_indices elements now bounded ge=0 (clear 422 instead of silent
  modulo-wrap).
- setup-wizard.ts: reuse tutorials' suppressGettingStartedTour()/TOUR_KEY
  instead of a duplicated 'tour_completed' literal; drop a duplicate manual-form
  submit listener.

Tests: + adversarial pass over the whole feature (solver/session/scaffold edge
cases) and a scaffold->register->startable regression test. Full suite
2149 passed / 2 skipped; tsc clean; build passes; ruff clean.
2026-06-08 16:55:36 +03:00
alexei.dolgolyov 81b18089e1 feat(onboarding): guided first-run setup wizard (phase 4, final)
A multi-step first-run wizard that takes a brand-new user from install to a
running, calibrated ambient light in ~2 minutes, orchestrating the existing
primitives (no node graph required).

- features/setup-wizard.ts + modals/setup-wizard.html: welcome -> find device
  (discovery list + manual add via the canonical, URL-validated POST /devices)
  -> pick screen (GET /config/displays) -> scaffold (POST /setup/scaffold) ->
  calibrate (embeds the phase-3 auto-calibration flow via mountAutoCalibration
  on the scaffolded CSS + device) -> start -> done, with a progress indicator.
- First-run trigger in app.ts (checkAndOpenWizardIfNeeded): on load, if the
  onboarding flag is unset AND no output targets exist, the wizard takes over
  and the tooltip tour is suppressed; on finish/skip it PUTs the onboarding
  flag and sets localStorage tour_completed so neither re-fires. Re-runnable.
- tutorials.ts exposes TOUR_KEY + a takeover hook so the getting-started tour
  and the wizard never double-fire.
- Calibrate step always calls unmountAutoCalibration() on exit so the device
  is restored. i18n in en/ru/zh (wizard.* keys + common.back).

Final phase of the edge-calibration + first-run-wizard feature. Big Bang final
gate green: tsc --noEmit clean, npm run build passes, full pytest suite
2064 passed / 2 skipped, ruff clean.
2026-06-08 16:27:55 +03:00
alexei.dolgolyov abc204c04e feat(snapshot): include scene playlists + cycling state in snapshot
The aggregated /api/v1/snapshot poll now emits a `scene_playlists` section
(each playlist with its `is_running` flag) plus a companion `playlist_state`
key carrying the single global cycling state (running playlist, current index/
preset, dwell) — so the HA-coordinator and other low-overhead pollers get
playlist state in the same round trip as scenes/targets, matching the other
entity sections. Gated by the `scene_playlists` include-section like the rest.

Reuses the existing list_scene_playlists handler; snapshot route tests updated.
2026-06-08 16:22:47 +03:00
alexei.dolgolyov 9550688c1e feat(calibration): browser-driven auto edge-calibration UI (phase 3)
Reusable, chase-driven calibration flow that solves + saves the linear
CalibrationConfig with a few taps — no LED counting — and works in the
browser on desktop and Android (no Tkinter dependency).

- features/auto-calibration.ts: 5-step flow (start corner -> direction ->
  tap-to-mark-corners -> solved preview -> save). Drives the phase-1 session
  endpoints (session/position/solve) and persists via PUT /color-strip-
  sources/{id}. cornerIndices[0] is anchored to strip index 0 per the solver
  contract. unmountAutoCalibration() is the single cleanup gate — the
  calibration session is always stopped (device restored) on cancel, modal
  close, after save, AND on a mid-flow error, so the strip is never left dark
  or stuck.
- Public API mountAutoCalibration({container, cssId, deviceId, onComplete,
  onCancel}) for the phase-4 wizard to embed; showAutoCalibration() standalone.
- "Auto-calibrate" entry added to the existing calibration modal; standalone
  modal template; app.ts/global.d.ts exports; .autocal-* CSS matching the
  ds-section vocabulary; 43 autocal.* i18n keys in en/ru/zh; docs/CALIBRATION.md.

Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate
phase — full build/suite + test-writer gated at the final phase).
2026-06-08 15:52:45 +03:00
alexei.dolgolyov 9dcd76d264 feat(setup): one-call setup scaffold + onboarding flag (phase 2)
Backend for the first-run wizard (phase 4).

- POST /api/v1/setup/scaffold: given an existing device_id + display_index
  (+ optional calibration), wires a working chain via the real validated
  store create paths — create-or-reuse capture template -> raw picture
  source -> picture color-strip source (calibration or default) -> LED
  output target -> returns the ids. Does NOT auto-start. Rolls back every
  entity it created (reverse order) on any partial failure, leaving no
  orphans; "created" events are deferred until the whole chain succeeds so
  a rolled-back scaffold never leaves ghost cards in the UI.
- Requires an existing device_id (no inline device creation) — the wizard
  creates the device first via the canonical, URL-validated POST /devices,
  so the scaffold can't bypass device validation. display_index is bounded.
- GET/PUT /api/v1/preferences/onboarding: persistent first-run flag
  ({onboarded, completed_at}) via db.set_setting; server stamps completed_at.
- Both routes AuthRequired. Tests: 25 (scaffold happy/reuse/rollback/
  validation + onboarding + calibration round-trip integration). docs/API.md.

Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate
phase — full build/suite gated at the final phase).
2026-06-08 15:22:04 +03:00
alexei.dolgolyov 0409cd8b66 feat(calibration): auto edge-calibration backend core (phase 1)
Backend engine for guided LED-chase calibration, driven by the upcoming
auto-calibration UI (phase 3) and first-run wizard (phase 4).

- solve_calibration(): pure function mapping start corner + direction + 4
  corner-tap indices to per-edge LED counts, consistent with EDGE_ORDER/
  EDGE_REVERSE so it round-trips through build_segments().
- CalibrationChaseMixin.set_calibration_pixel(): light a specific LED index
  (+ optional window) on a device, reusing the device_test_mode idle-client
  send path.
- CalibrationSession: single-active session with start/position/stop/cancel,
  a 60s idle-timeout watchdog, and a concurrency lock so interleaved calls
  can't corrupt the stop/restore bookkeeping — start() stops + remembers any
  running target on the device and stop/cancel/timeout always restore it
  (never leaves the device dark or stuck in chase).
- Routes /api/v1/calibration/{session,session/position,session/stop,
  session/cancel,session/state,solve} (all AuthRequired, bounds-validated);
  calibration is persisted by reusing the existing PUT /color-strip-sources/
  {id} (hot-reloads running streams) rather than a duplicate endpoint.
- Tests: 19 solver pure-logic + 19 route/bounds. docs/API.md updated.

Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate
phase — full build/suite gated at the final phase).
2026-06-08 14:59:58 +03:00
alexei.dolgolyov 6180569b10 wip(dashboard): in-progress dashboard customization changes
Snapshot of uncommitted dashboard-customization work (dashboard, customize,
layout, component styles, config defaults, and en/ru/zh locales) committed
as-is to clear the working tree before branching the edge-calibration +
first-run-wizard feature. Not independently verified to build.
2026-06-08 14:33:33 +03:00
alexei.dolgolyov f71e10ee06 feat(scenes): scene playlists with timed auto-cycling
Add ordered, timed sequences of scene presets that auto-cycle — activating
each preset and holding it for its dwell duration before advancing.

Backend:
- ScenePlaylist / PlaylistItem models + SQLite store (new scene_playlists table)
- PlaylistEngine: cycles ONE playlist at a time (starting one stops any other),
  loop/shuffle, re-reads the playlist each cycle so edits/deletes apply at the
  boundary, skips missing presets, guards against busy-loops; reuses the shared
  apply_scene_state path used by scene presets and automations
- REST API: CRUD + /start, /stop, /state with scene-preset reference validation
- Constructed in the app lifespan with a bounded stop on shutdown

Frontend:
- New "Playlists" sub-tab in the Automations tab with start/stop controls and a
  running indicator; editor modal with ordered scene rows (reorder + per-item
  duration), loop/shuffle toggles, and tags
- Live refresh via the playlist_state_changed WebSocket event
- i18n in en/ru/zh

Tests: new unit + API coverage for the store/model, engine (cycling,
single-active exclusivity, missing-preset skip, shuffle, and the
playlist_state_changed event contract), and routes. Full suite green;
ruff and tsc clean.
2026-06-08 13:48:43 +03:00
alexei.dolgolyov ca59546711 feat(capture): region-of-interest (ROI) crop for screen sampling
Sample only a sub-rectangle of the captured frame instead of the whole display,
so a taskbar, game HUD, or letterbox bars don't pollute the border colours — the
first functional gap a reviewer hits (capture was full-display only).

- New pure crop_screen_capture() returns a numpy view (no copy), fast-paths the
  full-frame case, and clamps degenerate/out-of-range ROIs to >=1px.
- ROI lives on CalibrationConfig (simple mode) as fractions 0..1 with a has_roi
  helper; applied in the picture color-strip stream just before border
  extraction, clamping border_width to the cropped size. Additive + backward
  compatible (full-frame default, omitted from serialization when unset -> no
  migration).
- Round-trips through the calibration schema automatically; frontend adds an
  X/Y/Width/Height (%) 'Capture region' group to the calibration editor with
  i18n (en/ru/zh).

10 unit tests (crop geometry, view-not-copy, clamping, ROI round-trip, legacy
default); full suite green (1946 passed).
2026-06-05 11:58:26 +03:00
alexei.dolgolyov 4a82595f26 Merge feat/roadmap-quick-wins: WLED realtime UDP, look presets, weekday/timezone scheduling 2026-06-05 11:44:38 +03:00
alexei.dolgolyov 1ada5ac334 feat(automations): weekday + timezone scheduling for time-of-day rule
Extend the time-of-day condition from a bare server-local HH:MM window to a real
schedule: pick which weekdays it is active (0=Mon..6=Sun, empty = every day) and
an optional IANA timezone (empty = server local). Closes the parity gap where
even a $5 WLED chip has weekday timers.

- Overnight windows (start > end) count toward the day they START on, so the
  after-midnight tail is matched against the previous weekday.
- Timezones are resolved via zoneinfo, cached, and fall back to server-local
  with a one-time warning on an invalid name (the ~1Hz tick never log-spams).
- Backward compatible: new fields default to all-days / server-local, so
  existing automations are unchanged (no migration).
- Frontend: weekday chips + timezone input on the rule editor, day/timezone in
  the rule summary, styles + i18n (en/ru/zh).

10 unit tests (weekday filter, overnight start-day semantics, tz fallback,
round-trip, invalid-day filtering); full suite green (1936 passed).
(Geographic sunrise/sunset triggers are a natural follow-up — the daylight
value source already has the solar math to reuse.)
2026-06-04 23:54:03 +03:00
alexei.dolgolyov e18d56c838 feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool)
Seed five curated, read-only post-processing templates so a non-expert gets
instant good-looking output before discovering the filter pipeline. Each is an
opinionated chain of existing filters (auto-crop/saturation/contrast/colour-
temperature/temporal-blur) tuned for a use case (films, games, evening ambience,
low-flicker, crisp cool-white).

Mirrors the built-in-gradient pattern: adds is_builtin to PostprocessingTemplate,
seeds missing looks on store init (idempotent, additive — no migration), and
makes built-ins read-only (update/delete raise -> 400; clone to customise).
Surfaced via the existing template picker + is_builtin in the response/type.

7 unit tests (seeding, idempotency, read-only protection, round-trip); full
suite green (1926 passed). (A runtime intensity slider is a follow-up — it needs
a filter-chain parameterisation layer.)
2026-06-04 23:43:11 +03:00
alexei.dolgolyov 7728aecb4f feat(wled): native realtime UDP output (DRGB/DRGBW/DNRGB) with auto-revert
Add WLED's native realtime UDP protocol (port 21324) as a third output mode for
LED targets, alongside DDP and HTTP. For the device LedGrab drives most, this
brings three user-visible wins DDP lacks:

- Auto-revert: every packet carries a timeout byte, so if the stream stops
  (host hiccup/sleep/crash) WLED returns to its preset instead of freezing on
  the last frame.
- Correct RGBW whites: the DRGBW variant carries an explicit white channel.
- Lighter on weak Wi-Fi: raw RGB with a 2-byte header.

New WledRealtimeClient auto-selects DRGB (<=490), DRGBW (<=367), or chunked
DNRGB (>490). WLED applies its own per-bus colour order in realtime mode, so we
send plain RGB and the user's colour-order config just works. Protocol 'udp' is
threaded through WLEDConfig/provider/processor and the schema pattern; the
target editor gains a protocol option + badge + i18n (en/ru/zh).

8 unit tests for the packet builder; full suite green (1919 passed).
2026-06-04 23:34:26 +03:00
alexei.dolgolyov e28ab5a956 Merge feat/power-budget-abl: automatic brightness limiting (ABL) / power budget 2026-06-04 23:22:18 +03:00
alexei.dolgolyov 1e395fd09e Merge fix/verified-bugs: weak default key, broken MQTT route, scene brightness sync 2026-06-04 23:22:18 +03:00
alexei.dolgolyov ffee156c17 feat(targets): automatic brightness limiting (ABL) / per-LED power budget
Cap an addressable strip's estimated current draw to a PSU budget so bright/
white scenes can't brown out an under-spec'd supply (voltage sag -> red/orange
shift, flicker, controller resets) — a classic 'it's broken' first impression.

- New core/processing/power_limit.py: pure current estimate (full white over N
  LEDs draws N * mA_per_led) + a (0,1] scale to land a frame on budget.
- Applied in WledTargetProcessor._send_to_device (single choke point, every send
  path; scales into a reusable scratch buffer, never mutates shared frames).
- Two per-target fields on LED targets: max_milliamps (0 = unlimited) and
  milliamps_per_led (default 55), threaded through model/store/manager/processor/
  schema/route with hot-update via update_target_settings. Additive with safe
  defaults (no data migration needed; legacy targets read as unlimited).
- Frontend: editor fields + i18n (en/ru/zh) + LedOutputTarget type.
- Tests: 10 unit tests for the estimator/scale; full suite green (1911 passed).
2026-06-04 22:56:50 +03:00
alexei.dolgolyov 02e2ea37f3 fix(scenes): sync brightness value-source change to live processor
apply_scene_state computed brightness_changed = "brightness" in changed, but
the change dict only ever uses the key "brightness_value_source_id", so the
branch was dead and a running target's brightness source was never live-synced
on scene activation (it only took effect after a restart). Check the correct
key.
2026-06-04 20:46:26 +03:00
alexei.dolgolyov fdc9201660 fix(api): remove broken legacy /system/mqtt/settings route
The GET/PUT /api/v1/system/mqtt/settings handlers read cfg.mqtt.*, but the
single-broker MQTTConfig block was removed in the multi-broker refactor, so any
call raised AttributeError. Brokers are now first-class MQTTSource entities
managed via the mqtt.py router, and the frontend no longer calls this endpoint.
Remove the dead handlers, the _load_mqtt_settings helper, the now-unused
get_config import, and the orphaned MQTTSettings{Request,Response} schemas.
2026-06-04 20:46:24 +03:00
alexei.dolgolyov 5686ae5468 fix(security): remove active weak default API key from shipped config
default_config.yaml shipped api_keys.dev: "development-key-change-in-production"
uncommitted/active, while the surrounding comment claimed it had been removed.
On a non-loopback bind this is a publicly-known credential granting full LAN
access. Restore the documented secure default (empty api_keys -> loopback-only
anonymous, LAN rejected) and leave a commented example instead.
2026-06-04 20:46:13 +03:00
alexei.dolgolyov 9960f15a1b docs(android): remove ANDROID-REVIEW planning/review docs
The Android feature-gap assessment and per-feature design docs have served
their purpose — notification, audio, webcam, and the foreground-app automation
condition are all implemented and merged, so no gaps remain to track. The
implementation is documented in the code, commit messages, and git history; the
review docs are now obsolete. No committed files referenced them (only the
local-only plans/ archives, left as point-in-time records).
2026-06-02 15:05:11 +03:00
alexei.dolgolyov 397a53ed1c Merge feature/android-foreground-app-automation: Android foreground-app automation condition
Foreground-app -> scene automation on the Android-TV build via a Kotlin
ForegroundAppBridge (UsageStatsManager) bridged into PlatformDetector ahead of the
Windows-only ctypes path; LauncherApps-backed app picker (/system/installed-apps) +
platform signal (/system/info); PACKAGE_USAGE_STATS special-access UX (on-device
button + web-UI banner, graceful degradation). Reuses the existing automation engine
unchanged; zero new deps. assembleDebug + 1897 pytest + ruff + tsc + build green;
independent final + security reviews pass.
2026-06-02 14:57:45 +03:00
alexei.dolgolyov 1c1bbe2551 feat(android): foreground-app automation condition
Make the existing Application automation rule (foreground app -> activate
scene) work on the Android-TV build. A Kotlin ForegroundAppBridge reads the
foreground app via UsageStatsManager and lists launchable apps via LauncherApps;
PlatformDetector bridges it in (ahead of the Windows-only ctypes guard) so the
existing AutomationEngine / ApplicationRule / storage / deactivation modes are
unchanged. New /system/installed-apps + /system/info endpoints feed an app picker
that stores package names (vs process names on desktop); on Android the editor
hides the match-type selector since the foreground app is the only obtainable
signal. PACKAGE_USAGE_STATS is granted via an on-device button + a web-UI banner
(no blanket prompt at capture start); detection degrades gracefully until granted.

Zero new Python/Gradle deps (UsageStatsManager + LauncherApps are in-platform;
matching only string-compares the package name, so no QUERY_ALL_PACKAGES).
assembleDebug + 1897 pytest + ruff + tsc + npm build all green; independent final
review (0 blockers) + security review (no critical issues).
2026-06-02 14:57:29 +03:00
alexei.dolgolyov 68040173c6 Merge feature/android-webcam-capture: Android on-device webcam capture
Camera2 + ImageReader (CameraBridge) -> push RGB frames -> AndroidCameraEngine,
reusing the MediaProjection capture-engine selection path so webcams surface as
selectable displays on Android TV. On-demand camera lifecycle, conditional camera
FGS type (granted-only), single-camera ownership lock. Per-phase + final + security
reviews pass; 14 files; new isolated tests; zero new Python/Gradle deps.
assembleDebug + 1883 pytest + ruff all green.
2026-06-02 13:46:59 +03:00
alexei.dolgolyov 4bf3fe65db feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine)
Add on-device webcam capture to the experimental Android-TV build. Desktop
captures webcams via OpenCV (no Chaquopy/Android wheel); this adds a push-based
AndroidCameraEngine that plugs into the same selection path desktop uses
(capture template engine_type="android_camera" + display_index, HAS_OWN_DISPLAYS).

A Kotlin CameraBridge (Camera2) enumerates cameras and opens them on demand —
only while a capture source is active, driven Python->Kotlin via a guarded jclass
singleton (BleBridge pattern) — converts each frame YUV_420_888->RGB, and pushes
RGB bytes into a module-level queue mirroring mediaprojection_engine.py. Cameras
surface as selectable displays like the desktop OpenCV engine; the data-driven
capture-template UI is unchanged. No new Python deps; no new Gradle deps
(Camera2 is in-platform).

Engine: ENGINE_PRIORITY=0 (never auto-selected over MediaProjection=100; explicit
engine_type only). Single-camera ownership is serialized with a lock + ref-count
(same-camera streams attach, different-camera refused, last release stops),
mirroring the desktop CameraEngine guard.

Permission: CAMERA requested at capture-start, gated on FEATURE_CAMERA_ANY so
camera-less TV boxes never prompt; graceful degradation when denied. The service
is promoted with the camera FGS type (+ FOREGROUND_SERVICE_CAMERA) only when
CAMERA is already granted, so backgrounded capture keeps working without risking
a failed startForeground on camera-less boxes (camera can't ride the
MediaProjection token the way audio playback capture does).

Reviewed via multi-agent adversarial pass (13 findings -> 4 fixed: device leak on
session-failure, multi-stream collision, camera FGS type, i18n key; 9 refuted).

Tests: 18 new desktop-CI tests (no device needed); full suite 1883 passed.
Verified: assembleDebug BUILD SUCCESSFUL, ruff clean.

Docs: ANDROID-REVIEW/android-webcam-capture-plan.md (design), updated
android-missing-functionality.md + README feature table + en/ru/zh locales.
2026-06-02 13:36:23 +03:00
alexei.dolgolyov 34db5de8c3 Merge feature/android-notification-capture: Android on-device notification capture
NotificationListenerService -> push_notification() -> _AndroidBackend path so OS
notifications drive the existing color-strip LED effects on Android TV at app-name
parity. Per-phase + final + security reviews all pass; 11 new isolated tests; zero
new Python deps. Android assembleDebug pending device/CI verification.
2026-06-02 11:47:29 +03:00
alexei.dolgolyov 0be3f833df feat(android): on-device OS notification capture (NotificationListenerService)
Add an Android backend to os_notification_listener.py so notifications on the
experimental Android-TV build drive the existing NotificationColorStripSource
LED effects (flash/pulse/sweep, per-app colors + sounds) at app-name parity
with the Windows/Linux backends.

A Kotlin NotificationListenerService forwards the posting app's display label
across the Chaquopy JNI boundary into a new push-based _AndroidBackend +
module-level push_notification() receiver; the existing color-strip pipeline,
per-app colors/filters, and history endpoint are reused unchanged.

- Python: _AndroidBackend (probed first), push_notification() receiver,
  _LinuxBackend.probe() hardened with is_linux() to exclude Android (which
  also reports platform.system() == "Linux").
- Android: LedGrabNotificationListener NLS — serial single-thread executor,
  full crash isolation around Python.getInstance(), label-only forwarding
  (never notification title/body), ongoing/group-summary/self-package noise
  filtering. Manifest service exported + gated by
  BIND_NOTIFICATION_LISTENER_SERVICE (no new uses-permission).
- UX: prompt-once notification-access + manual "Grant notification access"
  button wired into the D-pad focus chain (computed from visible controls);
  en/ru/zh strings.
- Tests: 11 isolated unit tests — module-global + tmp_path history isolation,
  push routing contract, callback-exception swallowing, None app-name, and a
  desktop-regression lock on backend selection order.
- Docs: README OS-support Android column (notification + audio cells),
  ANDROID-REVIEW status flipped to Implemented.

Zero new Python deps; no build.gradle.kts / Chaquopy pip changes.
2026-06-02 11:47:13 +03:00
alexei.dolgolyov 4b2e8fc5ec docs(android): add audio-capture design + missing-functionality review
- android-audio-capture-plan.md — design behind the merged on-device audio
  capture feature (487259a).
- android-missing-functionality.md — Android missing-feature review notes.
2026-06-02 03:30:43 +03:00
alexei.dolgolyov 487259a96d Merge feature/android-audio-capture: Android on-device audio capture
Push-based AndroidAudioEngine (AudioPlaybackCapture, API 29+) reusing the
MediaProjection token, feeding the unchanged AudioAnalyzer. Build green
(assembleDebug), 1854+13 tests pass. Reviewed: READY/SECURE, 0 blockers.
2026-06-02 03:28:37 +03:00
alexei.dolgolyov fd62db1720 feat(audio): Android on-device system playback capture
Enable audio-reactive lighting on the Android-TV build. A push-based
AndroidAudioEngine captures system playback audio via AudioPlaybackCapture
(API 29+), reusing the existing MediaProjection token, and feeds PCM into
the unchanged AudioAnalyzer pipeline. No new Python deps; no Chaquopy/pip
changes (numpy already bundled).

- Python: android_audio_engine.py — module-level queue + configure/
  push_samples/shutdown mirroring mediaprojection_engine; AndroidAudioEngine
  (priority 100) registered behind a guarded import. push_samples copies and
  defensively trims/clamps each block so the analyzer can't crash on
  variable-length or non-frame-divisible PCM.
- Kotlin: AudioCapture.kt — AudioRecord + AudioPlaybackCaptureConfiguration,
  fixed chunk-size block framing, little-endian float32, mic fallback;
  reads back the actual negotiated channel/sample rate. PythonBridge gains
  configureAudio/pushAudio/shutdownAudio with a cached module handle.
- Wiring: CaptureService starts/stops AudioCapture in the MediaProjection
  path (gated on API>=29 + RECORD_AUDIO + live projection); MainActivity
  requests RECORD_AUDIO; manifest declares it. Degrades gracefully when
  denied; root path stays audio-less by design.
- Tests: 13 desktop-CI tests incl. an over-length/non-divisible regression
  guard that exercises the full read_chunk -> AudioAnalyzer.analyze path.
2026-06-02 03:28:22 +03:00
alexei.dolgolyov 669ae20824 feat(value-sources): optional normalization for magnitude sources
Add an opt-out `normalize` flag to the four magnitude value sources
(ha_entity, http, system_metrics, game_event). get_value() stays in
[0,1] for every source (the normalized scalar-bus invariant), so all
existing consumers — brightness sinks, gradient_map, template `name`
bindings, and color-strip bindable floats — are unaffected.

- normalize=False is a clamp-passthrough: skip the min/max rescale and
  clamp the raw reading into [0,1] (for sources already reporting a
  0..1 fraction). The un-normalized magnitude stays available via
  get_raw_value() / template raw[name] / automations.
- Add get_raw_value() + a raw channel to GameEventValueStream (it had
  none). game_event's flag is model/stream-level only (no CRUD schema).
- Finite-safe clamp01() util; harden the composite-layer brightness
  multiply (latent negative-wrap / >=1 skip) with it.
- Preview WebSocket: tolerate non-numeric raw, generalize raw-range.
- Frontend: settings-toggle slider per HA/system_metrics/http editor
  with min/max grey-out; toggle hidden for fixed-mapping percent
  metrics. en/ru/zh locale keys.
- Additive optional field (default True) — JSON round-trip, no migration.

Tests: store create/update round-trip, clamp-passthrough, live
normalize flip, game_event raw channel + build_stream forwarding,
and finite-safe clamp01.
2026-06-02 02:24:40 +03:00
alexei.dolgolyov 6de61b965e feat(value-sources): add sandboxed-Jinja template combinator
A new `template` value source evaluates a hardened, sandboxed Jinja
expression over the live values of other value sources — the system's
first float combinator.

Backend:
- Shared engine (utils/template_expr.py): ImmutableSandboxedEnvironment with
  filters/tests and auto-injected globals stripped; only min/max/abs/round/
  clamp exposed; rejects **, string/collection-literal repetition, attribute
  access and non-global calls; NaN/inf-safe result coercion.
- TemplateValueSource model + TemplateValueStream runtime: compile-once,
  primitives-only eval context, raw[name] exposure, eval_interval throttle,
  ref-counted input acquire/release, rename-safe hot-update.
- Validation: unbound-variable + reserved-name rejection, reference
  cycle/depth guards (depth-only at create, full cycle at update), runtime
  acquire() depth backstop, and delete referential-integrity.
- API: Create/Update/Response schemas + discriminated unions, _RESPONSE_MAP,
  and an advisory POST /value-sources/validate-template endpoint.
- Demo seed: a static source plus a template combinator example.

Frontend:
- Editor modal section: repeatable inputs list (EntitySelect rows), a
  zero-dependency Jinja syntax highlighter, a hints/reference panel, and a
  debounced live validator that gates Save (stale-response-safe).
- Graph editor: read-only template node with one edge per input.
- i18n (en/ru/zh), icon, and card rendering.

Tests: engine, stream, factory/cycle, validate endpoint, and demo seed.
2026-06-01 18:53:56 +03:00
alexei.dolgolyov 12b40e6071 docs: actualize README and API reference, embed screenshots
README: add free & open-source (MIT) framing; add a Platforms table (Windows/Linux/macOS/Docker supported, Android-TV build marked experimental, scrcpy phone-capture retained); expand the LED device list to ~20 protocols; correct storage to SQLite, the auth model, and resource names (Output Targets, Automations); add a prebuilt-download section; remove the Architecture and Acknowledgments sections; embed dashboard, channels, live-preview, and device-picker screenshots.

docs/API.md: full rewrite from the live route modules — all 253 endpoints across 34 modules, grouped by resource with REST + WebSocket tables, accurate auth model and WS handshake, worked examples, and sensitive-endpoint markers. Replaces the stale v0.1.0 stub.

server/CLAUDE.md: storage is now SQLite (BaseSqliteStore / ledgrab.db / LEDGRAB_DATA_DIR / data_migrations.py); fix the auth description (loopback anonymous, LAN rejected with 401 — not all endpoints open); router registration happens in api/__init__.py.
2026-05-29 14:35:45 +03:00
alexei.dolgolyov 498854f04d refactor(storage): gate clone() behind an opt-in allowlist; expand duplicate tests
Follow-up polish from the duplicate-subgraph review:

- BaseSqliteStore.clone() now requires an opt-in `_cloneable` flag (default
  off), so a new or secret-bearing store can never be cloned by accident —
  defence-in-depth on top of the endpoint's `_DUPLICABLE_KINDS` restriction.
  Only value-source and colour-strip stores are flagged.
- Fix `_check_name_unique` annotation (`str | None = None`).
- Add tests: `layers[].brightness_source_id` remap, the warnings safety net
  firing, the clone allowlist invariant, and clone() refusing a non-cloneable
  store.
2026-05-29 11:55:58 +03:00
alexei.dolgolyov 15cfb821d3 feat(graph): duplicate a selected subgraph server-side
A secret-safe equivalent of blueprint import: the graph editor's overflow menu
gains "Duplicate selection", which deep-clones the selected value and
colour-strip sources server-side (full config preserved, never crossing the
wire) and rewires references that point within the selection — shared
dependencies (devices, HA sources, …) stay shared.

- graph_schema.remap_refs: write-twin of extract_refs (same dot/list/bindable
  grammar) that rewrites only in-selection ids; 8 unit tests.
- BaseSqliteStore.clone(): faithful deep-copy clone (no schema round-trip, so no
  field is lost), prefix-preserving fresh id; reusable by any store.
- POST /api/v1/graph/duplicate: two-pass clone-then-rewire restricted to value /
  colour-strip sources (no inline secrets), with a safety net flagging any
  unremapped reference; 7 integration tests vs real stores.
- Frontend: duplicateSubgraph (+cache invalidation), graphDuplicateSelection
  (reload + reselect the new cluster), overflow-menu item, i18n (en/ru/zh).
2026-05-29 11:45:55 +03:00
alexei.dolgolyov 2e51f46dfd feat(graph): make the visual editor a full wiring control surface
Lets users wire the system end-to-end from the graph, and fixes the core
bug that made drag-to-wire silently fail.

- Fix drag-to-wire 422s across 5 entity kinds: updateConnection() now echoes
  the target's discriminator (source_type/stream_type/target_type) into the
  partial PUT, so value/colour-strip/audio/picture sources and output targets
  all wire correctly. New contract test (54 cases) in test_graph_wiring_contract.py.
- Re-wire composite layers / mapped zones from the graph (right-click a
  layer/zone source edge -> Re-wire). Whole-list write preserves every sibling
  layer/zone setting, with an optimistic-concurrency guard and undo.
- Secret-safe /graph topology: project entities to id/name/subtype + reference
  roots so the endpoint cannot leak webhook tokens or other credentials.
- Carry slot indices on list edges; node custom-icon + schema-drift refinements;
  rewire i18n keys (en/ru/zh); wiring-control roadmap (TODO.md).
2026-05-29 02:29:19 +03:00
alexei.dolgolyov 05cf121666 fix(installer): open WebUI once after "Launch LedGrab"
The finish-page launch action started the app and also opened
http://localhost:8080/ itself, but the app already auto-opens the
browser on a manual (non-autostart) launch, so the page appeared
twice. Drop the installer's redundant open and let the app handle it
(it waits on /health, which is more reliable than a fixed sleep).
2026-05-28 23:52:52 +03:00
alexei.dolgolyov d505388f0e docs: graph-editor wiring-control roadmap (review findings A1-D6) 2026-05-28 23:38:04 +03:00
alexei.dolgolyov 6aeda935f1 chore: release v0.8.1
Build Android APK / build-android (push) Failing after 11s
Build Release / create-release (push) Successful in 6s
Build Release / build-docker (push) Successful in 2m57s
Build Release / build-linux (push) Successful in 1m49s
Build Release / build-windows (push) Successful in 3m35s
Build Release / publish-release (push) Successful in 2s
2026-05-28 23:35:35 +03:00
alexei.dolgolyov a5effba553 feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers
Backend
- snapshot: GET /api/v1/snapshot aggregates targets, devices, sources,
  presets and system into one payload for the HA coordinator, collapsing
  the prior ~2N+M request fan-out; per-section ?include= gating.
- graph: GET /api/v1/graph{,/schema,/dependents} backed by a pure,
  unit-tested graph_schema engine — one authoritative connectable-field
  registry so the editor no longer hard-codes topology in two places.
- devices: thread mqtt_source_id through DeviceCreate/Update/Response and
  the routes for multi-broker MQTT; shared validate_mqtt_source_exists
  (_mqtt_validation.py) reused by device + output-target routes; stop
  update_device masking intentional 4xx as 500.
- shutdown: bound uvicorn graceful-shutdown via GRACEFUL_SHUTDOWN_TIMEOUT
  (shared by __main__, android_entry, demo) so a lingering events WebSocket
  can't strand LED targets or block process exit.
- access log: structured _access_log middleware attributing each request to
  its authenticated token label (never the secret); uvicorn access_log off.

Frontend
- graph editor: generic schema-driven port/edge rendering, layout and
  connection handling; service-worker refresh.
- device modals: MQTT broker EntitySelect for device_type=mqtt in add-device
  and settings, wired into load/save/validate/dirty-check/clone.
- i18n: en/ru/zh keys.

Tests: graph routes + schema, snapshot routes, access log, mqtt_source_id
device regressions, bounded-shutdown entrypoint. 1614 passed.
2026-05-28 22:51:04 +03:00
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
368 changed files with 56471 additions and 12533 deletions
+78 -1
View File
@@ -98,6 +98,9 @@ jobs:
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" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
@@ -105,7 +108,7 @@ jobs:
\"tag_name\": \"$TAG\",
\"name\": \"LedGrab $TAG\",
\"body\": $BODY_JSON,
\"draft\": false,
\"draft\": true,
\"prerelease\": $IS_PRE
}")
@@ -350,3 +353,77 @@ jobs:
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
docker push "$REGISTRY:latest"
fi
# Best-effort arm64 (Raspberry Pi / arm64 HAOS hosts). Runs AFTER the
# amd64 push so the amd64 image always ships even if this fails.
# Deliberately avoids `docker buildx` (its docker-container driver needs
# nested networking the TrueNAS runners lack — see contexts/ci-cd.md):
# instead it cross-builds a single arm64 image via QEMU binfmt and folds
# amd64 + arm64 into multi-arch manifest lists under the existing tags.
# `continue-on-error` keeps a runner that can't emulate arm64 from
# failing the release; the plain amd64 tags pushed above remain valid.
- name: Build + publish arm64 (multi-arch manifest, best-effort)
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
continue-on-error: true
run: |
set -e
export DOCKER_CLI_EXPERIMENTAL=enabled
TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}"
VERSION="${{ steps.meta.outputs.version }}"
# Register arm64 emulation. If the runner forbids privileged
# containers this fails and the whole step is skipped.
docker run --privileged --rm tonistiigi/binfmt --install arm64
# Cross-build the arm64 image (QEMU-emulated — slow but uses arm64
# manylinux wheels, so no source compilation). Stays in the local
# daemon alongside the amd64 image from the previous build step.
DOCKER_BUILDKIT=1 docker build \
--platform linux/arm64 \
--build-arg APP_VERSION="$VERSION" \
--label "org.opencontainers.image.version=$VERSION" \
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
-t "$REGISTRY:$VERSION-arm64" \
./server
# Fold amd64 + arm64 into a multi-arch manifest list under each
# user-facing tag. The arch-suffixed tags remain pullable directly.
publish_manifest() {
local t="$1"
docker tag "$REGISTRY:$t" "$REGISTRY:$t-amd64"
docker push "$REGISTRY:$t-amd64"
docker tag "$REGISTRY:$VERSION-arm64" "$REGISTRY:$t-arm64"
docker push "$REGISTRY:$t-arm64"
docker manifest create --amend "$REGISTRY:$t" \
"$REGISTRY:$t-amd64" "$REGISTRY:$t-arm64"
docker manifest push "$REGISTRY:$t"
}
publish_manifest "$TAG"
publish_manifest "$VERSION"
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
publish_manifest "latest"
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"
+3 -2
View File
@@ -19,6 +19,7 @@ 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).
# Embedder used for semantic indexing. IDs: minilm-l6-v2 (default, CPU-fast),
# jina-code (code-specialized, GPU-worthy), bge-base-en-v1.5, bge-large-en-v1.5.
# Changing the embedder requires a full reindex.
# embedder = "minilm-l6-v2"
embedder = "jina-code"
+92 -11
View File
@@ -18,6 +18,7 @@ context.
| `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
@@ -100,16 +101,35 @@ registry.
**Estimated scope:** 1-2 sessions; coupled to H4.
#### H8 — `automations.ts` 1410 LOC
#### 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` is still
backend in `98fb61d`; the frontend dispatch on `RuleType` was
hand-rolled.
**Approach:** introduce a rule-type registry on the frontend matching
the backend's `_RULE_HANDLERS` shape.
**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`.)
**Estimated scope:** half a session.
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
@@ -161,16 +181,66 @@ 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
#### 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. ~25 files.
feature's save / load function. ~45 files, ~243 call sites.
**Approach:** introduce `static/js/core/api-client.ts` with typed
methods (`get`, `post`, `put`, `delete`) that handle auth, JSON parsing,
error normalisation. Replace `fetchWithAuth` calls across features.
**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
@@ -262,7 +332,11 @@ always start before reading).
### Other frontend (severity in main list above)
- **H6 rest** — split remaining ~1100 LOC of `types.ts` into per-entity files
- **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
@@ -299,6 +373,13 @@ 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
+33 -2
View File
@@ -2,9 +2,40 @@
## Code Search
**If `ast-index` is available, use it as the PRIMARY code search tool.** It is significantly faster than grep and returns structured, accurate results. Fall back to grep/Glob only when ast-index is not installed, returns empty results, or when searching regex patterns/string literals/comments.
**Priority order: `vex` (PRIMARY) → `ast-index` (fallback) → Grep/Glob (last resort).** This repo has a fully-featured `.vex.toml` index. Use vex first for any symbol/definition/usage/call-graph lookup. Fall back to ast-index only when vex legitimately can't help, and to Grep/Glob only for regex patterns, string literals, comments, config files, or unparsed languages.
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt.
**IMPORTANT — use ALL vex indexing features.** The index is built with every capability enabled, and queries must take advantage of them. Keep them ON and exploit them:
| Capability | Status | Powers |
| ---------- | ------ | ------ |
| Semantic embeddings (`jina-code`, 768-dim) | ON | `vex search` (semantic channel), `similar`, `find_similar`, `duplicates` |
| Call graph | ON | `vex callers`, `callees`, `paths`, `reachable`, `bundle --mode pr-impact` |
| BM25 | ON | hybrid RRF text channel in `vex search` |
| Pattern index | ON | `vex pattern` AST-shape matching |
| C++ includes | ON | include-graph resolution |
| Body tokens (incremental HNSW) | ON | fast incremental reindex |
| History | ON | `vex history`, `vex diff <rev>` blame/evolution queries |
**In-session, use the `mcp__vex__*` MCP tools** (`search`, `show`, `usages`, `callers`, `callees`, `bundle`, `outline`, `implementations`, `similar`, `grep`, `status`, etc.) — MCP output is far cheaper in tokens than `Bash("vex …")`. Drop to Bash `vex` only for CLI-only features (`pattern`, `diff`, `paths`, `reachable`, `bundle`, `history`, `--strict`/`--why` flags), for subagent prompts, or for shell composition.
```bash
vex search "query" --semantic # Hybrid semantic + BM25 search
vex show <Symbol> # Definition body (prefer over Read)
vex usages <Symbol> --strict # Reference sites (AST-precise on T1 langs)
vex callers <Function> # Call sites (function-scoped)
vex callees <Function> # Outgoing calls
vex paths --from <A> --to <B> # Multi-hop call-graph path
vex bundle --mode pr-impact --base master # Changed symbols + callers + reachable tests
vex pattern '$X async fn returning Response' # AST-shape (metavariables)
vex diff master # Symbol-level branch diff
vex history <Symbol> # Commit evolution of a symbol
```
**Maintenance:** the index has `auto_update = true`, so it refreshes on stale queries. After a `vex self-update`, rerun `vex index --history --semantic --embedder jina-code --device cuda` so newly-added extractors populate and all features stay enabled. Verify with `vex status` — every capability line should read `yes`.
**IMPORTANT for subagents:** Subagents don't inherit MCP. When spawning Agent subagents (Plan, Explore, general-purpose, etc.), instruct them to use `vex` via Bash for code search (e.g. include "Use `vex search`, `vex show`, `vex usages`, `vex callers` via Bash for code search; ast-index is the fallback"). Don't tell them to default to grep/Glob.
**Fallback — `ast-index`** (use only when vex is unavailable):
```bash
ast-index search "Query" # Universal search
+132 -108
View File
@@ -1,36 +1,58 @@
# LED Grab
Ambient lighting system that captures screen content and drives LED strips in real time. Supports WLED, Adalight, AmbileD, and DDP devices with audio-reactive effects, pattern generation, and automated profile switching.
Ambient lighting system that captures screen content and drives LED strips and smart lights in real time. Supports a wide range of devices — WLED, DDP, Adalight, smart bulbs, PC peripherals, Bluetooth strips, and more — with audio-reactive effects, pattern generation, and condition-based automation.
**Free and open source.** LedGrab is released under the [MIT license](LICENSE) — free to use, modify, and self-host, with no accounts, telemetry, or cloud dependency. Everything runs locally on your own machine and network.
## What It Does
The server captures pixels from a screen (or Android device via ADB), extracts border colors, applies post-processing filters, and streams the result to LED strips at up to 60 fps. A built-in web dashboard provides device management, calibration, live LED preview, and real-time metrics — no external UI required.
The server captures pixels from a screen (or from a connected Android phone via ADB), extracts border colors, applies a post-processing filter pipeline, and streams the result to your LED devices at up to 60 fps. A built-in web dashboard provides device management, calibration, a visual wiring editor, live LED preview, and real-time metrics — no external UI required.
A Home Assistant integration exposes devices as entities for smart home automation.
A separate Home Assistant integration exposes devices as entities for smart-home automation.
## Screenshots
![LedGrab dashboard](docs/screenshots/dashboard.PNG)
*Dashboard — live system performance, integrations, automations, and scene presets at a glance.*
![Channels view](docs/screenshots/dashboard-targets.PNG)
*Channels — start, stop, and monitor each source-to-device pipeline with live FPS.*
![Live capture preview](docs/screenshots/test-preview-capture.PNG)
*Live preview — inspect the processed capture output in real time before it reaches the LEDs.*
## Features
### Screen Capture
- Multi-monitor support with per-target display selection
- 6 capture engine backends MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows), Scrcpy (Android via ADB), Camera/Webcam (OpenCV)
- Capture engine backends: MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows only), and Camera/Webcam (OpenCV)
- Capture from a connected Android phone's screen via scrcpy (ADB) — the device is a *source*; LedGrab itself runs on your desktop
- Configurable capture regions, FPS, and border width
- Capture templates for reusable configurations
- Reusable capture templates
### LED Device Support
- WLED (HTTP/UDP) with mDNS auto-discovery
- Adalight (serial) — Arduino-compatible LED controllers
- AmbileD (serial)
- DDP (Distributed Display Protocol, UDP)
- OpenRGB — PC peripherals (keyboard, mouse, RAM, fans, LED strips)
- Serial port auto-detection and baud rate configuration
LedGrab speaks many protocols, so a single setup can drive everything from a DIY strip to off-the-shelf smart bulbs:
![Device type picker](docs/screenshots/devices.PNG)
- **Network LED controllers** — WLED (HTTP/UDP, with mDNS auto-discovery), DDP (Pixelblaze, ESPixelStick, Falcon), Open Pixel Control (OPC), Art-Net / sACN (E1.31), ESP-NOW, and generic WebSocket streaming
- **Serial / direct hardware** — Adalight (Arduino-compatible), AmbiLED, SPI-attached strips (e.g. WS2812B), and USB HID controllers
- **Smart bulbs & panels** — Philips Hue (Entertainment API), Nanoleaf, Yeelight, WiZ, LIFX, and Govee (Wi-Fi LAN)
- **Bluetooth LE strips** — SP110E, Triones / HappyLighting, Zengge, and Govee BLE
- **PC peripherals** — OpenRGB, Razer Chroma, and SteelSeries GameSense (keyboards, mice, RAM, fans, etc.)
- **Device groups** — combine multiple devices into one logical target
- Serial port auto-detection and baud-rate configuration
### Color Processing
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip, and more
- Reusable post-processing templates
- Color strip sources: audio-reactive, pattern generator, composite layering, audio-to-color mapping
- Color strip sources: audio-reactive, pattern generator, gradients, composite layering, and audio-to-color mapping
- Pattern templates with customizable effects
### Audio Integration
@@ -38,17 +60,20 @@ A Home Assistant integration exposes devices as entities for smart home automati
- Multichannel audio capture from any system device (input or loopback)
- WASAPI engine on Windows, Sounddevice (PortAudio) engine on Linux/macOS
- Per-channel mono extraction
- Audio-reactive color strip sources driven by frequency analysis
- Audio filter / processing pipeline feeding audio-reactive color sources driven by frequency analysis
### Automation
- Profile engine with condition-based switching (time of day, active window, etc.)
- Dynamic brightness value sources (schedule-based, scene-aware)
- Key Colors (KC) targets with live WebSocket color streaming
- Automations engine with condition-based rules — switch targets, scenes, or brightness by time of day, active window/process, MQTT, webhooks, or game events
- Scene presets for one-click lighting changes
- Dynamic value sources for brightness and other parameters (schedule-based, weather-based, scene-aware)
- Weather sources, clock sync, webhooks, and inbound/outbound HTTP endpoints
- Game integration adapters (e.g. League of Legends)
### Dashboard
- Web UI at `http://localhost:8080` — no installation needed on the client side
- Web UI at `http://localhost:8080` — nothing to install on the client side
- Visual node-graph editor for wiring sources → processing → targets
- Progressive Web App (PWA) — installable on phones and tablets with offline caching
- Responsive mobile layout with bottom tab navigation
- Device management with auto-discovery wizard
@@ -57,34 +82,72 @@ A Home Assistant integration exposes devices as entities for smart home automati
- Real-time FPS, latency, and uptime charts
- Localized in English, Russian, and Chinese
### Activity Log
The **Activity** tab is a persistent, queryable audit log of everything LedGrab has done — entity changes, auth events, device connections, and system actions.
- Filter by category (auth, device, entity, capture, system), severity, actor, entity type, date range, or free text
- Live-append of new events as they happen
- Export as CSV or JSON (authentication required)
- Entity crosslinks navigate directly to the relevant card
- **Retention settings** (Settings → Activity Log): configure max age, max entry count, and toggle recording on/off
- **Clear log** (Settings → Activity Log, requires authentication) — audited: a system entry records who cleared the log and when
> **Note:** The Activity Log is distinct from the **debug Log Viewer** (Settings → General → Open Log Viewer). The Log Viewer is an ephemeral real-time tail of the server's Python log stream (WARNING/ERROR lines, resets on disconnect). The Activity Log is a structured, persistent SQLite-backed record of semantic application events.
### Home Assistant Integration
- HACS-compatible custom component
- HACS-compatible custom component (separate repository)
- Light, switch, sensor, and number entities per device
- Real-time metrics via data coordinator
- Real-time metrics via a data coordinator
- WebSocket-based live LED preview in HA
## Platforms
LedGrab runs as a desktop / server application:
| Platform | Status | Notes |
| -------- | ------ | ----- |
| Windows | ✅ Supported | Installer (`.exe`) and portable ZIP; all capture/audio backends |
| Linux | ✅ Supported | Tarball and Docker image; X11 capture (Wayland in-container capture not supported) |
| macOS | ✅ Supported | Runs from source / Docker; MSS capture |
| Docker | ✅ Supported | Multi-arch container image |
| Android (TV) | ⚠️ Experimental | An on-device Android-TV build exists (APK attached to releases) but is emulator-verified only and **not officially supported** |
> **There is no production Android app.** Android phones are only supported as a *capture source* (via scrcpy/ADB) from a desktop host. The on-device Android-TV build is experimental.
### Feature support by OS
| Feature | Windows | Linux / macOS | Android TV (experimental) |
| ------- | ------- | ------------- | ------------------------- |
| Screen capture | DXCam, BetterCam, WGC, MSS | MSS | MediaProjection; root `screenrecord` (rooted devices) |
| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) | Camera2 (on-demand, while capture is running) |
| Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) | AudioPlaybackCapture (API 29+) |
| GPU monitoring | NVIDIA (nvidia-ml-py) | NVIDIA (nvidia-ml-py) | — (CPU/RAM/battery/thermal via `/proc`) |
| Capture from Android phone | scrcpy (ADB) | scrcpy (ADB) | — (captures its own screen instead) |
| Notification capture | WinRT | dbus (Linux) | NotificationListenerService |
| Monitor names | Friendly names (WMI) | Generic ("Display 0") | Single built-in display |
| LED transports | Network, USB-serial, BLE | Network, USB-serial, BLE | Network, USB-serial (Android driver), BLE (Android bridge) |
| Automation: window/process conditions | Supported | Partial | Foreground-app condition (UsageStatsManager) |
## Requirements
- Python 3.11+ (or Docker)
- A supported LED device on the local network or connected via USB
- Windows, Linux, or macOS — all core features work cross-platform
### Platform Notes
| Feature | Windows | Linux / macOS |
| ------- | ------- | ------------- |
| Screen capture | DXCam, BetterCam, WGC, MSS | MSS |
| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) |
| Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) |
| GPU monitoring | NVIDIA (pynvml) | NVIDIA (pynvml) |
| Android capture | Scrcpy (ADB) | Scrcpy (ADB) |
| Monitor names | Friendly names (WMI) | Generic ("Display 0") |
| Profile conditions | Process/window detection | Not yet implemented |
- A supported LED device on the local network, connected via USB/serial, or reachable over Bluetooth
- Windows, Linux, or macOS
## Quick Start
### Docker (recommended)
### Prebuilt downloads
Grab a ready-to-run build from the [Releases page](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/releases):
- **Windows** — `LedGrab-<version>-setup.exe` (installer, no admin required) or `LedGrab-<version>-win-x64.zip` (portable)
- **Linux** — `LedGrab-<version>-linux-x64.tar.gz`
- **Docker** — see below
- **Android TV** — `.apk` (experimental, see [Platforms](#platforms))
### Docker (recommended for servers)
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
@@ -115,11 +178,11 @@ export PYTHONPATH=$(pwd)/src # Linux/Mac
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
```
Open **http://localhost:8080** to access the dashboard.
Open <http://localhost:8080> to access the dashboard.
> **Important:** The default API key is `development-key-change-in-production`. Change it before exposing the server outside localhost. See [INSTALLATION.md](INSTALLATION.md) for details.
> **Network access:** By default, LedGrab allows anonymous access only from `localhost`. Any request from another machine on your LAN is rejected unless you configure an API key (`auth.api_keys`). Set a key before exposing the server on your network — see [INSTALLATION.md](INSTALLATION.md).
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup.
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and CORS setup.
## Demo Mode
@@ -133,50 +196,9 @@ docker compose run -e LEDGRAB_DEMO=true server
# Python
LEDGRAB_DEMO=true uvicorn ledgrab.main:app --host 0.0.0.0 --port 8081
# Windows (installed app)
set LEDGRAB_DEMO=true
LedGrab.bat
```
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data in `data/demo/` (separate from production data). It can run alongside the main server.
## Architecture
```text
ledgrab/
├── server/ # Python FastAPI backend
│ ├── src/ledgrab/
│ │ ├── main.py # Application entry point
│ │ ├── config.py # YAML + env var configuration
│ │ ├── api/
│ │ │ ├── routes/ # REST + WebSocket endpoints
│ │ │ └── schemas/ # Pydantic request/response models
│ │ ├── core/
│ │ │ ├── capture/ # Screen capture, calibration, pixel processing
│ │ │ ├── capture_engines/ # MSS, DXCam, BetterCam, WGC, Scrcpy, Camera backends
│ │ │ ├── devices/ # WLED, Adalight, AmbileD, DDP, OpenRGB clients
│ │ │ ├── audio/ # Audio capture engines
│ │ │ ├── filters/ # Post-processing filter pipeline
│ │ │ ├── processing/ # Stream orchestration and target processors
│ │ │ └── profiles/ # Condition-based profile automation
│ │ ├── storage/ # JSON-based persistence layer
│ │ ├── static/ # Web dashboard (vanilla JS, CSS, HTML)
│ │ │ ├── js/core/ # API client, state, i18n, modals, events
│ │ │ ├── js/features/ # Feature modules (devices, streams, targets, etc.)
│ │ │ ├── css/ # Stylesheets
│ │ │ └── locales/ # en.json, ru.json, zh.json
│ │ └── utils/ # Logging, monitor detection
│ ├── config/ # default_config.yaml
│ ├── tests/ # pytest suite
│ ├── Dockerfile
│ └── docker-compose.yml
├── docs/
│ ├── API.md # REST API reference
│ └── CALIBRATION.md # LED calibration guide
├── INSTALLATION.md
└── LICENSE # MIT
```
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data under `data/demo/` (separate from production data). It can run alongside the main server.
## Configuration
@@ -187,14 +209,15 @@ server:
host: "0.0.0.0"
port: 8080
log_level: "INFO"
cors_origins:
- "http://localhost:8080"
auth:
api_keys:
dev: "development-key-change-in-production"
storage:
devices_file: "data/devices.json"
templates_file: "data/capture_templates.json"
# Empty (default) → loopback-only anonymous access; LAN requests are rejected.
# Add a key to enable LAN/remote access (generate one with: openssl rand -hex 32).
api_keys: {}
# api_keys:
# dev: "your-secret-key-here"
logging:
format: "json"
@@ -202,25 +225,26 @@ logging:
max_size_mb: 100
```
Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
- Application data is stored in a SQLite database (`data/ledgrab.db` by default). Set `LEDGRAB_DATA_DIR` to relocate the data root (database + assets).
- Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
See [INSTALLATION.md](INSTALLATION.md) and [`server/.env.example`](server/.env.example) for the full configuration reference.
## API
The server exposes a REST API (with Swagger docs at `/docs`) covering:
The server exposes a REST API (with interactive Swagger docs at `/docs`) plus WebSocket endpoints. Resources include:
- **Devices** — CRUD, discovery, validation, state, metrics
- **Capture Templates** — Screen capture configurations
- **Picture Sources** — Screen capture stream definitions
- **Picture Targets** — LED target management, start/stop processing
- **Post-Processing Templates** — Filter pipeline configurations
- **Color Strip Sources** — Audio, pattern, composite, mapped sources
- **Audio Sources** — Multichannel and mono audio device configuration
- **Pattern Templates** — Effect pattern definitions
- **Value Sources** — Dynamic brightness/value providers
- **Key Colors Targets** — KC targets with WebSocket live color stream
- **Profiles** — Condition-based automation profiles
- **Capture Templates** & **Picture Sources** — screen capture configuration and stream definitions
- **Output Targets** — LED target management, start/stop processing, live color stream
- **Post-Processing Templates** — filter pipeline configurations
- **Color Strip Sources**, **Pattern Templates**, **Gradients** — color generation
- **Audio Sources / Templates / Filters** — audio capture and reactive processing
- **Value Sources**, **Weather Sources**, **Scene Presets** — dynamic parameters and presets
- **Automations**, **Webhooks**, **HTTP Endpoints**, **Game Integration** — triggers and rules
- **MQTT** & **Home Assistant**broker sources and HA integration
All endpoints require API key authentication via `X-API-Key` header or `?token=` query parameter.
Authentication uses a Bearer token (`Authorization: Bearer <api-key>`) when API keys are configured; loopback requests are anonymous by default. WebSocket connections authenticate via a first-message handshake.
See [docs/API.md](docs/API.md) for the full reference.
@@ -253,16 +277,16 @@ ruff check src/ tests/
Optional extras:
```bash
pip install -e ".[perf]" # High-performance capture engines (Windows)
pip install -e ".[camera]" # Webcam capture via OpenCV
pip install -e ".[perf]" # High-performance capture engines (Windows: DXCam, BetterCam, WGC)
pip install -e ".[notifications]" # OS notification capture (WinRT / dbus)
pip install -e ".[scrcpy]" # Capture from an Android phone via scrcpy
pip install -e ".[ble]" # Bluetooth LE LED controllers (desktop only)
```
## Contributing
Contributions are welcome. LedGrab is MIT-licensed, so you're free to fork, modify, and self-host. Please open an issue or pull request on the [repository](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab).
## License
MIT — see [LICENSE](LICENSE).
## Acknowledgments
- [WLED](https://github.com/Aircoookie/WLED) — LED control firmware
- [FastAPI](https://fastapi.tiangolo.com/) — Python web framework
- [MSS](https://python-mss.readthedocs.io/) — Cross-platform screen capture
MIT — see [LICENSE](LICENSE). Free and open source.
+68 -144
View File
@@ -1,167 +1,91 @@
## v0.7.0 (2026-05-26)
## v0.9.0 (2026-06-23)
A device-support release: **seven new device families**, a unified **pairing UX**,
a brand-new **HTTP-endpoint** output type, **multi-broker MQTT + Zigbee2MQTT**
support, a major **shutdown / data-safety** fix, and a deep architectural
refactor pass that landed registry patterns for every dispatch hot path.
A large feature release: a full activity/audit log, two roadmap batches of
capture and smart-light improvements, per-pixel control for LIFX/Hue/Nanoleaf,
and new outbound integrations (webhooks + Home Assistant MQTT discovery).
### Features
#### New device types
#### Activity Log
- Persistent activity/audit log: storage model with migration, recorder with
actor context and retention, and event instrumentation across four
categories ([1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f), [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e), [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c))
- REST API for list / export / settings / clear ([4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275))
- Activity tab with smart filtering, live updates, and export, plus a
dashboard widget and settings panel ([9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f), [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21))
- **DDP** — standalone Open-Pixel-Control-style target for Pixelblaze / ESPixelStick / xLights / Falcon endpoints, port 4048 ([8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a))
- **Yeelight** — Xiaomi/Yeelight bulbs and lightstrips over JSON-RPC on port 55443, SSDP discovery ([4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005))
- **WiZ Connected** — Philips WiZ smart bulbs over UDP on port 38899, broadcast discovery ([ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b))
- **LIFX** — LIFX bulbs and lightstrips over the binary LIFX LAN protocol on port 56700 ([8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490))
- **Govee LAN** — Govee Wi-Fi bulbs and ambient kits, multicast discovery (requires "LAN Control" enabled in the Govee Home app) ([887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d))
- **Open Pixel Control (OPC)** — Fadecandy boards, xLights/Falcon, OPC bridges, port 7890 with channel addressing ([31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a))
- **Nanoleaf** — Light Panels / Canvas / Shapes / Lines / Elements over the documented HTTP REST API on port 16021 ([426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a))
#### Per-pixel smart lights
- LIFX multizone (SetExtendedColorZones) and Tile per-pixel streaming,
auto-detected on connect with single-colour fallback ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- Philips Hue gradient-lightstrip mapping: Entertainment v2 frames keyed by
channel id, with a `hue_gradient_mode` toggle ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- Nanoleaf extControl v2 per-panel UDP streaming (`per_panel` mode) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
#### New output type
#### Capture & effects
- Linear-light blending and spatio-temporal dithering, opt-in per calibration ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- Audio-reactive palette modulation across all 12 procedural effects ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- Color-harmony gradient generator (complementary / analogous / triadic / …) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- **HTTP endpoint output target** — POST live strip frames to any user-configured HTTP endpoint, alongside WLED / MQTT / Hue. Full editor + storage + routes ([d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800))
#### Pairing flow
- Generic **pairing UX scaffold** — 30-second SVG ring + countdown, instructions, retry/cancel. First concrete consumer is Nanoleaf; Tuya/Twinkly slot into the same shape later ([2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680))
#### MQTT / Zigbee2MQTT
- **Multi-broker MQTT** + new **Zigbee2MQTT light output target** sharing the HA-Light editor. Legacy single-broker YAML/env config auto-migrates to a "Default Broker" MQTTSource on startup ([530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c))
#### Editor experience
- **Live preview** for color-strip sources of every type that can render without external calibration (audio, math_wave, weather, game_event, api_input, mapped, composite, processed) ([337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c))
- **Expanded automations** — new rule shapes + matching UI inputs + 285 lines of dispatch coverage ([3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8))
- **Expanded value sources** — storage + schema + UI for the new value-source kinds the per-type factory refactor introduced ([737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72))
- **Card icon picker expanded** from 44 → 120 icons across 5 new categories (weather, nature, controls, status, office) ([cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94))
- **closeIfPristine** modal save-guard — editing an unchanged entity now silently closes the modal instead of firing a misleading "updated" toast ([f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30))
- New **MiniSelect** primitive for compact dropdowns that don't justify the full IconSelect grid; **IconSelect** gains a defence-in-depth XSS sanitiser on the icon channel ([9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd), [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138))
#### Updater
- **SSRF-validated redirect chain** in the update service so a hostile mirror can't bounce the updater to a private IP. Stricter `restart.ps1` argument handling + clearer logs ([45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2))
#### Automations & integrations
- Solar sunrise/sunset automation trigger (new `utils/solar.py`) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- Outbound webhook automation action (Discord / IFTTT / Zapier / Node-RED),
SSRF-gated at save and fire time ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- Home Assistant MQTT auto-discovery: read-only binary sensors per automation,
availability via birth/will, with cleanup on disable/delete ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- League of Legends poller wired via a `LoLPollManager` + shared runtime state ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- `auth.expose_docs` flag (default off) to view `/docs`, `/redoc`, and
`/openapi.json` without a token; all real endpoints stay protected ([126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2))
### Bug Fixes
- **Survive PC restart** — SQLite was running WAL with `synchronous=NORMAL` and `Database.close()` was never called, so an unclean Windows shutdown rolled the DB back to the last checkpoint and silently lost recent edits. Now uses `synchronous=FULL` + `wal_autocheckpoint=100` + explicit `wal_checkpoint(TRUNCATE)` on close, and a hidden WM_QUERYENDSESSION / WM_ENDSESSION window keeps Windows from force-killing the process before the lifespan can finish ([e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3))
- **Devices PATCH preserves URL** — PATCH-without-`url` (rename / icon-only) used to drop the address into the processor as None ([0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43))
- **HA Light brightness scale** — `_send_entity_color` was double-applying `brightness_scale` below 1 (quartered output for a half-scale) and skipping it above 1 (boost lost). Now one `clamp(max(r,g,b) * bs * vs, 0, 255)` pass with regression coverage ([ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60))
- **Dashboard "MODIFIED" badge** no longer fires retroactively on un-edited legacy layouts — `userModified` is now driven by actual edits, not deep-equal drift from defaults ([e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d))
- **Transport-bar uptime** repaints on `/health` response instead of waiting up to ~10 s for the next poll ([f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e))
- **Pre-merge device-support review pass** — `update_device` no longer double-encrypts secrets in memory; `GET /devices` strips paired-only secrets behind boolean flags; SSRF validation on every new driver; corrupt-envelope decrypt returns `""` instead of deleting the device row; `update_device` URL trim matches create; Govee discovery port-4002 collision serialised behind a module lock; Nanoleaf mDNS scan cleans up tasks on cancel; pair endpoint stops logging userinfo / exception bodies ([0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78))
- **value_source factory contract** — `_build_game_event` raises `NotImplementedError` (preserves the historical store contract) and `create_source` runs `build_source` before `_check_name_unique` so an invalid `source_type` raises the right error ([c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb))
- **`utils/url_scheme` + `utils/net_classify`** were referenced but untracked on a clean checkout — server failed to start with `ModuleNotFoundError`. Now committed ([7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6))
### Performance
- **Capture hot paths vectorised** — WGC swaps per-frame ~30 MB BGRA→RGB fancy-index allocations for `cv2.cvtColor` into a 3-slot pre-allocated pool; MSS uses `screenshot.raw + cv2.cvtColor` with 256-byte change-detection; DXcam/BetterCam fixes a silent name-mangling factory leak; dominant-colour reduction is ~10× faster via packed-RGB `np.bincount` ([f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0))
- **Event-driven frame hand-off** — `LiveStream` gains a `frame_id` + `Condition`, consumers wait instead of polling, ring buffer grows 3 → 5 slots, `_blend_u16` uses `cv2.addWeighted`. Up to one `frame_time` of glass-to-LED latency saved at matched FPS ([ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81))
- **WLED brightness threshold** caches per-frame `np.max` keyed on frame identity instead of reducing the LED array every loop ([6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6))
- **Dashboard FPS charts** now diff target ids and only recreate added/removed/detached charts (skipping the history fetch when local samples already exist), and spark SVGs are mutated in place instead of `innerHTML`-rewritten every poll. Memoised patches/devices rendering by content signature so unchanged ticks no longer restart CSS animations ([f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9))
- Pre-release review hardening: solar timezone crash, webhook header CRLF,
MQTT topic-prefix injection, thread-safe `get_stats` copy, MQTT discovery
lock, `reactive_mode` Literal, and calibration-modal accessibility ([0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db))
- Comprehensive review fixes across security, concurrency, performance,
Android, and UI ([17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0))
- Activity Log polish: accessible export menu, i18n placeholders, dashboard
section reconciliation, column alignment, ticking time, and no spinner
flash on instant filtering ([3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3), [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06), [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8), [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca), [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c))
---
### Development / Internal
#### Architecture audit — registry patterns everywhere
#### CI/Build
- Best-effort arm64 multi-arch Docker manifest via QEMU + `docker manifest`
(amd64 path untouched) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- **Color-strip stream dispatch** — `ColorStripStreamManager.acquire()` and `ws_stream._create_stream()` now share a `STREAM_BUILDERS` registry keyed by source type, with import-time coverage assertion against `_SOURCE_TYPE_MAP`. CSS response builder gets the same treatment ([563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac))
- **Value-source create / update** — `ValueSourceStore.create_source` shrinks from ~260 → ~25 lines via per-type builder/applier functions in a new `storage.value_source_factories` module ([3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e))
- **SystemMetricsValueStream** — three parallel `if/elif` chains collapse into a `MetricSpec(name, read_psutil, read_fallback, normalize, prime)` registry in `core.processing.metric_readers` ([9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346))
- **Automation engine** — per-rule-type bodies become `_handle_<kind>` methods, dispatch table built once at class-creation, unknown-type fallback logs instead of silently returning False ([98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d))
- **Effect renderer dispatch** — `@_effect_renderer("fire")` decorators + class-level `_RENDERERS` dict replace per-frame dict-rebuild + silent fire fallback ([97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c))
- **Output-target response builders** — `isinstance` ladder + silent fabricated-LED fallback replaced with `_TARGET_RESPONSE_BUILDERS` dict and a runtime `RuntimeError` for unknown subclasses ([2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb))
- **Versioned data migrations** — replaces a naked `blob.replace(...)` migration with `storage.data_migrations.MigrationRunner` backed by a `data_migrations` audit table and atomic transactions ([563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac))
#### Chores
- Activity Log feature plan/subplan scaffold, post-merge cleanup, and context
graduated into CLAUDE.md ([1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6), [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235))
#### Dedup / refactor
- **Edge-to-LED kernels** in `PixelMapper` + `AdvancedPixelMapper` deduped into a shared `core.capture.edge_interpolation` module ([5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db))
- **HA/Z2M `_swap_color_source`** unified behind a shared `light_target_helpers.swap_color_source` helper ([29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf))
- **Single-pixel `_average_color`** lifted out of 6 LED drivers into `core.devices.pixel_reduce.average_color` ([cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba))
- **Static → single rename** for the color-strip source kind. Storage keeps backward-compatible serialisation ([826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680))
- **Bindable types** extracted into `types/bindable.ts`; the wider `types.ts` god-module split is staged for a follow-up frontend sprint ([05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee))
- **WebSocket auth** — 11 `except Exception` sites around handshake replaced with a narrow `_WS_SEND_BENIGN_EXC` tuple; receive path adds explicit observability ([ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88))
- **Backend hardening bundle** — MQTT task tracking + drain resilience, credential encryption with auto-migration, devices watcher task tracking, WLED scheme inference at boundaries, streaming-upload caps, `asyncio.gather(return_exceptions=True)` on broadcast loops, WebSocket Origin allow-list, `/docs` auth-gate ([898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f))
- **Frontend infra** — inbound-event allowlist mirroring the server side, `closeIfPristine` adoption across editors, MiniSelect markup for filter pickers ([ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571))
- **PEP-604 union sweep** — `ruff --select UP007,UP045 --fix` converted ~1760 sites from `Optional[T]` / `Union[X, Y]` to `T | None` / `X | Y`. Hooks bumped to ruff v0.15.12 to recognise UP045 ([888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd))
- **Typed window globals** — 59 `(window as any).foo` sites across 19 feature modules switched to typed `window.foo` against `global-types.d.ts` ([0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172))
- **Processing magic numbers** lifted to named module constants so tests can monkeypatch them ([d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f))
- **`Database.ensure_open()`** — module-level singleton reopens cleanly across lifespan cycles, fixing 65 spurious `sqlite3.ProgrammingError` setup failures on Windows pytest aggregate runs ([f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25))
#### Tests
- WLED URL scheme integration + IPv6 regression coverage ([907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf))
- Lifespan reopen invariants on `Database` ([f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25))
- Hundreds of new tests covering every registry / factory / migration introduced above
#### Tooling / docs
- `.vex.toml` makes vex the project's primary code-search backend with auto-update + semantic embeddings ([06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba))
- `REVIEW_TODO.md` captures audit items deliberately deferred; `TODO.md` records the architecture-audit remainder ([06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba), [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2))
- Locale + CLAUDE.md upkeep alongside the new features ([fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51), [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9), [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af), [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4))
> Tests: ~180 new unit tests added across the activity log, roadmap features,
> and integrations. Release gate green: ruff + tsc + build clean,
> **pytest 2739 passed / 2 skipped**.
---
<details>
<summary>All Commits (55)</summary>
<summary>All Commits</summary>
| Hash | Message |
|------|---------|
| [f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25) | fix(storage/database): reopen connection on lifespan restart |
| [f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9) | perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings |
| [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9) | docs(review-todo): check off items addressed in 2026-05-23 autonomous pass |
| [0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172) | refactor(types): migrate (window as any) statics to typed window globals |
| [888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd) | refactor(types): PEP-604 union sweep + UP007/UP045 enforcement |
| [ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88) | refactor(api/auth): narrow WS exception catches + observability log |
| [d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f) | refactor(processing): hot-path magic numbers -> named module constants |
| [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138) | feat(ui/icon-select): defence-in-depth XSS sanitiser on icon channel |
| [907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf) | test(url-scheme): WLED route-level integration + IPv6 regression |
| [0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43) | fix(devices): preserve existing URL on PATCH-without-url |
| [fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51) | docs: TODO + CLAUDE.md notes + locale keys for new features |
| [ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571) | chore(frontend-infra): inbound-event allowlist + storage/state touch-ups |
| [898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f) | chore(backend): MQTT/WLED/devices/capture/utils + api routes hardening |
| [45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2) | feat(update-service): SSRF-validated redirects + restart hardening |
| [826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680) | refactor(color-strip): rename static -> single + frontend follow-through |
| [737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72) | feat(value-sources): extend storage + schema + UI alongside new kinds |
| [3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8) | feat(automations): expand automation rules + UI + engine coverage |
| [f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30) | feat(modal): closeIfPristine save-guard + per-editor adoption |
| [9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd) | feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals |
| [d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800) | feat(http-endpoints): introduce HTTP endpoint output target stack |
| [06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba) | chore(tooling): vex semantic-search config + REVIEW_TODO backlog |
| [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2) | docs: capture architecture-audit remainder for follow-up sessions |
| [2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb) | refactor(output-targets): registry + coverage assertion for response builders |
| [c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb) | fix(value-source): preserve store contract for game_event + error precedence |
| [3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e) | refactor(value-source): per-type factories for create / update dispatch |
| [05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee) | refactor(types): extract bindable primitives into types/bindable.ts (H6 partial) |
| [9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346) | refactor(value-source): MetricSpec registry for SystemMetricsValueStream |
| [98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d) | refactor(automations): rule dispatch via class-level handler table |
| [5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db) | refactor(capture): lift duplicated edge-to-LED kernels into shared module |
| [97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c) | refactor(processing): replace inline effect dispatch with @_effect_renderer registry |
| [29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf) | refactor(processing): dedupe HA/Z2M _swap_color_source via shared helper |
| [563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac) | refactor(storage,processing): kind registries + versioned data migrations |
| [e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3) | fix(shutdown): survive PC restart with WAL fsync + Win32 session-end guard |
| [e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d) | fix(dashboard): stop showing perpetual MODIFIED for un-edited legacy layouts |
| [f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e) | fix(ui): repaint transport-bar uptime as soon as /health responds |
| [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af) | docs: record review-fix pass in TODO.md |
| [0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78) | fix(devices): address pre-merge review findings |
| [7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6) | fix(utils): commit url_scheme + net_classify dependencies |
| [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4) | docs: mark expand-device-support branch ready for merge |
| [cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba) | refactor(devices): extract _average_color to pixel_reduce |
| [426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a) | feat(devices): Nanoleaf OpenAPI target type + first pair-flow user |
| [2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680) | feat(devices): pairing-UX scaffold (Phase 2) |
| [31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a) | feat(devices): Open Pixel Control (OPC) target type |
| [887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d) | feat(devices): Govee LAN target type |
| [8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490) | feat(devices): LIFX LAN target type |
| [ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b) | feat(devices): WiZ Connected LAN target type |
| [4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005) | feat(devices): Yeelight LAN target type |
| [8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a) | feat(devices): standalone DDP target type |
| [337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c) | feat(color-strips): in-editor live preview for all viable source types |
| [530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c) | feat(mqtt): multi-broker MQTT + Zigbee2MQTT light target |
| [6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6) | perf(wled): cache per-frame max-pixel for brightness threshold |
| [ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81) | perf(processing): event-driven frame hand-off and scheduling fixes |
| [f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0) | perf(capture): vectorize hot paths and fix engine bugs |
| [ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60) | fix(ha-light): apply brightness_scale once and respect boost multipliers |
| [cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94) | feat(ui): expand card icon picker (44 -> 120 icons, +5 categories) |
| Hash | Message | Author |
|------|---------|--------|
| [0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db) | fix: address pre-release review findings (2026-06-23) | alexei.dolgolyov |
| [39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554) | feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations | alexei.dolgolyov |
| [6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25) | feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations | alexei.dolgolyov |
| [126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2) | feat(auth): add auth.expose_docs flag to view API docs without a token | alexei.dolgolyov |
| [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235) | chore(activity-log): post-merge cleanup + graduate context to CLAUDE.md | alexei.dolgolyov |
| [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c) | fix(activity-log): no spinner flash on instant filtering | alexei.dolgolyov |
| [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca) | fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix | alexei.dolgolyov |
| [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8) | fix(activity-log): dashboard section reconciliation + activity column alignment | alexei.dolgolyov |
| [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06) | fix(activity-log): post-test polish - localize descriptions, dashboard widget, ticking time | alexei.dolgolyov |
| [3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3) | fix(activity-log): final-review fixes - crosslink keys + sanitize parity | alexei.dolgolyov |
| [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21) | feat(activity-log): phase 6 - dashboard widget + settings panel + docs | alexei.dolgolyov |
| [9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f) | feat(activity-log): phase 5 - Activity tab (smart filtering, live updates, export) | alexei.dolgolyov |
| [4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275) | feat(activity-log): phase 4 - REST API (list/export/settings/clear) | alexei.dolgolyov |
| [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c) | feat(activity-log): phase 3 - event instrumentation (4 categories) | alexei.dolgolyov |
| [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e) | feat(activity-log): phase 2 - recorder, actor context, retention, lifecycle | alexei.dolgolyov |
| [1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f) | feat(activity-log): phase 1 - storage model, migration, repository | alexei.dolgolyov |
| [1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6) | chore(activity-log): scaffold feature plan and phase subplans | alexei.dolgolyov |
| [17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0) | fix: resolve comprehensive review findings (security, concurrency, perf, Android, UI) | alexei.dolgolyov |
</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.
+138 -5
View File
@@ -201,9 +201,19 @@ caller off the legacy path, then delete it.
- [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)*
- [x] Device editor: MQTT source picker shown for `device_type=mqtt`. Turned
out the API layer was *also* missing it (the TODO's "backend accepts the
field" was wrong — `mqtt_source_id` lived in `device_store` +
`device_config.MQTTConfig` but was dropped by `DeviceCreate/Update/Response`
and the routes). Added: schema fields + route threading + referenced-source
validation (`_validate_mqtt_source_exists`, mirrors output_targets) +
`except HTTPException: raise` guard in `update_device` (it was masking its
own 4xx as 500). Frontend: broker `EntitySelect` (reusing `mqttSourcesCache`)
in both the add-device (`device-discovery.ts`) and settings
(`devices.ts`) modals — shown for `device_type=mqtt`, wired into
load/save/validate/dirty-check/clone. Empty = "first available broker".
4 regression tests in `test_devices_routes.py::TestMqttSourceId`; full
suite 1567 passing; en/ru/zh keys added.
### Phase 5 — `AutomationEngine`
@@ -213,8 +223,11 @@ caller off the legacy path, then delete it.
### 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)
- [x] Update frontend dashboard payload (MQTT widget now expects a list of
sources instead of a single `enabled`/`connected` pair — surface in UI).
Done: `dashboard.ts` `_renderMQTTIntegrationCard` renders one card per
`mqttStatus.connections` entry; `_updateIntegrationsInPlace` iterates the
list.
### Phase 7 — Startup migration
@@ -980,3 +993,123 @@ After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen f
- LOW: Nanoleaf `.port` property added; pair-then-create E2E test
added.
- Tests: 1379 pass (+21 regression tests).
## Graph editor — "full control of wiring via graph" (in progress)
Goal: make the visual graph a first-class wiring control surface, not just a
viewer. Driven by the ULTRA-DEEP review (findings A1A5, B1B6, C1C6, D1D6).
### Done (NOT yet committed — awaiting review/commit)
- [x] **A1** Undo/redo wired to connect/detach/move (was dead code); inverse ops
throw on failure so the stack can't silently desync.
- [x] **A2** Manual node layout persists to `localStorage` (`graph_node_positions`),
cleared on relayout.
- [x] **A3** Scene-preset disambiguation — deactivation scene now reachable via a
field picker (was always picking the first match).
- [x] **B6** Edge field labels (revealed on zoom ≥ 0.9).
- [x] **C3** Health overlay — broken refs (referrer exists, target missing),
dependency cycles, orphans; node warning badges + an issues toolbar button.
- [x] **D1** `GET /api/v1/graph/schema` — authoritative connectable-field registry
(`api/graph_schema.py`, pure + unit-tested).
- [x] **D2** `GET /api/v1/graph` (nodes+edges+validation) and
`GET /api/v1/graph/dependents/{kind}/{id}`.
- [x] **D4** `POST /api/v1/graph/validate-connection` — existence + source-kind +
cycle pre-flight; frontend validates before every write (fails open if the
endpoint is unreachable). List/double-nested fields rejected.
- [x] **B2** Drop-on-node connect — empty top-level slots are now wireable (drop a
source onto any compatible node body, not just an existing port).
- [x] **C4** Overwrite-occupied-slot confirm + delete-with-dependents warning
(single delete only; bulk keeps the batch confirm).
- [x] **D5** Create-and-connect — drag a port onto empty canvas → pick a compatible
new entity kind → it's created and auto-wired (kind-scoped watcher).
- [x] **D6 (read-only half)** "Export graph (JSON)" toolbar action.
- [x] Custom per-entity `icon` + `icon_color` now render on graph nodes (parity
with custom node colours; fallback to kind/subtype glyph).
- [x] **B1** Edit single-level **BindableFloat** value slots from the graph
(`brightness`, `smoothing`, `intensity`, `scale`, `speed`, … on
color_strip_source; `brightness`/`transition` on output_target). Subtype-safe
(only offers slots the target entity actually has). Writes the partial
`{ <slot>: { source_id } }` payload → backend `Bindable*.apply_update` merges,
preserving the static value. Verified data-safe (no `from_raw`/value-reset path).
- [x] Render the two functional value-source references `buildGraph` was missing —
`value_source.value_source_id` (gradient_map → inner value source) and
`value_source.color_strip_source_id` (css_extract → strip). Both are runtime-
resolved and already drag-editable; now visible/detachable in the graph.
- [x] **B4 foundation:** backend schema now authoritative about graph-editability
(`is_editable()` + `editable` flag in `/graph/schema`); `validate-connection`
hardened to reject non-editable fields (colour/list/double-nested), not just lists.
- [x] **B4 drift guard + gap fixes:** `checkSchemaDrift()` (graph-connections.ts) warns
once if the frontend `CONNECTION_MAP` editable set diverges from `/graph/schema`
(the automated "10-step checklist"). Surfacing it found 3 real gaps; fixed 2:
`color_strip_source.input_source_id` + `processing_template_id` are now drag-editable
(processed-strip wiring; `apply_update` is partial-safe). The 3rd —
`device.default_css_processing_template_id` — is intentionally NOT drag-editable
(the device PUT route isn't partial-safe; a one-field PUT could null the URL) and is
in the drift-check exclude set. Also broadened `_availableMatches` to hide any slot
the target entity doesn't expose (subtype-accurate; refs are always-emitted so empty
slots stay wireable). Review also caught a **dead `output_target.picture_source_id`
slot** (no output target stores it — not a field/schema, never emitted) — removed
from both registries + `buildGraph`.
- [x] **Comprehensive review pass (4 subagents: backend/frontend-core/orchestrator/security).**
Findings fixed:
- **CRITICAL (security):** `GET /api/v1/graph` leaked plaintext **webhook tokens**
(`asdict` recursed `Automation.rules[].token`, an auth-equivalent secret). Fixed with
**field-projection** — `serialize_entity_for_graph()` / `graph_field_roots()` project
each entity to only `{id, name, subtype, reference-roots}`; secrets can't survive.
Added a structural regression test asserting no projection root is secret-bearing for
any kind (drift-proof boundary) + a token-drop test.
- MEDIUM: added missing `value_source.clock_id` (AnimatedColorValueSource → sync_clock)
to the backend registry for topology/dependents completeness (drift-excluded on the
frontend — value-source PUT needs a `source_type` discriminator, so it's editor-only).
- MEDIUM/LOW: `CSS.escape` on the markIssues id selector; grouped/clarified
`_DRIFT_EXCLUDE`; fixed the stale `_availableMatches` JSDoc; documented the
`checkSchemaDrift` forward-reference. Orchestrator + frontend-core + security: APPROVE.
- Verification: `npm --prefix server run typecheck` + `run build` clean; ruff clean;
graph backend tests 35 pass; full backend suite green. ~8 code-review passes,
all CRITICAL/HIGH findings fixed.
### Left to do (deferred)
- [x] **BindableColor slots** — CHECKED, decision: keep read-only (won't fix).
Value sources are scalar-only (`ValueStream.get_value() -> float`) and every
colour consumer (`color_strip/single.py`, `effect_stream.py`) reads the static
RGB via `bcolor()`, ignoring `source_id`. So a value_source cannot drive a
colour — wiring `color`/`color_peak`/… would be a dead binding. Documented in
`api/graph_schema.py` next to the BindableColor entries. (Would only become
viable if a colour-producing value-source type is added.)
- [~] **B4 — delete the frontend `CONNECTION_MAP` duplication.**
- [x] **Foundation done:** the backend schema now carries an authoritative
`editable` flag per field (`is_editable()` in `api/graph_schema.py`, mirroring
the frontend `_isEditable`: top-level refs + single-level BindableFloat slots;
NOT colour/list/double-nested). `validate-connection` is hardened to reject any
non-editable field (was list-only). `editable` is surfaced in `/graph/schema`.
- [ ] **Remaining (the refactor):** frontend fetches `/graph/schema` on load and
derives connection metadata + edges from it (port the `extract_refs` dot-path/list
grammar to TS), keeping only a tiny `kind → {endpoint, cache}` write-routing table;
then delete the field-level `CONNECTION_MAP` + the `buildGraph` edge loops
(graph-connections.ts / graph-layout.ts). Removes the 10-step sync checklist in
`contexts/graph-editor.md`. **A backend apply-write endpoint is NOT required** —
keep the proven per-entity PUT. Risk: regressing drag-connect/bindable; keep a
dev drift-check (frontend editable set vs `/graph/schema`) during the transition.
Note: frontend `CONNECTION_MAP` also has inert `ha_source_id`/`gradient_id` entries
(no graph node kind) — drop them, the backend schema already omits them.
- [ ] **D6 — blueprint import/instantiate.** Export exists; the apply half (serialize
a selected subgraph's topology + entities, re-import with id remapping, conflict
handling) is large and data-integrity-sensitive (see Data Migration Policy in
CLAUDE.md). Scope as its own feature.
- [ ] **List-slot editing** (composite `layers[]`, mapped `zones[]`, scene preset
`targets[]`) — needs an element index in the write + validate paths
(`validate_connection` currently rejects list fields). Edit via entity modal
for now.
### Notes / decisions
- The backend `CONNECTION_SCHEMA` (`api/graph_schema.py`) is the authoritative
superset; it already declares the bindable + list + value_source-chain edges. The
frontend `CONNECTION_MAP` still owns write-routing (endpoint/cache) — that's the
only reason it survives (see B4).
- Bindable edges render dashed (`.graph-edge-nested`) but ARE editable — the dashed
style intentionally distinguishes value bindings from structural edges.
- `validate-connection` and `dependents` fail **open/safe** on the frontend so the
graph keeps working against an older server without these endpoints.
+48 -11
View File
@@ -30,23 +30,39 @@ val ledgrabVersionCode: Int = run {
android {
namespace = "com.ledgrab.android"
compileSdk = 34
// SDK 35 (Android 15) — required for Play Store from Aug 2025 onward.
compileSdk = 35
defaultConfig {
applicationId = "com.ledgrab.android"
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
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.7.0"
versionName = "0.9.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 {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
// emulators), x86 (legacy emulators). Wheels in android/wheels/
// must be kept in sync — see build-scripts/build-pydantic-core.sh.
abiFilters += listOf("arm64-v8a", "x86_64", "x86")
// arm64-v8a is the primary target (real TV hardware).
// x86_64/x86 cover emulators.
// armeabi-v7a is opt-in: many pre-2018 Mecool/X96/H96 TV boxes
// 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.
splits {
abi {
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
isEnable = true
reset()
include("arm64-v8a", "x86_64", "x86")
if (v7Wheel) include("armeabi-v7a")
isUniversalApk = true // also produce a fat APK for sideloading
}
}
@@ -96,10 +115,21 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = if (hasCiSigning) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
// Refuse to silently sign release APKs with the debug
// keystore — that's how a debug-signed release accidentally
// ships. CI must provide all four signing env vars. If a
// 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,8 +205,15 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
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
implementation("com.google.zxing:core:3.5.3")
// EncryptedSharedPreferences (Android Keystore-backed) for the per-install
// server API key (see ApiKeyManager). Falls back to plain SharedPreferences
// when the keystore is unavailable.
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
+93 -8
View File
@@ -14,7 +14,8 @@
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
tools:targetApi="s" />
<!-- BLE hardware — required=false so non-BT boxes still install. -->
<uses-feature
@@ -26,13 +27,57 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_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_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- FOREGROUND_SERVICE_CAMERA (API 34+): required to keep camera access while
the app is backgrounded during on-device webcam capture. The service is
promoted with the `camera` FGS type ONLY when CAMERA is already granted
(see CaptureService.onStartCommand) — unlike audio playback capture (which
rides the MediaProjection token under the mediaProjection type), the camera
has no such coupling and needs its own FGS type to survive backgrounding. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- RECORD_AUDIO for on-device system-playback capture (AudioPlaybackCapture,
API 29+) feeding audio-reactive lighting. Runtime "dangerous" permission,
requested in MainActivity; capture degrades gracefully when denied.
Playback capture runs under the existing mediaProjection FGS type, so no
FOREGROUND_SERVICE_MICROPHONE / microphone FGS type is needed (that would
only be required if the mic-fallback path ran inside the service). -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- CAMERA for on-device webcam capture (Camera2). Runtime "dangerous"
permission, requested in MainActivity gated on FEATURE_CAMERA_ANY so
camera-less TV boxes never see the prompt; capture degrades gracefully
when denied. The camera is opened ON DEMAND (only while a camera
capture source is active). To keep capturing after the app is
backgrounded, the service is promoted with the `camera` FGS type
(FOREGROUND_SERVICE_CAMERA above) — but only when CAMERA is already
granted, so a camera-less / not-yet-granted box never risks a failed
service start. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- PACKAGE_USAGE_STATS — read the foreground app for the "Application"
automation rule (foreground app -> activate scene) via UsageStatsManager.
A special-access permission: it can't be granted at runtime; the user
toggles it under Settings > Usage access (opened from MainActivity).
tools:ignore="ProtectedPermissions" silences the build warning that this
is a system/signature-level permission — it is honoured as a user-grantable
special access. NO QUERY_ALL_PACKAGES is needed: matching only compares the
foreground package NAME, and the app picker uses LauncherApps. -->
<uses-permission
android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
mode so capture resumes without the user touching the remote. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -57,30 +102,70 @@
android:name="android.hardware.usb.host"
android:required="false" />
<!-- Camera hardware — for on-device webcam capture. required=false so
camera-less TV boxes (the common case) still install; the camera
engine simply reports no displays on such devices. camera.any covers
built-in (front/back) and external/USB-UVC cameras the platform
routes through Camera2. -->
<uses-feature
android:name="android.hardware.camera.any"
android:required="false" />
<application
android:name=".LedGrabApp"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:banner="@drawable/ic_launcher"
android:banner="@drawable/banner_tv"
android:networkSecurityConfig="@xml/network_security_config"
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
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:theme="@style/Theme.LedGrab.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</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
android:name=".CaptureService"
android:foregroundServiceType="mediaProjection"
android:exported="false" />
android:foregroundServiceType="mediaProjection|specialUse|camera"
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>
<!-- Notification capture — a NotificationListenerService bound by
system_server. exported="true" is REQUIRED here (the system binds
it cross-process) and intentionally diverges from CaptureService
(exported="false"); access is gated by the system-held
BIND_NOTIFICATION_LISTENER_SERVICE permission, so no new
<uses-permission> is needed. The user grants access via
Settings > Notification access (opened from MainActivity). -->
<service
android:name=".LedGrabNotificationListener"
android:label="@string/notification_listener_label"
android:exported="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<!-- Autostart — fires on device boot (and package replace).
On rooted devices, launches CaptureService directly so capture
@@ -0,0 +1,243 @@
package com.ledgrab.android
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
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 appContext = context.applicationContext
// Prefer Android-Keystore-backed EncryptedSharedPreferences for the API
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
// or absent keystore, or a key got corrupted), creation throws — fall back
// to plain SharedPreferences so a keystore failure NEVER bricks the local
// API key (which would 401 every LAN client).
private val prefs: SharedPreferences
init {
val (store, isEncrypted) = buildPrefs(appContext)
prefs = store
// Only run the plain→encrypted migration when the encrypted store is
// actually available; on the degraded plain path there is nothing to
// migrate INTO (and recoverLegacyKey reads the backup directly).
if (isEncrypted) migrateLegacyKeyIfPresent()
}
// 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
}
// Before minting a fresh key, fall back to any key still in the
// legacy plain store (covers a failed/partial encrypted migration:
// commit() can return false WITHOUT throwing, so migration may have
// left the live key only in the legacy file). Rotating the
// per-install key would 401 every already-paired client, so we
// generate a brand-new key ONLY when no key exists anywhere.
recoverLegacyKey()?.let { recovered ->
// Best-effort persist into the encrypted store; cache regardless
// so we still return the recovered key if the write keeps failing.
runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() }
cached = recovered
Log.i(TAG, "Recovered existing API key from legacy storage")
return recovered
}
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
}
}
/**
* Build the backing store, preferring EncryptedSharedPreferences. Returns
* (store, isEncrypted). Any keystore failure falls back to the plain prefs
* file so the local API key is never lost on a broken-keystore device.
*/
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
return try {
createEncrypted(context) to true
} catch (e: Exception) {
// The keystore can become invalidated (OS upgrade, device restore,
// OEM keystore bug), after which create() throws on EVERY launch and
// the corrupt encrypted file is never cleaned up — degrading to plain
// prefs forever and (because the live key was only in the encrypted
// store) rotating the per-install key on the next mint, 401-ing every
// paired client. Self-heal once: delete the corrupt store + master key
// alias and retry create() before degrading.
Log.w(TAG, "EncryptedSharedPreferences unavailable, attempting one-time reset: ${e.message}")
runCatching {
context.deleteSharedPreferences(ENCRYPTED_PREFS_NAME)
runCatching {
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (ks.containsAlias(MasterKey.DEFAULT_MASTER_KEY_ALIAS)) {
ks.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
}
}.onFailure { Log.w(TAG, "Master-key alias cleanup failed: ${it.message}") }
createEncrypted(context) to true
}.getOrElse {
// Still failing after reset — degrade to plain prefs rather than
// crashing. Worst case the key is stored unencrypted on a
// single-user TV box, which is the pre-existing behaviour.
Log.w(TAG, "EncryptedSharedPreferences still unavailable after reset, using plain prefs: ${it.message}")
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
}
}
}
private fun createEncrypted(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
/**
* One-time migration: if a key exists in the legacy plain-text prefs file
* (from before encrypted storage), copy it into the encrypted store and
* remove the plain copy. Preserves the existing key so already-scanned QR
* clients keep working — generating a fresh key here would silently 401
* every LAN client (see the Data Migration Policy in CLAUDE.md).
*/
private fun migrateLegacyKeyIfPresent() {
// Don't migrate if the encrypted store already holds a key.
if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return
runCatching {
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val legacyKey = legacy.getString(KEY_API_KEY, null)
if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) {
// commit() returns false on write failure WITHOUT throwing, so the
// runCatching wrapper alone does NOT protect this path. Verify the
// encrypted store both committed AND reads back the identical value
// before touching the legacy copy — otherwise a silent write
// failure could delete the only surviving copy of the key and
// rotate it on next launch (401s every paired client — the exact
// silent-data-loss the Data Migration Policy forbids).
val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit()
if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) {
// Keep the value as a .migrated backup (don't hard-delete) per
// the migration policy; remove only the live legacy key so the
// plaintext copy no longer answers reads.
legacy.edit()
.putString(KEY_API_KEY_MIGRATED, legacyKey)
.remove(KEY_API_KEY)
.apply()
Log.i(TAG, "Migrated API key from plain to encrypted storage")
} else {
// Leave the legacy key untouched; getOrCreateKey() will recover
// it via recoverLegacyKey() rather than minting a fresh one.
Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating")
}
}
}.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") }
}
/**
* Recover a still-present key from the legacy plain store — either the live
* key (failed/never-run migration) or the `.migrated` backup. Returns null
* only when no valid key survives.
*
* This MUST run on the degraded plain-prefs path too (not just the encrypted
* path): after a successful migration the live key is moved to the
* `.migrated` backup in this same plain file, so when the keystore later
* fails and we degrade to plain prefs, the backup is the only surviving
* copy. Returning null here (the previous `if (!encrypted) return null`
* guard) would mint a fresh key and rotate the per-install key, 401-ing every
* paired client.
*/
private fun recoverLegacyKey(): String? {
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val candidate = legacy.getString(KEY_API_KEY, null)
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
return candidate?.takeIf { it.length >= MIN_KEY_LENGTH }
}
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 ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
private const val KEY_API_KEY = "api_key"
// Backup of a migrated legacy key, kept in the plain store per the
// Data Migration Policy (never hard-delete user data on rename/move).
private const val KEY_API_KEY_MIGRATED = "api_key_migrated"
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"
}
}
@@ -0,0 +1,234 @@
package com.ledgrab.android
import android.annotation.SuppressLint
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioPlaybackCaptureConfiguration
import android.media.AudioRecord
import android.media.MediaRecorder
import android.media.projection.MediaProjection
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Captures audio with [AudioRecord] and pushes interleaved float32 PCM to
* the LedGrab Python server via [PythonBridge], where the
* `android_audio_engine` feeds it into the unchanged audio-analysis
* pipeline.
*
* Two sources:
* - [start] — system playback capture via `AudioPlaybackCapture` (API 29+),
* reusing the same [MediaProjection] token the app already holds for
* screen capture. This is the primary path on the consent flow.
* - [startMic] — microphone fallback (`AudioSource.MIC`) for paths with no
* MediaProjection (root mode) or API < 29.
*
* Mirrors [ScreenCapture]'s shape: a dedicated capture thread, a single
* reusable cross-JNI buffer (no per-block allocation → no GC churn on
* low-end TV boxes), and graceful teardown in [stop].
*
* The capture format is negotiated by [AudioRecord]; the **actual**
* channel count and sample rate are read back and forwarded to
* `configureAudio` so the Python analyzer's interleaving matches the bytes
* we push (e.g. a stereo request that the device satisfies as mono).
*/
class AudioCapture(
private val projection: MediaProjection?,
private val bridge: PythonBridge,
private val sampleRate: Int = 48000,
private val channels: Int = 2,
private val chunkFrames: Int = 1024,
) {
companion object {
private const val TAG = "AudioCapture"
private const val BYTES_PER_FLOAT = 4
}
private var audioRecord: AudioRecord? = null
private var captureThread: Thread? = null
@Volatile private var running = false
/**
* Start system playback capture (API 29+). Requires the app to hold
* RECORD_AUDIO and a valid [projection]. Returns true if capture began.
*/
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (running) return true
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Log.i(TAG, "Playback capture needs API 29+; skipping (have ${Build.VERSION.SDK_INT})")
return false
}
val proj = projection
if (proj == null) {
Log.i(TAG, "No MediaProjection; playback capture unavailable")
return false
}
val config = AudioPlaybackCaptureConfiguration.Builder(proj)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_GAME)
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
.build()
val record = try {
AudioRecord.Builder()
.setAudioFormat(audioFormat())
.setBufferSizeInBytes(bufferBytes())
.setAudioPlaybackCaptureConfig(config)
.build()
} catch (e: Exception) {
Log.e(TAG, "Failed to build playback AudioRecord: ${e.message}")
return false
}
return begin(record, "playback")
}
/**
* Start microphone capture (fallback). Works on API 24+ and needs no
* MediaProjection. Requires RECORD_AUDIO. Returns true if capture began.
*
* ⚠️ SECURITY/POLICY: currently UNWIRED (no caller). Microphone capture is
* a materially different posture than playback capture — it records real
* room audio (bystander voices). Before wiring this into [CaptureService]:
* - add FOREGROUND_SERVICE_MICROPHONE permission + the `microphone` FGS
* type (on API 34+ the service is killed without it), and
* - add the Play Store privacy disclosure for microphone use,
* - re-trigger a security review.
* Do NOT call this from inside the foreground service without the above.
*/
@SuppressLint("MissingPermission")
fun startMic(): Boolean {
if (running) return true
val record = try {
AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(audioFormat())
.setBufferSizeInBytes(bufferBytes())
.build()
} catch (e: Exception) {
Log.e(TAG, "Failed to build mic AudioRecord: ${e.message}")
return false
}
return begin(record, "mic")
}
/** Stop capturing and release all resources. Idempotent. */
fun stop() {
running = false
// AudioRecord.stop() unblocks a pending READ_BLOCKING read within
// milliseconds, so the loop sees running=false and returns well inside
// the 500ms join window — release() below won't race a live read.
// (Mirrors ScreenCapture's bounded join.)
runCatching { audioRecord?.stop() }
captureThread?.let { runCatching { it.join(500) } }
captureThread = null
runCatching { audioRecord?.release() }
audioRecord = null
runCatching { bridge.shutdownAudio() }
Log.i(TAG, "Audio capture stopped")
}
// ── internals ──────────────────────────────────────────────────────
private fun begin(record: AudioRecord, mode: String): Boolean {
if (record.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord ($mode) failed to initialize")
runCatching { record.release() }
return false
}
val actualChannels = record.channelCount.coerceAtLeast(1)
val actualRate = record.sampleRate
// Confirm recording actually started before reporting success —
// startRecording() can throw (exclusive-capture contention) or
// leave the record in a non-recording state, in which case read()
// would only ever return errors.
val started = runCatching { record.startRecording() }.isSuccess &&
record.recordingState == AudioRecord.RECORDSTATE_RECORDING
if (!started) {
Log.e(TAG, "AudioRecord ($mode) failed to start recording")
runCatching { record.release() }
return false
}
// Recording confirmed — tell Python the real negotiated format
// before frames flow, so the analyzer's channel/sample-rate match
// the interleaving we push.
bridge.configureAudio(actualRate, actualChannels, chunkFrames)
audioRecord = record
running = true
captureThread = Thread(
{ captureLoop(record, actualChannels) },
"LedGrab-AudioCapture",
).also { it.start() }
Log.i(TAG, "Audio capture started ($mode, sr=$actualRate ch=$actualChannels chunk=$chunkFrames)")
return true
}
/**
* Blocking read loop. Accumulates into fixed `chunkFrames * channels`
* float blocks and pushes only COMPLETE blocks — [AudioRecord.read]
* returns a variable count, so partial reads are stitched here rather
* than handed to Python as ragged chunks (the analyzer requires
* whole-frame, ≤ chunk-size blocks).
*/
private fun captureLoop(record: AudioRecord, actualChannels: Int) {
val blockFloats = chunkFrames * actualChannels
val floatBuf = FloatArray(blockFloats)
// Reusable little-endian byte buffer — Python copies on push, so the
// same backing array is safe to overwrite next block. Default
// ByteBuffer order is BIG_ENDIAN, which would corrupt every sample;
// LITTLE_ENDIAN matches numpy's native float32 on all Android ABIs.
val byteBuf = ByteArray(blockFloats * BYTES_PER_FLOAT)
val floatView = ByteBuffer.wrap(byteBuf).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
var filled = 0
while (running) {
val n = record.read(floatBuf, filled, blockFloats - filled, AudioRecord.READ_BLOCKING)
if (n < 0) {
if (running) {
// A negative read (e.g. ERROR_DEAD_OBJECT after an audio-route
// change, ERROR_INVALID_OPERATION) means this AudioRecord is
// finished. Deactivate the Python engine so is_available() stops
// advertising a dead stream and the audio-reactive consumer isn't
// left polling an empty queue forever. We're on the capture thread,
// so we can't call stop() (it would self-join) — just flip running
// and shut the engine down; onDestroy's stop() releases the record.
Log.w(TAG, "AudioRecord.read error: $n — stopping audio capture")
running = false
runCatching { bridge.shutdownAudio() }
}
break
}
filled += n
if (filled < blockFloats) continue
floatView.clear()
floatView.put(floatBuf, 0, blockFloats)
bridge.pushAudio(byteBuf)
filled = 0
}
}
private fun channelMask(): Int =
if (channels >= 2) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO
private fun audioFormat(): AudioFormat =
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
.setSampleRate(sampleRate)
.setChannelMask(channelMask())
.build()
private fun bufferBytes(): Int {
val minBuf = AudioRecord.getMinBufferSize(sampleRate, channelMask(), AudioFormat.ENCODING_PCM_FLOAT)
// A few blocks of headroom so a slow consumer doesn't overrun the
// hardware buffer between reads.
val want = chunkFrames * channels * BYTES_PER_FLOAT * 4
return if (minBuf > 0) maxOf(minBuf, want) else want
}
}
@@ -103,12 +103,32 @@ object BleBridge {
}
try {
bleHandler.post { scanner.startScan(callback) }
// startScan runs on the BLE handler thread; a denied
// BLUETOOTH_SCAN throws SecurityException there, which would
// crash the whole process (an uncaught exception on a handler
// thread is fatal). Catch it inside the posted body and report.
bleHandler.post {
try {
scanner.startScan(callback)
} catch (e: SecurityException) {
Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e)
} catch (e: Exception) {
Log.w(TAG, "BLE startScan failed: ${e.message}")
}
}
Thread.sleep(timeoutMs)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
} finally {
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
bleHandler.post {
try {
scanner.stopScan(callback)
} catch (e: SecurityException) {
Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e)
} catch (e: Exception) {
Log.w(TAG, "BLE stopScan failed: ${e.message}")
}
}
}
return seen.values.toList()
}
@@ -136,7 +156,18 @@ object BleBridge {
newState == BluetoothProfile.STATE_CONNECTED
&& status == BluetoothGatt.GATT_SUCCESS -> {
Log.d(TAG, "GATT connected to $address, discovering services")
gatt.discoverServices()
// Runs on the BLE handler thread; a denied
// BLUETOOTH_CONNECT throws SecurityException here, which
// would crash the process. Catch and fail the connect.
try {
gatt.discoverServices()
} catch (e: SecurityException) {
Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e)
readyDeferred.complete(false)
} catch (e: Exception) {
Log.w(TAG, "discoverServices failed: ${e.message}")
readyDeferred.complete(false)
}
}
newState == BluetoothProfile.STATE_DISCONNECTED -> {
Log.w(TAG, "GATT disconnected from $address (status=$status)")
@@ -0,0 +1,411 @@
package com.ledgrab.android
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.ImageFormat
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.media.Image
import android.media.ImageReader
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.util.Log
import android.util.Size
import android.view.Surface
import com.chaquo.python.PyObject
import com.chaquo.python.Python
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import org.json.JSONArray
import org.json.JSONObject
/**
* Android camera bridge exposed to the Python server via Chaquopy.
*
* Wraps the Camera2 API into synchronous, blocking calls that can be
* invoked from a Python thread (Chaquopy proxy threads are real OS
* threads). The physical camera is opened **on demand** — Python's
* `android_camera_engine` calls [startCamera] when a capture stream
* initializes and [stopCamera] when it cleans up, so the camera-in-use
* indicator and battery cost are limited to actual use.
*
* Each captured frame is converted YUV_420_888 → RGB and pushed to the
* Python engine's `push_frame`, mirroring how [ScreenCapture] feeds
* `mediaprojection_engine`. Camera2 callbacks run on a private
* [HandlerThread] so they never touch the main looper.
*
* Python callers access the singleton via
* `jclass("com.ledgrab.android.CameraBridge").INSTANCE` — see
* `server/src/ledgrab/core/capture_engines/android_camera_engine.py`.
*/
object CameraBridge {
private const val TAG = "CameraBridge"
private const val ENGINE_MODULE = "ledgrab.core.capture_engines.android_camera_engine"
private const val OPEN_TIMEOUT_MS = 8_000L
private const val MAX_IMAGES = 2
private const val TARGET_FPS = 20
// "auto" capture size — balanced for ambient LED sampling (the LED
// pipeline downscales anyway), kept modest so the per-frame YUV→RGB
// conversion stays cheap on low-end TV boxes.
private const val DEFAULT_W = 1280
private const val DEFAULT_H = 720
private const val BYTES_PER_RGB = 3
@Volatile private var appContext: Context? = null
// Dedicated looper thread so Camera2 callbacks don't land on main.
private val camThread = HandlerThread("LedGrab-Camera").also { it.start() }
private val camHandler = Handler(camThread.looper)
// Active session state — guarded by [lock]. One camera at a time.
private val lock = Any()
private var cameraDevice: CameraDevice? = null
private var captureSession: CameraCaptureSession? = null
private var imageReader: ImageReader? = null
@Volatile private var running = false
private var activeIndex = -1
// Cached Python engine module handle for the per-frame push fast path.
@Volatile private var engineModule: PyObject? = null
// Reusable conversion buffers — sized once per session (output size is
// fixed for the session), reused to avoid per-frame GC churn on TV boxes.
private var rgbBuffer: ByteArray? = null
private var yBuf: ByteArray? = null
private var uBuf: ByteArray? = null
private var vBuf: ByteArray? = null
// Monotonic frame pacing (mirrors ScreenCapture's accumulator).
private val frameIntervalNanos = 1_000_000_000L / TARGET_FPS.coerceAtLeast(1)
private var nextFrameNanos = 0L
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
@JvmStatic
fun init(context: Context) {
appContext = context.applicationContext
}
/**
* Enumerate cameras as a JSON array string the Python engine parses:
* `[{"index":0,"name":"Back camera","facing":"back","cameraId":"0"}, ...]`
*
* Indices are stable (positional in [CameraManager.cameraIdList]) so
* Python's `display_index` maps 1:1 to [startCamera]'s `index`.
* Enumeration needs no CAMERA permission. Returns `[]` on any error.
*/
@JvmStatic
fun listCameras(): String {
val arr = JSONArray()
val ctx = appContext
if (ctx == null) {
Log.w(TAG, "listCameras: context not bound (init not called)")
return arr.toString()
}
try {
val mgr = ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager
mgr.cameraIdList.forEachIndexed { idx, id ->
val facing = facingOf(mgr, id)
val name = when (facing) {
"front" -> "Front camera"
"back" -> "Back camera"
"external" -> "External camera $idx"
else -> "Camera $idx"
}
arr.put(
JSONObject()
.put("index", idx)
.put("name", name)
.put("facing", facing)
.put("cameraId", id),
)
}
} catch (e: Exception) {
Log.w(TAG, "listCameras failed: ${e.message}")
}
return arr.toString()
}
/**
* Open camera [index] and start streaming RGB frames to Python.
* Blocks until the capture session is configured (or fails/times out).
*
* Returns false — without throwing across the JNI boundary — when the
* CAMERA permission is missing, the index is out of range, or the
* device/session fails to configure. Closes any previously-open camera
* first (one active at a time).
*/
@SuppressLint("MissingPermission")
@JvmStatic
fun startCamera(index: Int, width: Int, height: Int): Boolean {
synchronized(lock) {
closeLocked()
val ctx = appContext ?: run {
Log.w(TAG, "startCamera: context not bound")
return false
}
if (ctx.checkSelfPermission(Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED
) {
Log.w(TAG, "startCamera: CAMERA permission not granted")
return false
}
val mgr = ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager
val ids = try {
mgr.cameraIdList
} catch (e: Exception) {
Log.w(TAG, "startCamera: cameraIdList failed: ${e.message}")
return false
}
if (index < 0 || index >= ids.size) {
Log.w(TAG, "startCamera: index $index out of range (${ids.size} cameras)")
return false
}
val cameraId = ids[index]
val size = chooseSize(mgr, cameraId, width, height) ?: run {
Log.w(TAG, "startCamera: no YUV output sizes for camera $index")
return false
}
val reader = ImageReader.newInstance(
size.width, size.height, ImageFormat.YUV_420_888, MAX_IMAGES,
)
// Size the conversion buffers once for this session.
rgbBuffer = ByteArray(size.width * size.height * BYTES_PER_RGB)
yBuf = null; uBuf = null; vBuf = null
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
reader.setOnImageAvailableListener({ r -> onFrame(r) }, camHandler)
return try {
runBlocking {
withTimeout(OPEN_TIMEOUT_MS) {
// Publish each resource to its field as soon as it exists so
// closeLocked() (in the catch) can release it if a LATER step
// throws. Assigning only after setRepeatingRequest succeeds
// would orphan the opened CameraDevice on a createSession /
// setRepeatingRequest failure (camera stuck on; subsequent
// opens fail with CAMERA_IN_USE).
imageReader = reader
val device = openCamera(mgr, cameraId)
cameraDevice = device
val session = createSession(device, reader.surface)
captureSession = session
val request = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
.apply { addTarget(reader.surface) }
.build()
session.setRepeatingRequest(request, null, camHandler)
activeIndex = index
running = true
Log.i(TAG, "Camera $index opened (${size.width}x${size.height} @ ${TARGET_FPS}fps)")
true
}
}
} catch (e: Exception) {
Log.e(TAG, "startCamera($index) failed: ${e.message}")
// imageReader/cameraDevice/captureSession are now whatever got
// assigned before the failure — closeLocked releases each exactly
// once (idempotent, runCatching-wrapped).
closeLocked()
false
}
}
}
/** Stop streaming and release the camera. Idempotent; safe if not started. */
@JvmStatic
fun stopCamera() {
synchronized(lock) { closeLocked() }
Log.i(TAG, "Camera stopped")
}
// ── internals ────────────────────────────────────────────────────────
private fun facingOf(mgr: CameraManager, id: String): String =
when (mgr.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING)) {
CameraCharacteristics.LENS_FACING_FRONT -> "front"
CameraCharacteristics.LENS_FACING_BACK -> "back"
CameraCharacteristics.LENS_FACING_EXTERNAL -> "external"
else -> "unknown"
}
/** Pick the supported YUV size closest in area to the request (or the
* balanced default for `auto`/0). */
private fun chooseSize(mgr: CameraManager, cameraId: String, reqW: Int, reqH: Int): Size? {
val map = mgr.getCameraCharacteristics(cameraId)
.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) ?: return null
val sizes = map.getOutputSizes(ImageFormat.YUV_420_888)
if (sizes == null || sizes.isEmpty()) return null
val targetArea = (if (reqW > 0) reqW else DEFAULT_W).toLong() *
(if (reqH > 0) reqH else DEFAULT_H)
return sizes.minByOrNull { kotlin.math.abs(it.width.toLong() * it.height - targetArea) }
}
@SuppressLint("MissingPermission")
private suspend fun openCamera(mgr: CameraManager, cameraId: String): CameraDevice =
suspendCancellableCoroutine { cont ->
mgr.openCamera(cameraId, object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice) {
if (cont.isActive) cont.resume(device) else device.close()
}
override fun onDisconnected(device: CameraDevice) {
device.close()
if (cont.isActive) cont.resumeWithException(IllegalStateException("camera disconnected"))
}
override fun onError(device: CameraDevice, error: Int) {
device.close()
if (cont.isActive) cont.resumeWithException(IllegalStateException("camera error $error"))
}
}, camHandler)
}
@Suppress("DEPRECATION")
private suspend fun createSession(device: CameraDevice, surface: Surface): CameraCaptureSession =
suspendCancellableCoroutine { cont ->
// createCaptureSession(List, callback, handler) is deprecated at
// API 30 but is the correct API down to minSdk 24 (the
// SessionConfiguration overload is API 28+).
device.createCaptureSession(
listOf(surface),
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
if (cont.isActive) cont.resume(session)
}
override fun onConfigureFailed(session: CameraCaptureSession) {
if (cont.isActive) cont.resumeWithException(IllegalStateException("session configure failed"))
}
},
camHandler,
)
}
/** ImageReader callback — paced, converts YUV→RGB, pushes to Python. */
private fun onFrame(reader: ImageReader) {
if (!running) {
runCatching { reader.acquireLatestImage()?.close() }
return
}
val now = SystemClock.elapsedRealtimeNanos()
if (now < nextFrameNanos) {
runCatching { reader.acquireLatestImage()?.close() }
return
}
val image = runCatching { reader.acquireLatestImage() }.getOrNull() ?: return
try {
val w = image.width
val h = image.height
val out = ensureRgbBuffer(w * h * BYTES_PER_RGB)
yuv420ToRgb(image, out, w, h)
pushFrame(out, w, h)
nextFrameNanos += frameIntervalNanos
if (now - nextFrameNanos > frameIntervalNanos * 4) {
nextFrameNanos = now + frameIntervalNanos
}
} catch (e: Exception) {
Log.w(TAG, "frame processing error: ${e.message}")
} finally {
runCatching { image.close() }
}
}
private fun ensureRgbBuffer(size: Int): ByteArray {
val buf = rgbBuffer
if (buf != null && buf.size == size) return buf
return ByteArray(size).also { rgbBuffer = it }
}
/**
* Stride-aware YUV_420_888 → packed RGB (3 bytes/px) using BT.601
* fixed-point coefficients. Handles both planar and semi-planar
* (NV21-like, pixelStride 2) chroma layouts via the plane strides.
*/
private fun yuv420ToRgb(image: Image, out: ByteArray, width: Int, height: Int) {
val planes = image.planes
val yPlane = planes[0]
val uPlane = planes[1]
val vPlane = planes[2]
val yRowStride = yPlane.rowStride
val yPixStride = yPlane.pixelStride
val uRowStride = uPlane.rowStride
val uPixStride = uPlane.pixelStride
val vRowStride = vPlane.rowStride
val vPixStride = vPlane.pixelStride
// Copy each plane to a reusable array for fast indexed access
// (ByteBuffer absolute-get per pixel is far slower).
val yByteBuf = yPlane.buffer
val uByteBuf = uPlane.buffer
val vByteBuf = vPlane.buffer
val yArr = ensurePlane(yBuf, yByteBuf.remaining()).also { yBuf = it }
val uArr = ensurePlane(uBuf, uByteBuf.remaining()).also { uBuf = it }
val vArr = ensurePlane(vBuf, vByteBuf.remaining()).also { vBuf = it }
yByteBuf.get(yArr, 0, yArr.size)
uByteBuf.get(uArr, 0, uArr.size)
vByteBuf.get(vArr, 0, vArr.size)
var o = 0
for (row in 0 until height) {
val yRowBase = row * yRowStride
val uvRow = row shr 1
val uRowBase = uvRow * uRowStride
val vRowBase = uvRow * vRowStride
for (col in 0 until width) {
val y = (yArr[yRowBase + col * yPixStride].toInt() and 0xFF)
val uvCol = col shr 1
val u = (uArr[uRowBase + uvCol * uPixStride].toInt() and 0xFF) - 128
val v = (vArr[vRowBase + uvCol * vPixStride].toInt() and 0xFF) - 128
// BT.601 full-range, fixed-point (<<16).
var r = y + ((91881 * v) shr 16)
var g = y - ((22554 * u + 46802 * v) shr 16)
var b = y + ((116130 * u) shr 16)
if (r < 0) r = 0 else if (r > 255) r = 255
if (g < 0) g = 0 else if (g > 255) g = 255
if (b < 0) b = 0 else if (b > 255) b = 255
out[o++] = r.toByte()
out[o++] = g.toByte()
out[o++] = b.toByte()
}
}
}
/** Return [cached] if it already fits [n] bytes, else a fresh array. */
private fun ensurePlane(cached: ByteArray?, n: Int): ByteArray =
if (cached != null && cached.size == n) cached else ByteArray(n)
private fun pushFrame(rgb: ByteArray, width: Int, height: Int) {
val module = engineModule ?: runCatching {
Python.getInstance().getModule(ENGINE_MODULE)
}.getOrNull()?.also { engineModule = it } ?: return
try {
module.callAttr("push_frame", rgb, width, height)
} catch (e: Exception) {
Log.w(TAG, "push_frame failed: ${e.message}")
}
}
/** Tear down the active session. Caller holds [lock]. */
private fun closeLocked() {
running = false
activeIndex = -1
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
runCatching { captureSession?.stopRepeating() }
runCatching { captureSession?.close() }
captureSession = null
runCatching { cameraDevice?.close() }
cameraDevice = null
runCatching { imageReader?.close() }
imageReader = null
}
}
@@ -4,9 +4,12 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.Manifest
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
@@ -15,6 +18,7 @@ import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -26,7 +30,13 @@ import kotlinx.coroutines.launch
/**
* 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() {
@@ -77,6 +87,7 @@ class CaptureService : Service() {
private var bridge: PythonBridge? = null
private var screenCapture: ScreenCapture? = null
private var rootCapture: RootScreenrecord? = null
private var audioCapture: AudioCapture? = null
private var mediaProjection: MediaProjection? = null
// Service-scoped coroutine scope for the root-capture watchdog.
@@ -92,15 +103,83 @@ class CaptureService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// CRITICAL: startForeground must be called IMMEDIATELY —
// before any other work, especially before getMediaProjection().
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
// CRITICAL (Android 14+): for the MediaProjection path, validate the
// projection token BEFORE promoting to a foreground service with the
// mediaProjection FGS type. On service recreation (system redelivery
// or a stale relaunch) the consent token is gone — promoting first and
// then discovering the dead token causes a spurious foreground-service
// start + immediate stop, which on strict OEMs flickers the
// notification or trips a stopSelf loop. Bail out cleanly here, before
// startForeground, when the MediaProjection consent data is missing.
val localIp = NetworkUtils.getLocalIpAddress(this) ?: ""
val url = "http://$localIp:$SERVER_PORT"
val mediaProjectionResultData: Intent? =
if (!useRoot) extractProjectionResultData(intent) else null
if (!useRoot && (intent == null || mediaProjectionResultData == null)) {
// MediaProjection mode can't recover from a redelivery —
// the consent token in the original intent is single-use.
//
// We were launched via startForegroundService(), so the OS REQUIRES
// a startForeground() within ~5s even on this immediate-stop path,
// or it raises the fatal ForegroundServiceDidNotStartInTimeException.
// Promote with a benign SPECIAL_USE type (NOT mediaProjection — we
// have no valid consent token, and requesting that type without an
// active projection is exactly what we're avoiding) just long enough
// to satisfy the contract, then stop.
Log.w(TAG, "MediaProjection start without a valid consent token — stopping")
runCatching {
val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
0
}
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType)
}.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") }
stopSelf()
return START_NOT_STICKY
}
// startForeground must be called IMMEDIATELY after the token check —
// before any heavier work like getMediaProjection(). The service type
// must match the work; pass it explicitly via ServiceCompat so we stay
// compatible back to API 24. The MEDIA_PROJECTION type is only used
// here once resultData is confirmed non-null (checked above).
try {
startForeground(NOTIFICATION_ID, buildNotification(url))
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
var t = if (useRoot) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
// On-demand webcam capture opens the camera from this service.
// To retain camera access once the app is backgrounded (the
// always-on ambient-lighting case), API 34+ requires the camera
// FGS type. Add it ONLY when CAMERA is already granted — promoting
// with the camera type without the runtime permission throws and
// would kill the whole service on the (common) camera-less or
// not-yet-granted box. If CAMERA is granted later, it takes effect
// on the next Start (matches the audio/permission UX).
if (checkSelfPermission(Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
) {
t = t or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
}
t
} else {
0
}
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
buildNotification(url),
type,
)
} catch (e: Exception) {
// 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)
stopSelf()
return START_NOT_STICKY
@@ -109,22 +188,13 @@ class CaptureService : Service() {
// otherwise `isRunning=true` sticks forever when startForeground throws.
isRunning = true
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
if (intent == null && !useRoot) {
// MediaProjection mode can't recover from a redelivery —
// the consent token in the original intent is single-use.
Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
isRunning = false
stopSelf()
return START_NOT_STICKY
}
try {
if (useRoot) {
startRootCapture(url)
} else {
startMediaProjectionCapture(intent!!, url)
// mediaProjectionResultData is guaranteed non-null here — the
// token was validated before startForeground above.
startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start capture", e)
@@ -140,10 +210,13 @@ class CaptureService : Service() {
return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
}
private fun apiKey(): String? =
(application as? LedGrabApp)?.apiKeyManager?.apiKey
private fun startRootCapture(url: String) {
val newBridge = PythonBridge(this).also { b ->
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
b.startServer(SERVER_PORT, apiKey())
}
bridge = newBridge
@@ -167,12 +240,21 @@ class CaptureService : Service() {
* Replace the active root pipeline with a fresh instance, reusing
* the existing Python bridge (no server restart). Returns true if
* 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 {
val currentBridge = bridge ?: return false
val old = rootCapture
rootCapture = null
runCatching { old?.stop() }
// Tear down the old instance first so we don't run two
// screenrecord processes simultaneously fighting for the GPU.
rootCapture?.let { old ->
rootCapture = null
runCatching { old.stop() }
}
val next = RootScreenrecord(
bridge = currentBridge,
@@ -180,11 +262,21 @@ class CaptureService : Service() {
height = CAPTURE_HEIGHT,
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()) {
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
}
rootCapture = next
return true
}
@@ -212,7 +304,7 @@ class CaptureService : Service() {
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
"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")
stopSelf()
return@launch
@@ -231,20 +323,24 @@ class CaptureService : Service() {
}
}
private fun startMediaProjectionCapture(intent: Intent, url: String) {
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
/**
* Extract the single-use MediaProjection consent token from the start
* intent, or null if the intent is missing/redelivered without it.
* Called BEFORE startForeground so the mediaProjection FGS type is only
* ever requested when a valid token is present (see onStartCommand).
*/
private fun extractProjectionResultData(intent: Intent?): Intent? {
if (intent == null) return null
@Suppress("DEPRECATION")
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else {
intent.getParcelableExtra(EXTRA_RESULT_DATA)
}
}
if (resultData == null) {
Log.e(TAG, "No MediaProjection result data")
stopSelf()
return
}
private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) {
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
val projectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
@@ -263,7 +359,6 @@ class CaptureService : Service() {
val bounds = windowMetrics.bounds
widthPixels = bounds.width()
heightPixels = bounds.height()
// densityDpi is still needed for VirtualDisplay; read from resources.
densityDpi = resources.displayMetrics.densityDpi
}
} else {
@@ -276,7 +371,7 @@ class CaptureService : Service() {
val newBridge = PythonBridge(this).also { b ->
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
b.startServer(SERVER_PORT, apiKey())
}
bridge = newBridge
@@ -293,6 +388,25 @@ class CaptureService : Service() {
onProjectionStopped = { stopSelf() },
).also { it.start() }
// Reuse the same projection to capture system playback audio so
// audio-reactive lighting works on-device (API 29+, RECORD_AUDIO
// granted). Best-effort: screen capture and the server keep running
// if audio is unavailable. Started AFTER ScreenCapture so the
// projection's callback is already registered.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
) {
audioCapture = AudioCapture(projection, newBridge).also { ac ->
if (!ac.start()) {
Log.i(TAG, "Playback audio capture unavailable — continuing without audio")
audioCapture = null
}
}
} else {
Log.i(TAG, "RECORD_AUDIO not granted or API < 29 — audio-reactive capture disabled")
}
Log.i(TAG, "LedGrab service started (MediaProjection) — web UI at $url")
}
@@ -306,6 +420,10 @@ class CaptureService : Service() {
screenCapture?.stop()
screenCapture = null
// Stop audio before the server: stop() calls bridge.shutdownAudio().
audioCapture?.stop()
audioCapture = null
rootCapture?.stop()
rootCapture = null
@@ -323,10 +441,10 @@ class CaptureService : Service() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"LedGrab Screen Capture",
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Shows while LedGrab is capturing the screen"
description = getString(R.string.notification_channel_description)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
@@ -343,9 +461,14 @@ class CaptureService : Service() {
PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("LedGrab Running")
.setContentText("Web UI: $url")
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text, url))
// 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)
.setOngoing(true)
.build()
@@ -0,0 +1,154 @@
package com.ledgrab.android
import android.app.AppOpsManager
import android.app.usage.UsageEvents
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.pm.LauncherApps
import android.os.Build
import android.os.Process
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
/**
* Foreground-app + installed-app bridge exposed to the Python server via Chaquopy.
*
* Backs the Android implementation of the "Application" automation rule
* (foreground app -> activate scene). Desktop detects the foreground process via
* Win32 ctypes in ``platform_detector.py``; Android has no such API, so this
* bridge wraps two in-platform services into synchronous calls a Python thread
* can invoke (Chaquopy proxy threads are real OS threads):
*
* - [getForegroundPackage] via [UsageStatsManager] (needs PACKAGE_USAGE_STATS,
* a special-access permission granted from Settings — see MainActivity).
* - [listLaunchableApps] via [LauncherApps] for the automation editor's app
* picker (no QUERY_ALL_PACKAGES needed — getActivityList is the sanctioned
* launchable-app enumeration API).
* - [hasUsageAccess] so the server / UI can detect the missing grant.
*
* Detection only ever string-compares the foreground *package name*, so no label
* resolution / package visibility is required at match time.
*
* Python callers access the singleton via
* `jclass("com.ledgrab.android.ForegroundAppBridge").INSTANCE` — see
* `server/src/ledgrab/core/automations/platform_detector.py`.
*/
object ForegroundAppBridge {
private const val TAG = "ForegroundAppBridge"
// Trailing window for queryEvents. queryEvents reports discrete foreground
// transitions (not "current app"), and events can lag a few seconds, so we
// look back far enough to reliably catch the latest MOVE_TO_FOREGROUND while
// staying recent enough not to report a stale app on the ~1s automation tick.
private const val WINDOW_MS = 10_000L
@Volatile private var appContext: Context? = null
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
@JvmStatic
fun init(context: Context) {
appContext = context.applicationContext
}
/**
* Package name of the most recently foregrounded app, or null when none is
* found in the trailing window, Usage Access is not granted, or on any error.
* Never throws across the JNI boundary.
*/
@JvmStatic
fun getForegroundPackage(): String? {
val ctx = appContext ?: run {
Log.w(TAG, "getForegroundPackage: context not bound (init not called)")
return null
}
return try {
val usm = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
?: return null
val end = System.currentTimeMillis()
val events = usm.queryEvents(end - WINDOW_MS, end)
val event = UsageEvents.Event()
var latestPkg: String? = null
var latestTs = Long.MIN_VALUE
while (events.hasNextEvent()) {
events.getNextEvent(event)
// ACTIVITY_RESUMED (API 29+) shares the value of the legacy
// MOVE_TO_FOREGROUND constant, so the single check covers both.
// >= (not >) so that on an exact-timestamp tie the later-iterated
// event wins — events arrive chronologically, so that is the most
// recent foreground transition.
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND &&
event.timeStamp >= latestTs
) {
latestTs = event.timeStamp
latestPkg = event.packageName
}
}
latestPkg
} catch (e: Exception) {
// SecurityException when access is missing, plus any service error.
Log.w(TAG, "getForegroundPackage failed: ${e.message}")
null
}
}
/** Whether the user has granted Usage Access (PACKAGE_USAGE_STATS) to this app. */
@JvmStatic
fun hasUsageAccess(): Boolean {
val ctx = appContext ?: return false
return try {
val appOps = ctx.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager
?: return false
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), ctx.packageName,
)
} else {
@Suppress("DEPRECATION")
appOps.checkOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), ctx.packageName,
)
}
mode == AppOpsManager.MODE_ALLOWED
} catch (e: Exception) {
Log.w(TAG, "hasUsageAccess failed: ${e.message}")
false
}
}
/**
* Launchable apps as a JSON array string the Python server parses:
* `[{"package":"com.netflix.mediaclient","label":"Netflix"}, ...]`
*
* Uses [LauncherApps.getActivityList] (launcher + leanback launchables) —
* no QUERY_ALL_PACKAGES. De-duplicated by package, sorted by label.
* Returns `[]` on any error.
*/
@JvmStatic
fun listLaunchableApps(): String {
val arr = JSONArray()
val ctx = appContext ?: run {
Log.w(TAG, "listLaunchableApps: context not bound (init not called)")
return arr.toString()
}
try {
val launcher = ctx.getSystemService(Context.LAUNCHER_APPS_SERVICE) as? LauncherApps
?: return arr.toString()
val seen = HashSet<String>()
val items = ArrayList<Pair<String, String>>()
for (info in launcher.getActivityList(null, Process.myUserHandle())) {
val pkg = info.applicationInfo?.packageName ?: continue
if (!seen.add(pkg)) continue
val label = info.label?.toString().takeUnless { it.isNullOrBlank() } ?: pkg
items.add(pkg to label)
}
items.sortBy { it.second.lowercase() }
for ((pkg, label) in items) {
arr.put(JSONObject().put("package", pkg).put("label", label))
}
} catch (e: Exception) {
Log.w(TAG, "listLaunchableApps failed: ${e.message}")
}
return arr.toString()
}
}
@@ -26,9 +26,13 @@ class LedGrabApp : Application() {
var initError: Throwable? = null
private set
/** Lazily-initialized API-key manager (see [ApiKeyManager]). */
val apiKeyManager: ApiKeyManager by lazy { ApiKeyManager(this) }
override fun onCreate() {
super.onCreate()
installCrashLogger()
pruneOldCrashLogs()
try {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
@@ -47,6 +51,22 @@ class LedGrabApp : Application() {
// Bind application context for the BLE bridge so Python can
// scan and connect to BLE LED controllers.
BleBridge.init(this)
// Bind application context for the camera bridge so Python can
// enumerate cameras and open them on demand (webcam capture).
CameraBridge.init(this)
// Bind application context for the foreground-app bridge so Python can
// detect the foreground app (Application automation rule) and list
// launchable apps for the editor's picker.
ForegroundAppBridge.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 +97,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 {
private const val TAG = "LedGrabApp"
private const val MAX_CRASH_LOGS = 10
}
}
@@ -0,0 +1,178 @@
package com.ledgrab.android
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import com.chaquo.python.Python
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
/**
* Captures posted OS notifications and forwards the posting app's display
* label to the Python notification pipeline, where the existing
* `NotificationColorStripSource` fires its one-shot LED effect.
*
* Direction is Kotlin -> Python via the process-global Chaquopy instance
* (NOT a per-[CaptureService] [PythonBridge]): `system_server` binds this
* service independently of [CaptureService], so it resolves Python itself.
* The Python receiver (`os_notification_listener.push_notification`) is a
* no-op whenever the server/listener isn't running, so a notification
* arriving before — or after — a capture session is safely ignored.
*/
class LedGrabNotificationListener : NotificationListenerService() {
// Serial executor: the Python receiver does a (non-concurrency-safe) history
// disk write and may play a sound, so pushes must not overlap. Off the main
// looper to keep the system service responsive.
//
// Tied to the listener-connection lifecycle (onListenerConnected /
// onListenerDisconnected), NOT onDestroy: this is a system-rebindable
// service, so it can be connected/disconnected multiple times across a
// single onCreate..onDestroy span. Managing the executor here — combined
// with the runCatching guard at the submit site — keeps a notification
// that races teardown from triggering RejectedExecutionException on a
// shut-down executor. @Volatile so the connect/disconnect callbacks (which
// may run on a different thread than onNotificationPosted) publish safely.
@Volatile private var pushExecutor: ExecutorService? = null
// Guards executor creation so the lazy submit-site fallback and
// onListenerConnected can't race two executors into existence.
private val executorLock = Any()
// Tracks whether the listener is currently connected. ensureExecutor() only
// CREATES a new executor while connected — otherwise a notification racing
// onListenerDisconnected (which nulls pushExecutor) would spin up a fresh
// executor that nothing reaps until the next disconnect cycle (a thread leak).
@Volatile private var connected: Boolean = false
// packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working.
// Naturally bounded by the number of notification-posting apps (tens) and
// cleared with the process — no eviction needed.
private val labelCache = ConcurrentHashMap<String, String>()
override fun onNotificationPosted(sbn: StatusBarNotification?) {
val notification = sbn ?: return
// The Python server (and thus the listener) only exists during a capture
// session. isRunning is a coarse early-out — the authoritative gate is the
// Python receiver's None-check — but it avoids needless JNI churn here.
if (!CaptureService.isRunning) return
// Filter notifications that should never drive an effect:
// - ongoing (media transport, downloads): not user-facing "alerts"
// - group summaries: duplicate their child notifications
// - our own foreground-service notification: would self-trigger
if (notification.isOngoing) return
if ((notification.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return
if (notification.packageName == packageName) return
val label = resolveAppLabel(notification.packageName)
// Obtain (creating if needed) the executor. onListenerConnected normally
// creates it, but that callback is not reliably invoked on every
// OEM/version (re)bind, and a notification can arrive before it fires —
// lazily creating here keeps a missing/late onListenerConnected from
// permanently disabling notification forwarding. A late submit onto an
// executor that onListenerDisconnected is shutting down throws
// RejectedExecutionException — guard with runCatching so a notification
// racing teardown can never crash this system-bound service.
val executor = ensureExecutor() ?: run {
Log.d(TAG, "no executor (listener disconnected) — skipping push")
return
}
runCatching {
executor.execute {
try {
Python.getInstance()
.getModule(PY_MODULE)
.callAttr("push_notification", label)
} catch (t: Throwable) {
// Never crash a system-bound service. Python.getInstance() throws
// IllegalStateException if Python.start() hasn't run (e.g. the
// service was bound at boot before the app process initialized).
// Log at debug — the label is potentially sensitive on a shared TV.
Log.d(TAG, "push_notification failed: ${t.message}")
}
}
}.onFailure { e ->
if (e is RejectedExecutionException) {
Log.d(TAG, "push rejected — listener disconnecting")
} else {
throw e
}
}
}
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
private fun resolveAppLabel(pkg: String): String {
labelCache[pkg]?.let { return it }
// Only cache SUCCESSFUL resolutions. Caching the package-name fallback
// would permanently pin a wrong label if the PackageManager lookup
// failed transiently (e.g. the app was mid-install / still updating).
val resolved = runCatching {
val info = packageManager.getApplicationInfo(pkg, 0)
packageManager.getApplicationLabel(info).toString()
}.getOrNull()
if (resolved != null) {
labelCache[pkg] = resolved
return resolved
}
return pkg
}
/**
* Return the push executor, creating it under [executorLock] if absent AND
* the listener is connected. Returns null when disconnected so a notification
* racing teardown neither submits onto a shutting-down executor nor spins up
* a stray one. Safe against a concurrent onListenerConnected/onNotificationPosted
* race (single executor) and against a missing onListenerConnected callback.
*/
private fun ensureExecutor(): ExecutorService? {
pushExecutor?.let { return it }
synchronized(executorLock) {
if (!connected) return null
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
}
}
override fun onListenerConnected() {
Log.i(TAG, "Notification listener connected")
// Spin up the push executor on connect. The system can disconnect and
// later reconnect this service without destroying it, so own the
// executor here rather than in onCreate/onDestroy. onNotificationPosted
// also lazily creates it (via ensureExecutor) in case this callback is
// late or skipped on some ROMs.
connected = true
ensureExecutor()
}
override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected")
// Mark disconnected BEFORE nulling the executor so a racing ensureExecutor
// sees !connected and skips creating a replacement. Tear the executor
// down; a fresh one is created on the next onListenerConnected.
connected = false
pushExecutor?.let { exec ->
pushExecutor = null
exec.shutdown()
}
}
override fun onDestroy() {
// Defensive: onListenerDisconnected normally clears this first, but
// shut down here too in case onDestroy fires without a prior disconnect.
connected = false
pushExecutor?.shutdown()
pushExecutor = null
super.onDestroy()
}
companion object {
private const val TAG = "LedGrabNotifListener"
private const val PY_MODULE = "ledgrab.core.processing.os_notification_listener"
}
}
@@ -1,7 +1,10 @@
package com.ledgrab.android
import android.Manifest
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
@@ -13,12 +16,17 @@ import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.view.View
import android.view.ViewStub
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Button
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.app.Activity
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.CoroutineScope
@@ -26,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
/**
@@ -46,25 +55,54 @@ class MainActivity : Activity() {
private const val SERVER_PORT = 8080
private const val REQUEST_MEDIA_PROJECTION = 1001
private const val REQUEST_POST_NOTIFICATIONS = 1002
private const val REQUEST_RECORD_AUDIO = 1003
private const val REQUEST_CAMERA = 1004
private const val REQUEST_BLUETOOTH = 1005
private const val QR_SIZE_PX = 560
private const val NOTIF_PREFS = "ledgrab_notif"
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
}
// Stopped-state views (always inflated).
private lateinit var stoppedPanel: View
private lateinit var runningPanel: View
private lateinit var statusText: TextView
private lateinit var urlText: TextView
private lateinit var qrImage: ImageView
private lateinit var toggleButton: Button
private lateinit var stopButtonRunning: Button
private lateinit var versionText: TextView
private lateinit var autostartCheck: CheckBox
private lateinit var autostartPrefs: AutostartPrefs
private lateinit var grantNotificationButton: Button
private lateinit var grantUsageAccessButton: Button
// 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?) {
// 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)
// Surface fatal Python init errors instead of crashing.
val initError = (application as? LedGrabApp)?.initError
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)
return
}
@@ -72,39 +110,71 @@ class MainActivity : Activity() {
setContentView(R.layout.activity_main)
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)
urlText = findViewById(R.id.url_text)
qrImage = findViewById(R.id.qr_image)
toggleButton = findViewById(R.id.toggle_button)
stopButtonRunning = findViewById(R.id.stop_button_running)
versionText = findViewById(R.id.version_text)
autostartCheck = findViewById(R.id.autostart_check)
grantNotificationButton = findViewById(R.id.grant_notification_button)
grantUsageAccessButton = findViewById(R.id.grant_usage_access_button)
val versionName = packageManager
.getPackageInfo(packageName, 0).versionName
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
autostartPrefs = AutostartPrefs(this)
autostartCheck.isChecked = autostartPrefs.isEnabled
// Autostart only takes effect on rooted devices — grey it out
// on unrooted hardware so users don't expect magic. Cheap probe
// (file-existence only, no process spawn).
if (!Root.looksRooted()) {
autostartCheck.isEnabled = false
autostartCheck.text = getString(R.string.autostart_unavailable)
}
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
autostartPrefs.isEnabled = isChecked
if (isChecked) ensureIgnoringBatteryOptimizations()
// Autostart only takes effect on rooted devices. Hide the
// checkbox entirely on unrooted hardware instead of showing a
// disabled-but-visible control, which reads as broken UI from
// across the room.
if (Root.looksRooted()) {
autostartCheck.visibility = View.VISIBLE
autostartCheck.isChecked = autostartPrefs.isEnabled
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
autostartPrefs.isEnabled = isChecked
if (isChecked) ensureIgnoringBatteryOptimizations()
}
} else {
autostartCheck.visibility = View.GONE
}
grantNotificationButton.setOnClickListener { openNotificationListenerSettings() }
grantUsageAccessButton.setOnClickListener { openUsageAccessSettings() }
toggleButton.setOnClickListener { startCapture() }
stopButtonRunning.setOnClickListener { stopCaptureService() }
updateStoppedPermissionButtons()
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()
if (!::stoppedPanel.isInitialized) return
// 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. When stopped, refresh the notification-access button —
// the user may have just granted/revoked access in Settings.
if (CaptureService.isRunning) {
updateUI()
} else {
updateStoppedPermissionButtons()
}
}
/**
* Decide whether to go through the MediaProjection consent flow or
* jump straight into root capture. Root check is fast but may block
@@ -112,20 +182,25 @@ class MainActivity : Activity() {
* on the UI thread is acceptable because we're responding to a
* button press and we want to block until the user answers.
*/
override fun onDestroy() {
uiScope.cancel()
super.onDestroy()
}
private fun startCapture() {
// `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
statusText.text = "Checking root access…"
toggleButton.text = getString(R.string.btn_starting)
statusText.text = getString(R.string.status_checking_root)
uiScope.launch(Dispatchers.IO) {
val rooted = Root.requestGrant()
// runInterruptible so a config change (rotation) during the
// up-to-10s `su` probe cancels the coroutine AND interrupts the
// blocking probe thread — Root.requestGrant honours the interrupt,
// destroys the su child, and rethrows, so we don't leak the
// process + drain thread. Without this, IO-dispatcher cancellation
// would not interrupt the blocking waitFor().
val rooted = runInterruptible { Root.requestGrant() }
withContext(Dispatchers.Main) {
toggleButton.isEnabled = true
toggleButton.text = originalText
statusText.text = ""
if (rooted) {
Log.i(TAG, "Root available — skipping MediaProjection consent")
@@ -145,6 +220,9 @@ class MainActivity : Activity() {
private fun startRootCaptureService() {
ensureNotificationPermission()
ensureNotificationListenerAccess()
ensureCameraPermission()
ensureBluetoothPermissions()
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
updateUI()
}
@@ -156,7 +234,7 @@ class MainActivity : Activity() {
if (resultCode == RESULT_OK && data != null) {
startCaptureService(resultCode, data)
} else {
statusText.text = "Permission denied — screen capture requires authorization"
statusText.text = getString(R.string.status_permission_denied)
Log.w(TAG, "MediaProjection permission denied")
}
}
@@ -164,6 +242,10 @@ class MainActivity : Activity() {
private fun startCaptureService(resultCode: Int, resultData: Intent) {
ensureNotificationPermission()
ensureNotificationListenerAccess()
ensureAudioPermission()
ensureCameraPermission()
ensureBluetoothPermissions()
val intent = CaptureService.createIntent(this, resultCode, resultData)
ContextCompat.startForegroundService(this, intent)
updateUI()
@@ -174,42 +256,130 @@ class MainActivity : Activity() {
updateUI()
}
private fun updateUI() {
if (CaptureService.isRunning) {
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val url = "http://$localIp:$SERVER_PORT"
private fun ensureRunningPanelInflated(): View {
runningPanel?.let { return it }
val view = runningPanelStub.inflate()
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
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(url)
withContext(Dispatchers.Main) {
if (CaptureService.isRunning && urlText.text == url) {
qrImage.setImageBitmap(bitmap)
private fun updateUI() {
// Fatal-init-error path took over setContentView and the
// lateinit view fields are unassigned. Guard so any future
// caller (Resume, broadcast receiver, etc.) doesn't NPE.
if (!::stoppedPanel.isInitialized) return
if (CaptureService.isRunning) {
val running = ensureRunningPanelInflated()
val localIp = NetworkUtils.getLocalIpAddress(this)
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
versionText.visibility = View.GONE
runningPanel.visibility = View.VISIBLE
stopButtonRunning.requestFocus()
running.visibility = View.VISIBLE
stopButtonRunning?.requestFocus()
startStatusDotPulse()
} else {
urlText.text = ""
qrImage.setImageBitmap(null)
stopStatusDotPulse()
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
versionText.visibility = View.VISIBLE
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 {
val size = 560
val size = QR_SIZE_PX
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)
for (y in 0 until size) {
val rowOffset = y * size
@@ -218,34 +388,54 @@ class MainActivity : Activity() {
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)
return bitmap
}
/**
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
* Rendered programmatically so we don't depend on the regular layout
* (which itself may reference resources affected by the failure).
* Stack trace is hidden behind a "Show details" toggle so we don't
* print user-path data on shared TV screens by default.
*/
private fun showFatalErrorScreen(error: Throwable) {
Log.e(TAG, "Fatal init error — showing error screen", error)
val stackText = android.util.Log.getStackTraceString(error)
val container = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
val stackText = Log.getStackTraceString(error)
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(48, 48, 48, 48)
}
val title = TextView(this).apply {
text = "LedGrab failed to start"
text = getString(R.string.fatal_title)
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 {
text = "Python runtime initialization failed:\n\n$stackText"
text = stackText
textSize = 12f
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 {
text = "Copy log"
text = getString(R.string.fatal_copy_log)
setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE)
as android.content.ClipboardManager
@@ -254,19 +444,20 @@ class MainActivity : Activity() {
)
}
}
val scroll = android.widget.ScrollView(this).apply { addView(body) }
container.addView(title)
container.addView(description)
container.addView(toggleBtn)
container.addView(copyBtn)
container.addView(scroll)
setContentView(container)
}
/**
* Prompt the user to exempt LedGrab from battery optimization. On
* TV boxes this is usually a no-op, but on phones Doze/App Standby
* will kill the foreground service after a few hours of sleep. We
* only ask when autostart is turned on. No-op on pre-M or when
* already exempt.
* Prompt the user to exempt LedGrab from battery optimization.
* Strictly a phone-side concern (Doze/App Standby kill the FG
* service after hours of sleep); essentially a no-op on TV boxes.
* Only asked when autostart is turned on, which is itself only
* available on rooted devices.
*
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
* — LedGrab's ambient-capture use case falls under the documented
@@ -311,4 +502,152 @@ class MainActivity : Activity() {
}
}
}
/**
* Request RECORD_AUDIO (API 29+) so the capture service can capture
* system playback audio for audio-reactive lighting. Fire-and-forget,
* like [ensureNotificationPermission]: capture still works without it
* (just no audio), so we don't block on the result. If first granted
* here, audio becomes available on the next Start.
*/
private fun ensureAudioPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
@Suppress("DEPRECATION")
requestPermissions(
arrayOf(Manifest.permission.RECORD_AUDIO),
REQUEST_RECORD_AUDIO,
)
}
}
/**
* Request CAMERA so the capture service can open the device camera for
* on-device webcam capture. Fire-and-forget, like [ensureAudioPermission]:
* capture still works without it (just no camera engine), so we don't block
* on the result. Gated on actual camera hardware via FEATURE_CAMERA_ANY so
* camera-less TV boxes (the common case) never see the prompt. The camera
* is opened on demand only while a camera source is active — granting this
* does not keep the camera on. If first granted here, the camera engine
* becomes available on the next Start.
*/
private fun ensureCameraPermission() {
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) return
if (checkSelfPermission(Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED
) {
@Suppress("DEPRECATION")
requestPermissions(
arrayOf(Manifest.permission.CAMERA),
REQUEST_CAMERA,
)
}
}
/**
* Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded
* server can discover and drive BLE LED controllers (SP110E / Triones /
* Zengge). On API < 31 these are install-time legacy permissions
* (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and
* need no runtime grant — so this is a no-op there. Fire-and-forget,
* like [ensureAudioPermission]: screen capture works without BLE, and
* BleBridge degrades gracefully (empty scan / failed connect) when the
* grant is denied, so we don't block on the result. If first granted
* here, BLE devices become reachable on the next scan/connect.
*/
private fun ensureBluetoothPermissions() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
val needed = listOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
).filter {
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
}
if (needed.isEmpty()) return
@Suppress("DEPRECATION")
requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH)
}
/** Whether the user has granted notification-listener access to this app. */
private fun isNotificationAccessGranted(): Boolean =
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
/** Open the system Notification-access screen (manual affordance / re-grant). */
private fun openNotificationListenerSettings() {
runCatching {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
}.onFailure { Log.w(TAG, "Notification-access settings unavailable: ${it.message}") }
}
/**
* Whether Usage Access (PACKAGE_USAGE_STATS) is granted — needed by the
* foreground-app automation rule. Delegates to the bridge's AppOps check.
*/
private fun isUsageAccessGranted(): Boolean = ForegroundAppBridge.hasUsageAccess()
/**
* Open the system Usage-Access screen so the user can grant LedGrab access
* for the foreground-app automation rule. Falls back to the generic Settings
* screen on TV-box OEM builds that strip the dedicated intent.
*/
private fun openUsageAccessSettings() {
runCatching {
startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
}.onFailure {
Log.w(TAG, "Usage-access settings unavailable: ${it.message}")
runCatching { startActivity(Intent(Settings.ACTION_SETTINGS)) }
}
}
/**
* Prompt-once-then-remember: the first time capture starts without
* notification-listener access, open the settings screen so the user can
* grant it — then never nag again (the manual "Grant notification access"
* button stays available). Fire-and-forget like [ensureNotificationPermission].
*/
private fun ensureNotificationListenerAccess() {
if (isNotificationAccessGranted()) return
val prefs = getSharedPreferences(NOTIF_PREFS, MODE_PRIVATE)
if (prefs.getBoolean(KEY_NOTIF_ACCESS_PROMPTED, false)) return
prefs.edit().putBoolean(KEY_NOTIF_ACCESS_PROMPTED, true).apply()
openNotificationListenerSettings()
}
/**
* Show each "Grant <permission> access" button only while that access is
* missing, then re-wire the D-pad focus chain. Called on create and on resume
* (access can change in Settings while we're backgrounded). The usage-access
* button is a passive affordance (no auto-prompt at capture start) — the
* primary guidance is the web-UI banner when an Android app rule needs it.
*/
private fun updateStoppedPermissionButtons() {
if (!::grantNotificationButton.isInitialized) return
grantNotificationButton.visibility =
if (isNotificationAccessGranted()) View.GONE else View.VISIBLE
grantUsageAccessButton.visibility =
if (isUsageAccessGranted()) View.GONE else View.VISIBLE
wireStoppedFocusChain()
}
/**
* Link the visible stopped-panel controls into a single up/down D-pad chain.
* The optional controls (the grant-access buttons and the root-only autostart
* checkbox) may be GONE, so the chain is computed from whatever is visible —
* a static nextFocus pointing at a GONE view would strand the focus on a TV
* remote.
*/
private fun wireStoppedFocusChain() {
val chain = listOfNotNull(
toggleButton,
grantNotificationButton.takeIf { it.visibility == View.VISIBLE },
grantUsageAccessButton.takeIf { it.visibility == View.VISIBLE },
autostartCheck.takeIf { it.visibility == View.VISIBLE },
)
chain.forEachIndexed { i, view ->
view.nextFocusUpId = (chain.getOrNull(i - 1) ?: view).id
view.nextFocusDownId = (chain.getOrNull(i + 1) ?: view).id
}
}
}
@@ -3,6 +3,8 @@ package com.ledgrab.android
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import java.net.Inet4Address
/**
@@ -11,18 +13,58 @@ import java.net.Inet4Address
object NetworkUtils {
/**
* Return the device's local IPv4 address on the active network,
* or `null` if unavailable.
* Return the device's local IPv4 address, preferring (in order):
* - 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? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return null
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
// TODO(AP-mode): On TV boxes acting as a Wi-Fi tether/hotspot,
// 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
.asSequence()
.map { it.address }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
.firstOrNull { !it.isLoopbackAddress && !it.isLinkLocalAddress }
?.hostAddress
}
}
@@ -9,7 +9,8 @@ import com.chaquo.python.Python
* Bridge between Kotlin and the LedGrab Python server.
*
* 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) {
@@ -27,6 +28,7 @@ class PythonBridge(private val context: Context) {
// single-writer/single-reader pattern we have here.
@Volatile private var mediaProjectionEngine: PyObject? = null
@Volatile private var rootEngine: PyObject? = null
@Volatile private var androidAudioEngine: PyObject? = null
/**
* Configure the MediaProjection engine with screen dimensions.
@@ -52,12 +54,60 @@ class PythonBridge(private val context: Context) {
Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}")
}
/**
* Configure the Android playback-capture audio engine with the format
* actually negotiated by [AudioCapture]'s `AudioRecord`. Must be called
* before [pushAudio]. Caches the module handle for the per-block fast
* path (same pattern as [configureCapture]).
*/
fun configureAudio(sampleRate: Int, channels: Int, chunkFrames: Int) {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.audio.android_audio_engine")
engine.callAttr("configure", sampleRate, channels, chunkFrames)
androidAudioEngine = engine
Log.i(TAG, "Android audio engine configured: sr=$sampleRate ch=$channels chunk=$chunkFrames")
}
/**
* Push one interleaved little-endian float32 PCM block to the Python
* audio engine. Called from [AudioCapture]'s capture thread. The byte
* array crosses the JNI boundary; Python copies it on receipt, so the
* caller may reuse the same buffer for the next block.
*/
fun pushAudio(pcmFloat32: ByteArray) {
if (!running) return
val engine = androidAudioEngine ?: return
try {
engine.callAttr("push_samples", pcmFloat32)
} catch (e: Exception) {
Log.w(TAG, "Failed to push audio: ${e.message}")
}
}
/**
* Deactivate the Python audio engine. Called from [AudioCapture.stop].
*/
fun shutdownAudio() {
val engine = androidAudioEngine ?: return
try {
engine.callAttr("shutdown")
} catch (e: Exception) {
Log.w(TAG, "Failed to shut down audio engine: ${e.message}")
}
androidAudioEngine = null
}
/**
* 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) {
Log.w(TAG, "Server already running")
return
@@ -71,7 +121,11 @@ class PythonBridge(private val context: Context) {
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
val py = Python.getInstance()
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) {
Log.e(TAG, "Python server error", e)
} finally {
@@ -106,7 +160,8 @@ class PythonBridge(private val context: Context) {
*
* Called from [ScreenCapture] on the capture thread. The byte array
* 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) {
if (!running) return
@@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit
object Root {
private const val TAG = "Root"
// Slice length for the cancellation-aware su probe wait loop. Short
// enough that coroutine cancellation is honoured promptly, long enough
// to avoid busy-spinning while Magisk's grant dialog is up.
private const val POLL_SLICE_MS = 100L
private val SU_PATHS = listOf(
"/system/bin/su",
"/system/xbin/su",
@@ -49,17 +54,19 @@ object Root {
return false
}
var process: Process? = null
val granted = try {
// redirectErrorStream merges stderr into stdout so a single
// drain thread is enough — avoids the classic pipe-buffer
// deadlock where waitFor() blocks because stderr filled up.
val process = ProcessBuilder("su", "-c", "id")
val proc = ProcessBuilder("su", "-c", "id")
.redirectErrorStream(true)
.start()
process = proc
val outputBuilder = StringBuilder()
val drain = Thread({
try {
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
val buf = CharArray(512)
while (true) {
val n = r.read(buf)
@@ -72,17 +79,35 @@ object Root {
}
}, "Root-su-drain").apply { isDaemon = true; start() }
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
// Cancellation-aware wait: callers run this on a coroutine
// (MainActivity wraps it in runInterruptible), so a config change
// mid-probe cancels the coroutine and interrupts this thread.
// Poll waitFor() in short slices and honour interruption so we
// don't leak the `su` child + its drain thread for up to 10s.
// The catch(InterruptedException) below destroys the process; we
// re-arm the interrupt and rethrow so coroutine cancellation
// propagates cleanly.
val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
var finished = false
while (System.nanoTime() < deadlineNanos) {
if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) {
finished = true
break
}
// Throws InterruptedException if the thread was interrupted
// by coroutine cancellation — handled below to tear down.
if (Thread.interrupted()) throw InterruptedException("su probe cancelled")
}
if (!finished) {
process.destroyForcibly()
proc.destroyForcibly()
drain.join(500)
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
false
} else {
drain.join(500)
val output = synchronized(outputBuilder) { outputBuilder.toString() }
if (process.exitValue() != 0) {
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
if (proc.exitValue() != 0) {
Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'")
false
} else {
val rooted = output.contains("uid=0")
@@ -90,8 +115,17 @@ object Root {
rooted
}
}
} catch (e: InterruptedException) {
// Coroutine cancelled mid-probe (e.g. config change). Kill the
// su child so it doesn't outlive the cancelled work, re-arm the
// interrupt flag, and rethrow so the coroutine cancels cleanly.
// Do NOT cache a result — the probe never completed.
runCatching { process?.destroyForcibly() }
Thread.currentThread().interrupt()
throw e
} catch (e: Exception) {
Log.w(TAG, "su invocation failed: ${e.message}")
runCatching { process?.destroyForcibly() }
false
}
@@ -100,14 +134,41 @@ object Root {
}
/**
* Run an `su -c <cmd>` command. 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).
* Run a command as root.
*
* 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
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 {
val process = ProcessBuilder("su", "-c", cmd)
val process = ProcessBuilder("su", "-c", shellLine)
.redirectErrorStream(true)
.start()
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
@@ -122,12 +183,34 @@ object Root {
true
}
} catch (e: Exception) {
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}")
Log.w(TAG, "runAsRoot('$shellLine') failed: ${e.message}")
cachedGranted = null
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. */
@JvmStatic
fun invalidateCache() {
@@ -38,8 +38,15 @@ class RootScreenrecord(
private const val TAG = "RootScreenrecord"
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
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
private var decoder: MediaCodec? = null
private var imageReader: ImageReader? = null
@@ -48,7 +55,22 @@ class RootScreenrecord(
private var outputThread: Thread? = null
@Volatile private var running = false
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. */
val framesDelivered: Int get() = framesDeliveredCounter.get()
@@ -67,14 +89,15 @@ class RootScreenrecord(
running = true
try {
imageReader = buildImageReader()
decoder = buildDecoder(imageReader!!)
process = spawnScreenrecord() ?: run {
val reader = buildImageReader().also { imageReader = it }
val codec = buildDecoder(reader).also { decoder = it }
val proc = spawnScreenrecord() ?: run {
stop()
return false
}
startInputPump(process!!.inputStream, decoder!!)
startOutputDrain(decoder!!)
process = proc
startInputPump(proc.inputStream, codec)
startOutputDrain(codec)
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
return true
} catch (e: Exception) {
@@ -84,11 +107,11 @@ class RootScreenrecord(
}
}
/** Stop everything and release resources. Idempotent. */
/** Stop everything and release resources. Idempotent. Single-use: do not call start() again. */
@Synchronized
fun stop() {
if (stopped) return
stopped = true
if (disposed) return
disposed = true
// Order matters: signal first so worker loops drop out, then
// stop the codec on the thread that created it (this one), then
// join workers BEFORE releasing the codec/ImageReader they may
@@ -107,7 +130,9 @@ class RootScreenrecord(
// Best-effort: kill the screenrecord child before reaping `su`,
// otherwise screenrecord can outlive su as an orphan and keep
// 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() }
decoder = null
@@ -120,8 +145,13 @@ class RootScreenrecord(
runCatching { readerThread?.join(500) }
readerThread = null
runCatching { process?.destroy() }
process = null
// Use the same lock as the respawn path so we don't destroy a
// 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})")
}
@@ -131,7 +161,7 @@ class RootScreenrecord(
readerThread = thread
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 ->
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
try {
@@ -139,19 +169,25 @@ class RootScreenrecord(
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
val bytes = if (rowStride == width * pixelStride) {
ByteArray(buffer.remaining()).also { buffer.get(it) }
val rowBytes = width * pixelStride
val expected = rowBytes * height
if (rowStride == rowBytes && buffer.remaining() >= expected) {
buffer.get(frameBuffer, 0, expected)
} else {
// Strip row padding — common when width isn't a multiple of 16.
val rowBytes = width * pixelStride
ByteArray(width * height * 4).also { out ->
for (row in 0 until height) {
buffer.position(row * rowStride)
buffer.get(out, row * rowBytes, rowBytes)
}
for (row in 0 until height) {
buffer.position(row * rowStride)
buffer.get(frameBuffer, row * rowBytes, rowBytes)
}
}
bridge.pushRootFrame(bytes, width, height)
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
// reader callback — no copy here). Safety depends on the Python
// receiver copying the bytes before this callback returns and
// overwrites the buffer for the next frame. It does:
// PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame
// (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py)
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
// pixels independently of this buffer. Do NOT remove that copy.
bridge.pushRootFrame(frameBuffer, width, height)
framesDeliveredCounter.incrementAndGet()
} catch (e: Exception) {
Log.w(TAG, "Root frame delivery failed: ${e.message}")
@@ -173,18 +209,26 @@ class RootScreenrecord(
}
private fun spawnScreenrecord(): Process? {
val cmd = buildString {
append("screenrecord")
append(" --output-format=h264")
append(" --size=${width}x$height")
append(" --bit-rate=$bitRate")
// argv form — passes safely through Root.runAsRoot's shell-quote
// logic so future changes to flag values can't introduce injection.
val args = arrayOf(
"screenrecord",
"--output-format=h264",
"--size=${width}x$height",
"--bit-rate=$bitRate",
// Time limit 0 isn't supported; the largest accepted is 180s.
// We restart the process ourselves if it exits early.
append(" --time-limit=180")
append(" -")
}
"--time-limit=180",
"-",
)
// 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 {
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
ProcessBuilder("su", "-c", args.joinToString(" "))
.redirectErrorStream(false)
.start()
} catch (e: Exception) {
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
null
@@ -210,21 +254,56 @@ class RootScreenrecord(
// exits cleanly we respawn so capture survives
// long sessions instead of freezing after ~3min.
Log.i(TAG, "screenrecord EOF — respawning")
runCatching { process?.destroy() }
synchronized(processLock) {
runCatching { process?.destroy() }
process = null
}
val next = spawnScreenrecord()
if (next == null) {
// Avoid a tight loop if `su` is suddenly unhappy.
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
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
continue@outer
}
var offset = 0
while (offset < n && running) {
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
inputBuffer.clear()
val chunk = minOf(n - offset, inputBuffer.capacity())
@@ -1,6 +1,5 @@
package com.ledgrab.android
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
@@ -8,24 +7,26 @@ import android.media.ImageReader
import android.media.projection.MediaProjection
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.util.DisplayMetrics
import android.util.Log
import java.nio.ByteBuffer
/**
* Captures the Android screen via MediaProjection and feeds frames
* to [PythonBridge].
*
* Frames are downscaled to [targetWidth] x [targetHeight] before
* crossing the JNI boundary to minimize overhead. For LED ambient
* lighting, even 480x270 contains far more data than needed.
* Frames are downscaled to roughly [targetWidth] x [targetHeight] before
* crossing the JNI boundary to minimize overhead. The actual capture
* 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(
private val projection: MediaProjection,
private val metrics: DisplayMetrics,
private val bridge: PythonBridge,
private val targetWidth: Int = 480,
private val targetHeight: Int = 270,
targetWidth: Int = 480,
targetHeight: Int = 270,
private val targetFps: Int = 30,
private val onProjectionStopped: () -> Unit = {},
) {
@@ -34,13 +35,51 @@ class ScreenCapture(
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 imageReader: ImageReader? = null
private var captureThread: HandlerThread? = null
private var captureHandler: Handler? = null
@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.
@@ -48,6 +87,7 @@ class ScreenCapture(
fun start() {
if (running) return
running = true
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
captureHandler = Handler(captureThread!!.looper)
@@ -56,28 +96,32 @@ class ScreenCapture(
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped (external)")
stop()
// Notify the service so the foreground notification /
// Python server get torn down too — otherwise a stale
// "Running" notification lingers after the user taps
// Android's system Cast/Screen-capture stop banner.
// We're on captureHandler's thread here — calling stop()
// directly would self-join captureThread (handler.join()
// from inside the handler thread hangs until the join
// timeout, then closes resources while we're STILL
// 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()
}
}, captureHandler)
imageReader = ImageReader.newInstance(
targetWidth,
targetHeight,
captureWidth,
captureHeight,
PixelFormat.RGBA_8888,
2, // maxImages — double buffer
3, // maxImages — small ring buffer; 3 is more forgiving than 2 under jitter
)
imageReader?.setOnImageAvailableListener({ reader ->
if (!running) return@setOnImageAvailableListener
val now = System.currentTimeMillis()
if (now - lastFrameTimeMs < frameIntervalMs) {
// Skip frame to maintain target FPS
val now = SystemClock.elapsedRealtimeNanos()
if (now < nextFrameNanos) {
// Too early — drop this image to stay on cadence.
reader.acquireLatestImage()?.close()
return@setOnImageAvailableListener
}
@@ -88,26 +132,38 @@ class ScreenCapture(
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
val rowBytes = captureWidth * pixelStride
val expected = rowBytes * captureHeight
// Handle row padding: rowStride may be > width * pixelStride
val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
// No padding — direct copy
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
bytes
// Fill the reusable buffer. Two paths:
// - rowStride == rowBytes: bulk get into the buffer
// - rowStride > rowBytes: row-by-row copy stripping padding
if (rowStride == rowBytes && buffer.remaining() >= expected) {
buffer.get(frameBuffer, 0, expected)
} else {
// Strip row padding
val rowBytes = targetWidth * pixelStride
val bytes = ByteArray(targetWidth * targetHeight * 4)
for (row in 0 until targetHeight) {
for (row in 0 until captureHeight) {
buffer.position(row * rowStride)
buffer.get(bytes, row * rowBytes, rowBytes)
buffer.get(frameBuffer, row * rowBytes, rowBytes)
}
bytes
}
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
lastFrameTimeMs = now
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
// capture handler — no copy here). Safety depends on the Python
// receiver copying the bytes before this callback returns and
// overwrites the buffer for the next frame. It does:
// PythonBridge.pushFrame → mediaprojection_engine.push_frame
// (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py)
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
// pixels independently of this buffer. Do NOT remove that copy.
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
// 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) {
Log.w(TAG, "Frame processing error: ${e.message}")
} finally {
@@ -117,8 +173,8 @@ class ScreenCapture(
virtualDisplay = projection.createVirtualDisplay(
VIRTUAL_DISPLAY_NAME,
targetWidth,
targetHeight,
captureWidth,
captureHeight,
metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader?.surface,
@@ -126,7 +182,7 @@ class ScreenCapture(
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.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
@@ -54,8 +55,23 @@ object UsbSerialBridge {
if (!initialized.compareAndSet(false, true)) return
val filter = IntentFilter(ACTION_USB_PERMISSION)
val ourPackage = app.packageName
val receiver = object : BroadcastReceiver() {
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(
UsbManager.EXTRA_PERMISSION_GRANTED,
false,
@@ -69,13 +85,16 @@ object UsbSerialBridge {
}
}
}
// Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
app.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
app.registerReceiver(receiver, filter)
}
// ContextCompat handles the RECEIVER_NOT_EXPORTED flag correctly
// across all supported API levels (it's a no-op on platforms
// where the flag doesn't exist, and explicit on API ≥33 where
// Android enforces it).
ContextCompat.registerReceiver(
app,
receiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)
}
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"?>
<!-- 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"
android:shape="oval">
<solid android:color="@color/green_status" />
<size android:width="18dp" android:height="18dp" />
</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>
+57 -115
View File
@@ -32,16 +32,28 @@
android:textStyle="bold"
android:letterSpacing="0.08"
android:layout_marginBottom="12dp"
android:fontFamily="sans-serif-light" />
android:fontFamily="sans-serif" />
<TextView
android:id="@+id/status_text"
android:id="@+id/tagline_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tagline"
android:textColor="@color/text_secondary"
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
android:id="@+id/toggle_button"
@@ -51,7 +63,38 @@
android:text="@string/btn_start"
android:textSize="22sp"
android:focusable="true"
android:focusableInTouchMode="true" />
android:focusableInTouchMode="true"
android:nextFocusDown="@+id/autostart_check" />
<!-- Shown only while notification-listener access is missing. The D-pad
focus chain is wired at runtime (wireStoppedFocusChain) because this
button and the autostart checkbox are both conditionally visible. -->
<Button
android:id="@+id/grant_notification_button"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="320dp"
android:layout_height="56dp"
android:layout_marginTop="20dp"
android:text="@string/btn_grant_notification_access"
android:textSize="18sp"
android:focusable="true"
android:focusableInTouchMode="true"
android:visibility="gone" />
<!-- Shown only while Usage Access is missing (needed by the foreground-app
automation rule). Like the grant-notification button, its D-pad focus
chain is wired at runtime (wireStoppedFocusChain). -->
<Button
android:id="@+id/grant_usage_access_button"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="320dp"
android:layout_height="56dp"
android:layout_marginTop="20dp"
android:text="@string/btn_grant_usage_access"
android:textSize="18sp"
android:focusable="true"
android:focusableInTouchMode="true"
android:visibility="gone" />
<CheckBox
android:id="@+id/autostart_check"
@@ -63,10 +106,11 @@
android:textSize="20sp"
android:buttonTint="@color/teal_accent"
android:focusable="true"
android:focusableInTouchMode="true" />
android:focusableInTouchMode="true"
android:nextFocusUp="@id/toggle_button" />
</LinearLayout>
<!-- Version at bottom -->
<!-- Version at bottom (always visible — looks polished on TV idle). -->
<TextView
android:id="@+id/version_text"
android:layout_width="wrap_content"
@@ -77,115 +121,13 @@
android:textSize="18sp"
tools:text="v0.1.0" />
<!-- RUNNING STATE -->
<LinearLayout
android:id="@+id/running_panel"
<!-- RUNNING STATE — deferred-inflate via ViewStub so first paint is
cheaper and the inflater doesn't measure two competing layouts. -->
<ViewStub
android:id="@+id/running_panel_stub"
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"
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>
android:inflatedId="@+id/running_panel"
android:layout="@layout/panel_running"
android:visibility="gone" />
</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,29 @@
<string name="app_name">LedGrab</string>
<string name="tagline">Фоновая подсветка для телевизора</string>
<string name="btn_start">Начать захват</string>
<string name="btn_starting">Запуск…</string>
<string name="btn_stop">Стоп</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="scan_to_configure">Сканируйте для настройки</string>
<string name="scan_fallback_hint">или откройте этот адрес с любого устройства в сети</string>
<string name="qr_description">QR-код для веб-интерфейса</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">Запускать при загрузке (только с 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>
<string name="notification_listener_label">Захват уведомлений LedGrab</string>
<string name="btn_grant_notification_access">Разрешить доступ к уведомлениям</string>
<string name="btn_grant_usage_access">Разрешить доступ к статистике использования</string>
</resources>
@@ -3,12 +3,29 @@
<string name="app_name">LedGrab</string>
<string name="tagline">电视氛围灯光</string>
<string name="btn_start">开始捕获</string>
<string name="btn_starting">正在启动…</string>
<string name="btn_stop">停止</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="scan_to_configure">扫码配置</string>
<string name="scan_fallback_hint">或在同一网络的任何设备上访问上方网址</string>
<string name="qr_description">Web界面二维码</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">开机自启(仅限 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>
<string name="notification_listener_label">LedGrab 通知捕获</string>
<string name="btn_grant_notification_access">授予通知访问权限</string>
<string name="btn_grant_usage_access">授予使用情况访问权限</string>
</resources>
@@ -3,12 +3,29 @@
<string name="app_name">LedGrab</string>
<string name="tagline">Ambient lighting for your TV</string>
<string name="btn_start">Start Capture</string>
<string name="btn_starting">Starting…</string>
<string name="btn_stop">Stop</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="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="version_prefix">v%1$s</string>
<string name="autostart_label">Start on boot (root only)</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>
<string name="notification_listener_label">LedGrab notification capture</string>
<string name="btn_grant_notification_access">Grant notification access</string>
<string name="btn_grant_usage_access">Grant usage access</string>
</resources>
@@ -12,6 +12,16 @@
<item name="android:colorControlActivated">@color/teal_accent</item>
</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">
<item name="android:background">@drawable/bg_button_primary</item>
<item name="android:textColor">@color/bg_navy</item>
@@ -1,8 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
brokers on the local network via plain HTTP/UDP. Cleartext traffic
must be allowed for these connections to work on Android 9+.
LedGrab is a LAN-only app:
- Inbound: web UI / API on the device (HTTP, port 8080)
- 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>
<base-config cleartextTrafficPermitted="true" />
+17 -12
View File
@@ -1,16 +1,18 @@
#!/usr/bin/env bash
#
# Cross-compile pydantic-core for Android across all three ABIs:
# arm64-v8a (primary — real TV hardware)
# x86_64 (modern emulators)
# x86 (legacy emulators)
# Cross-compile pydantic-core for Android across all supported ABIs:
# arm64-v8a (primary — modern TV hardware)
# x86_64 (modern 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
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
# memory/project_android_app.md for the incident notes).
#
# 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/*)
# - Python 3.11 (matches Chaquopy's embedded version)
# - maturin (pip install maturin)
@@ -19,9 +21,10 @@
# core dependency version changes.
#
# Usage:
# ./build-pydantic-core.sh # build all three ABIs
# ./build-pydantic-core.sh arm64 # build a single ABI
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
# ./build-pydantic-core.sh # build all 4 ABIs
# ./build-pydantic-core.sh arm64 # build a single ABI
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
# ./build-pydantic-core.sh armv7 # 32-bit ARM only
#
set -euo pipefail
@@ -91,21 +94,23 @@ fi
# ── ABI table ───────────────────────────────────────────────────────
# Columns: short_name rust_target clang_prefix sysconfig_dir
ABI_TABLE=(
"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 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
"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 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=(
[arm64]="arm64_v8a"
[x86_64]="x86_64"
[x86]="x86"
[armv7]="armeabi_v7a"
)
# ── Select which ABIs to build ──────────────────────────────────────
SELECTED=("$@")
if [ ${#SELECTED[@]} -eq 0 ]; then
SELECTED=(arm64 x86_64 x86)
SELECTED=(arm64 x86_64 x86 armv7)
fi
# ── 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()
+3 -2
View File
@@ -56,9 +56,10 @@ SetCompressor /SOLID lzma
; Functions
Function LaunchApp
; Only launch the app do NOT open the browser here. A manual launch (no
; --autostart) makes the app open the WebUI itself once /health responds,
; so opening the URL here too made the page appear twice.
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
Sleep 2000
ExecShell "open" "http://localhost:8080/"
FunctionEnd
; Detect running instance before install (file lock check on python.exe)
+7 -1
View File
@@ -31,9 +31,15 @@ Creates the Gitea release with a description table listing all artifacts. **The
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
### 4. `build-docker`
- Plain `docker build` + `docker push` (no Buildx — TrueNAS runners lack nested networking)
- Plain `docker build` + `docker push` for **amd64** (no Buildx — TrueNAS runners lack nested networking)
- Registry: `{gitea_host}/{repo}:{tag}`
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
- **arm64 is best-effort** (Raspberry Pi / arm64 HAOS): a `continue-on-error` step
cross-builds arm64 via **QEMU binfmt** (`tonistiigi/binfmt`) + `docker manifest`
(NOT buildx — sidesteps the docker-container-driver networking limit) and folds
amd64 + arm64 into multi-arch manifest lists under the same tags, plus
`:{tag}-amd64` / `:{tag}-arm64` arch-suffixed tags. If the runner can't run
privileged binfmt the step is skipped and the amd64 tags above remain valid.
## Build Scripts
+721 -251
View File
File diff suppressed because it is too large Load Diff
+42 -1
View File
@@ -54,7 +54,48 @@ When you attach a device, a default calibration is created:
}
```
## Custom Calibration
## Automatic Calibration
The easiest way to calibrate your strip is the **Auto-Calibrate** wizard, available directly
from the calibration modal. No LED counting required — just answer three questions and tap four
corners.
### Prerequisites
- A **Color Strip Source** (not a device-only target) associated with the strip.
- A **WLED device** connected and reachable by LedGrab.
### How to Start
1. Open the **Calibration** modal for your strip source (pencil icon → Calibration tab).
2. Click the **Auto-calibrate** button in the modal footer.
3. Follow the five-step wizard.
### Wizard Steps
| Step | What you do |
| ---- | ----------- |
| 1. Device | Select the WLED device that drives the strip. |
| 2. Start corner | LED #0 lights up on your device. Tap the corner where you see it. |
| 3. Direction | Sweep a few LEDs light up in sequence. Tap the direction they move. |
| 4. Mark corners | Use the step buttons to sweep to each remaining corner, then tap **Mark corner**. Repeat for all 4 corners. |
| 5. Preview & Save | Review the detected layout (start position, direction, LED counts per edge). Click **Save** to apply. |
### What Happens in the Background
- A calibration session takes exclusive control of the device for the duration of the wizard;
any previously running effect is paused and automatically restored when the wizard exits
(whether by saving, cancelling, or closing the modal).
- The solved `CalibrationConfig` is written directly to the Color Strip Source via the existing
PUT endpoint and takes effect immediately (no restart needed).
### Tips
- If LED #0 is hard to see, reduce ambient lighting briefly.
- The wizard works in the browser — desktop and Android TV app both supported.
- If you make a mistake in step 4, use **Step back** to re-mark the previous corner.
## Manual Calibration
### Step 1: Identify Your LED Layout
Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

+24 -10
View File
@@ -6,33 +6,47 @@
- `src/ledgrab/api/routes/` — REST API endpoints (one file per entity)
- `src/ledgrab/api/schemas/` — Pydantic request/response models (one file per entity)
- `src/ledgrab/core/` — Core business logic (capture, devices, audio, processing, automations)
- `src/ledgrab/storage/` — Data models (dataclasses) and JSON persistence stores
- `src/ledgrab/storage/` — Data models (dataclasses) and SQLite-backed persistence stores (`BaseSqliteStore`)
- `src/ledgrab/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
- `src/ledgrab/static/` — Frontend files (TypeScript, CSS, locales)
- `src/ledgrab/templates/` — Jinja2 HTML templates
- `config/` — Configuration files (YAML)
- `data/` — Runtime data (JSON stores, persisted state)
- `data/` — Runtime data: SQLite database (`ledgrab.db`) + assets. Relocate the root with `LEDGRAB_DATA_DIR`.
## Entity & Storage Pattern
Each entity follows: dataclass model (`storage/`) + JSON store (`storage/*_store.py`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`).
Each entity follows: dataclass model (`storage/`) + SQLite store (`storage/*_store.py`, subclassing `BaseSqliteStore`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`).
Stores keep an in-memory write-through cache over a per-entity SQLite table (the legacy `BaseJsonStore` still exists for reference but new stores use `BaseSqliteStore`). Schema/data shape changes go through `storage/data_migrations.py` — migrations are idempotent and tracked in a dedicated `data_migrations` audit table, so they run safely on every startup. **When renaming or restructuring stored fields, add a migration there** (see the Data Migration Policy in the root `CLAUDE.md`).
## Authentication
Server uses API key authentication via Bearer token in `Authorization` header.
API key authentication via Bearer token in the `Authorization` header (`Authorization: Bearer <key>`). WebSocket connections authenticate with a first-message handshake (`{"type":"auth","token":"<key>"}`). See `src/ledgrab/api/auth.py` for the canonical logic.
- Config: `config/default_config.yaml` under `auth.api_keys`
- Env var: `LEDGRAB_AUTH__API_KEYS`
- When `api_keys` is empty (default), auth is disabled — all endpoints are open
- To enable auth, add key entries (e.g. `dev: "your-secret-key"`)
- Config: `config/default_config.yaml` under `auth.api_keys`; env var `LEDGRAB_AUTH__API_KEYS`
- When `api_keys` is **empty** (default): **loopback** requests (`127.0.0.1` / `::1` / `localhost`) are allowed anonymously, but **LAN / remote** requests are rejected with `401`. Auth is *not* fully open.
- When `api_keys` is **set**: a valid Bearer token is required from every client (loopback included).
- `require_authenticated()` rejects even loopback-anonymous callers on sensitive endpoints (e.g. backup download, secret reveal).
## Activity / Audit Log
Persistent, queryable audit log of meaningful actions (auth, device, entity CRUD, capture, system), surfaced in the WebUI (Activity tab + Dashboard widget + Settings retention panel).
- **Storage is NOT a `BaseSqliteStore`.** `storage/activity_log_repository.py` is a purpose-built repository over a dedicated indexed `activity_log` table (migration `002_add_activity_log`) — query-on-demand with **keyset pagination** (`seq` cursor), never load-all-into-memory. Don't route it through the entity-store pattern.
- **Recording.** `core/activity_log/recorder.py` (`ActivityRecorder`) is best-effort (never raises into the audited action) and **thread-safe** (inline on the event loop; `loop.call_soon_threadsafe` from non-loop threads, e.g. zeroconf discovery). It persists the entry **and** fires an `activity_logged` realtime event. Actor comes from the `current_actor` `ContextVar` (set in `verify_api_key`), default `"system"`.
- **Entity CRUD is auto-audited** via the `fire_entity_event()` choke point in `api/dependencies.py` — every create/update/delete already calls it. **Delete handlers must pass `entity_name`** (the entity is gone by record time). Non-entity events use explicit `recorder.record(...)` (get it via `get_activity_recorder()` DI or `get_module_recorder()` for engine/thread sites).
- **Never log secrets.** API-key tokens are never recorded. Wrap any untrusted/attacker-controllable string (mDNS names, headers, user-authored names) with `sanitize_display()` (`core/activity_log/sanitize.py`) before it enters a `message`/`metadata` field. Per-IP throttle bounds auth-failure audit writes.
- **Adding a new audited event:** pick a dotted `action` (e.g. `"thing.created"`), call the recorder; for it to render localized in the UI, add `activity_log.msg.<action>` to all three `static/locales/*.json` (the frontend `localizeMessage()` maps action→template; falls back to the server `message`). Entity-type labels live under `activity_log.entity_type.<type>`.
- **Adding a new realtime event type** (`pm.fire_event({"type": ...})`): add it to `_ALLOWED_SERVER_EVENT_TYPES` in `static/js/core/events-ws.ts` AND keep `tests/test_events_ws_parity.py` green.
- **Retention + API.** `core/activity_log/retention.py` prunes by `max_days` + `max_entries` (settings persisted via `db.set_setting("activity_log")`); the recorder's `enabled` flag is rehydrated from those settings on startup. REST in `api/routes/activity_log.py`: `GET /activity-log` (list, `AuthRequired`), `GET /export` (CSV/JSON stream — `require_authenticated`; chunked keyset so it never holds the DB lock across the stream; CSV formula-injection guarded), `GET|PUT /settings` (PUT is `require_authenticated`), `DELETE` (clear — `require_authenticated`, self-audited). The table is covered by the existing whole-DB backup (no `STORE_MAP` change needed).
## Common Tasks
### Adding a new API endpoint
1. Create route file in `api/routes/`
1. Create route file in `api/routes/` (define an `APIRouter(prefix="/api/v1/...")`)
2. Define request/response schemas in `api/schemas/`
3. Register the router in `main.py`
3. Register the router in `api/__init__.py` (it aggregates every route module into the single `router` that `main.py` mounts)
4. Restart the server
5. Test via `/docs` (Swagger UI)
+13 -4
View File
@@ -15,11 +15,20 @@ auth:
# - LAN requests are REJECTED with 401 (security default)
# To enable LAN access, uncomment the example below and replace the value
# with a secret you generated yourself (e.g. `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.
# Do NOT ship a hard-coded key here — a publicly-known token grants full
# LAN access to anyone on the network.
api_keys:
dev: "development-key-change-in-production"
default: "development-key-change-in-production"
# api_keys:
# my-client: "replace-with-output-of-openssl-rand-hex-32"
# Expose the interactive API docs (/docs, /redoc, /openapi.json) WITHOUT a
# Bearer token so they can be opened directly in a browser. When true, this
# applies to loopback AND LAN clients. Only the API *surface* (route paths +
# parameter schemas) is exposed — calling an endpoint from Swagger still
# requires the token via its "Authorize" button, and every other route stays
# protected. Leave false unless you want browsable docs on your network.
expose_docs: false
# Storage paths default to ./data relative to the server's working directory.
# Set LEDGRAB_DATA_DIR in the environment to point at a different data root
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.7.0"
version = "0.9.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+1 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
# In dev (running from source without `pip install -e .`) and on Android
# (Chaquopy embeds the source directly with no dist-info), we additionally
# read pyproject.toml so the version is always correct without manual sync.
_FALLBACK_VERSION = "0.7.0"
_FALLBACK_VERSION = "0.8.1"
def _read_pyproject_version() -> str | None:
+26 -10
View File
@@ -39,8 +39,9 @@ _fix_embedded_tcl_paths()
import uvicorn # noqa: E402
from ledgrab.config import get_config # noqa: E402
from ledgrab.config import Config, get_config # noqa: E402
from ledgrab.server_ref import set_server, set_tray # noqa: E402
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT # noqa: E402
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
from ledgrab.utils import setup_logging, get_logger # noqa: E402
from ledgrab.utils.platform import is_windows # noqa: E402
@@ -49,7 +50,8 @@ from ledgrab.utils.win_shutdown import WindowsShutdownGuard # noqa: E402
setup_logging()
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:
@@ -107,17 +109,28 @@ def _check_port(host: str, port: int) -> None:
sys.exit(1)
def main() -> None:
config = get_config()
_check_port(config.server.host, config.server.port)
def _build_server(config: Config) -> uvicorn.Server:
"""Construct the uvicorn Server with a bounded graceful-shutdown timeout.
Extracted so the graceful-shutdown bound is unit-testable leaving it
unset (the uvicorn default of ``None``) is the regression that strands
LED targets and prevents the process from exiting.
"""
uv_config = uvicorn.Config(
"ledgrab.main:app",
host=config.server.host,
port=config.server.port,
log_level=config.server.log_level.lower(),
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
)
server = uvicorn.Server(uv_config)
return uvicorn.Server(uv_config)
def main() -> None:
config = get_config()
_check_port(config.server.host, config.server.port)
server = _build_server(config)
set_server(server)
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
@@ -154,8 +167,9 @@ def main() -> None:
).start()
# Tray on main thread (blocking)
tray_icon = _ICON_PATH if _ICON_PATH.exists() else _ICON_FALLBACK_PATH
tray = TrayManager(
icon_path=_ICON_PATH,
icon_path=tray_icon,
port=config.server.port,
on_exit=lambda: _request_shutdown(server),
)
@@ -163,9 +177,11 @@ def main() -> None:
tray.run()
# Tray exited — wait for server to finish its graceful shutdown.
# 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)
# Budget: the graceful-shutdown wait (GRACEFUL_SHUTDOWN_TIMEOUT) runs
# first, then the lifespan's own ~16 s shutdown (target restore + DB
# checkpoint). Join longer than their sum so a slow disk doesn't get
# the DB checkpoint cut short.
server_thread.join(timeout=25)
if guard is not None:
guard.stop()
else:
+35 -1
View File
@@ -6,6 +6,7 @@ inside an Android application. Sets up Android-specific paths
"""
import asyncio
import json
import os
import threading
from typing import Any
@@ -15,7 +16,7 @@ _server: Any | None = None # uvicorn.Server
_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.
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
(e.g. ``/data/data/com.ledgrab.android/files``).
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 ──────────────
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__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 ──────────────────────────────
import uvicorn # noqa: E402
@@ -50,10 +64,27 @@ def start_server(data_dir: str, port: int = 8080) -> None:
logger = get_logger(__name__)
logger.info("LedGrab Android: starting server on port %d", port)
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
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."
)
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
uv_config = uvicorn.Config(
"ledgrab.main:app",
@@ -62,6 +93,9 @@ def start_server(data_dir: str, port: int = 8080) -> None:
log_level=config.server.log_level.lower(),
# No uvloop/httptools on Android — use pure-Python asyncio
loop="asyncio",
# Bound the graceful-shutdown wait so stop_server() can't hang forever
# on a lingering WebView events WebSocket — see shutdown_state for why.
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
)
global _server, _loop
+12
View File
@@ -18,6 +18,7 @@ from .routes.audio_templates import router as audio_templates_router
from .routes.value_sources import router as value_sources_router
from .routes.automations import router as automations_router
from .routes.scene_presets import router as scene_presets_router
from .routes.scene_playlists import router as scene_playlists_router
from .routes.webhooks import router as webhooks_router
from .routes.sync_clocks import router as sync_clocks_router
from .routes.color_strip_processing import router as cspt_router
@@ -33,6 +34,11 @@ from .routes.audio_processing_templates import router as audio_processing_templa
from .routes.audio_filters import router as audio_filters_router
from .routes.pattern_templates import router as pattern_templates_router
from .routes.preferences import router as preferences_router
from .routes.snapshot import router as snapshot_router
from .routes.graph import router as graph_router
from .routes.calibration import router as calibration_router
from .routes.setup import router as setup_router
from .routes.activity_log import router as activity_log_router
router = APIRouter()
router.include_router(system_router)
@@ -51,6 +57,7 @@ router.include_router(output_targets_router)
router.include_router(output_targets_control_router)
router.include_router(automations_router)
router.include_router(scene_presets_router)
router.include_router(scene_playlists_router)
router.include_router(webhooks_router)
router.include_router(sync_clocks_router)
router.include_router(cspt_router)
@@ -66,5 +73,10 @@ router.include_router(audio_processing_templates_router)
router.include_router(audio_filters_router)
router.include_router(pattern_templates_router)
router.include_router(preferences_router)
router.include_router(snapshot_router)
router.include_router(graph_router)
router.include_router(calibration_router)
router.include_router(setup_router)
router.include_router(activity_log_router)
__all__ = ["router"]
+204 -7
View File
@@ -3,18 +3,137 @@
import asyncio
import json
import secrets
import threading
import time
from collections import OrderedDict
from typing import Annotated
from urllib.parse import urlparse
from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from starlette.websockets import WebSocket, WebSocketDisconnect
from ledgrab.config import get_config
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
logger = get_logger(__name__)
# ── Auth-failure audit throttle (H3) ───────────────────────────────────────
#
# Unauthenticated callers can hammer any auth path; without a recording
# throttle each attempt would write one SQLite row AND broadcast one WS event,
# providing a cheap disk/broadcast amplification vector.
#
# Mitigation: record at most one ``auth.rejected`` audit entry per client IP
# per _AUTH_RECORD_WINDOW seconds. The auth decision (401) is NEVER
# suppressed — only the *audit recording* is de-duplicated.
#
# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP
# entries. When the cap is exceeded the oldest-inserted IP is evicted in O(1)
# so the dict stays bounded regardless of the number of distinct source IPs an
# attacker can forge.
#
# Thread safety: the throttle dict is guarded by ``_auth_record_lock`` (mirrors
# ``_auth_fail_lock`` in routes/game_integration) so the compound
# read/evict/insert is atomic. The HTTP auth dependency runs on the event loop
# (``verify_api_key`` is async), but ``_record_auth_failure`` is reached from
# both the HTTP and WebSocket auth paths and must remain safe if ever called
# from a background thread — the lock is uncontended on the loop, so it costs
# nothing while preventing a KeyError / "dict changed size" from ever turning
# an intended 401 into a 500.
_AUTH_RECORD_WINDOW: float = 10.0 # seconds — one record per IP per window
_AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously
# ip -> monotonic timestamp of last *recorded* auth.rejected entry.
# OrderedDict so the oldest insertion can be evicted in O(1) via popitem.
_auth_record_last: "OrderedDict[str, float]" = OrderedDict()
_auth_record_lock = threading.Lock()
def _should_record_auth_failure(client_ip: str) -> bool:
"""Return True when an ``auth.rejected`` record should be written for *client_ip*.
Suppresses duplicates within _AUTH_RECORD_WINDOW seconds. Evicts the
oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent
unbounded memory growth under IP-spray attacks.
Thread-safe: the entire read/evict/insert is performed under
``_auth_record_lock`` so concurrent threadpool workers cannot corrupt the
dict or raise mid-mutation.
"""
now = time.monotonic()
with _auth_record_lock:
last = _auth_record_last.get(client_ip)
if last is not None and (now - last) < _AUTH_RECORD_WINDOW:
return False # suppress: within the de-dup window
# Enforce hard cap before inserting: evict the oldest entry in O(1).
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
_auth_record_last.popitem(last=False)
# Refresh recency: move/insert this IP to the most-recent end so the
# popitem(last=False) above always drops a genuinely old entry.
_auth_record_last[client_ip] = now
_auth_record_last.move_to_end(client_ip)
return True
def _record_auth_failure(reason: str, client_host: str | None) -> None:
"""Best-effort: record an auth failure audit entry (never raises).
SECURITY: the attempted token is NEVER passed here; only the reason and
the caller's IP/label are recorded.
THROTTLE: at most one ``auth.rejected`` record is written per client IP
per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification
DoS. The 401 response is always returned regardless.
The whole body is wrapped so an audit-path failure can never convert an
intended 401 into a 500 (honors the "never raises" contract).
"""
try:
if not _should_record_auth_failure(client_host or "unknown"):
return # throttled — drop duplicate recording for this IP/window
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.rejected",
severity=ActivitySeverity.WARNING,
actor="anonymous",
message=f"Authentication failed: {reason}",
metadata={"reason": reason, "client": client_host or "unknown"},
)
except Exception as exc: # never raise into the auth path
logger.warning("auth-failure audit recording failed: %s", exc)
def _record_ws_auth_success(label: str, client_host: str | None) -> None:
"""Best-effort: record a successful WebSocket session establishment."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.ws_connected",
severity=ActivitySeverity.INFO,
actor=label,
message=f"WebSocket session established by '{label}'",
metadata={"client": client_host or "unknown"},
)
# Security scheme for Bearer token
security = HTTPBearer(auto_error=False)
@@ -49,7 +168,7 @@ def _is_loopback(host: str | None) -> bool:
return _classify_is_loopback(host)
def verify_api_key(
async def verify_api_key(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str:
@@ -63,6 +182,13 @@ def verify_api_key(
LAN access requires an API key).
- When API keys ARE configured, valid Bearer credentials are required.
This is an ``async`` dependency on purpose: token comparison is CPU-trivial,
and an async dependency runs in the SAME task/context as the route handler,
so ``current_actor.set(...)`` below is visible to ``ActivityRecorder`` when
the handler later records an entity event. A sync dependency would run in a
throwaway threadpool context and the actor mutation would be discarded,
attributing every audited action to "system".
Args:
request: incoming request (used to read client host)
credentials: HTTP authorization credentials
@@ -80,10 +206,13 @@ def verify_api_key(
if not config.auth.api_keys:
# No keys configured — allow loopback only.
if _is_loopback(client_host):
request.state.auth_label = "anonymous"
current_actor.set("anonymous")
return "anonymous"
# Allow caller to authenticate explicitly even without configured keys?
# No — there are no keys to compare against. Reject.
logger.warning("Rejected LAN request from %s: no API key configured", client_host)
_record_auth_failure("LAN access rejected: no API key configured", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
@@ -96,24 +225,32 @@ def verify_api_key(
# Check if credentials are provided
if not credentials:
logger.warning("Request missing Authorization header")
_record_auth_failure("missing Bearer token", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key - authentication is required",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract token
# Extract token — NEVER log or record the token value itself.
token = credentials.credentials
# Find matching key and return its label using constant-time comparison
# Find matching key and return its label using constant-time comparison.
# Compare UTF-8 byte encodings: secrets.compare_digest raises TypeError on
# non-ASCII str (an attacker can put 0x80-0xFF in the Authorization header,
# which Starlette latin-1-decodes to a non-ASCII str). Byte comparison is
# well-defined for any input and preserves constant-time behavior, so a
# bad/non-ASCII token cleanly falls through to the 401 below instead of 500.
token_b = (token or "").encode("utf-8")
authenticated_as = None
for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
authenticated_as = label
break
if not authenticated_as:
logger.warning("Invalid API key attempt")
_record_auth_failure("invalid Bearer token", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
@@ -123,6 +260,12 @@ def verify_api_key(
# Log successful authentication
logger.debug(f"Authenticated as: {authenticated_as}")
# Stash the friendly label so the access-log middleware can attribute the
# request to a client without re-running the token comparison.
request.state.auth_label = authenticated_as
# Set the actor ContextVar so ActivityRecorder can resolve it without
# threading it through every call site.
current_actor.set(authenticated_as)
return authenticated_as
@@ -131,6 +274,31 @@ def verify_api_key(
AuthRequired = Annotated[str, Depends(verify_api_key)]
async def verify_docs_access(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str:
"""Auth gate for the OpenAPI docs routes (/docs, /redoc, /openapi.json).
When ``auth.expose_docs`` is True, the docs pages load anonymously from any
client (loopback and LAN) so they can be viewed in a browser without a
Bearer token. Only the API *surface* is exposed this way every other
endpoint still goes through :func:`verify_api_key`.
When ``auth.expose_docs`` is False (default), this delegates to
:func:`verify_api_key`, so docs require a token exactly like the rest of
the API.
"""
if get_config().auth.expose_docs:
request.state.auth_label = "anonymous-docs"
return "anonymous-docs"
return await verify_api_key(request, credentials)
# Dependency for the OpenAPI docs routes — relaxed when auth.expose_docs is set
DocsAccess = Annotated[str, Depends(verify_docs_access)]
def require_authenticated(label: str) -> None:
"""Reject the anonymous (loopback) auth label.
@@ -186,12 +354,30 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
# a strong signal even before the token check. Non-browser clients
# legitimately omit Origin; those fall through to the auth handshake.
config = get_config()
client_host = websocket.client.host if websocket.client else None
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,
)
# Sanitize first so urlparse does not choke on control chars / ANSI / NUL
# embedded by an attacker in the Origin header (e.g. \n triggers IPv6 parse
# error in Python's urlsplit on malformed netloc).
_safe_origin_raw = sanitize_display(origin) if origin else ""
try:
_netloc = urlparse(_safe_origin_raw).netloc if _safe_origin_raw else ""
except ValueError:
# Malformed IPv6 addresses (e.g. "http://[::1" without closing "]")
# cause urlparse to raise ValueError. Fall back to "unknown" — do NOT
# fall back to the raw origin string, which could carry query params
# or path components containing secrets.
_netloc = ""
_safe_origin = sanitize_display(_netloc or "unknown")
_record_auth_failure(
f"WebSocket origin rejected: {_safe_origin!r}",
client_host,
)
try:
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
except _WS_SEND_BENIGN_EXC:
@@ -206,6 +392,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
except _WS_SEND_BENIGN_EXC:
pass
return None
_record_ws_auth_success(label, client_host)
return label
@@ -213,12 +400,19 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
def _match_api_key(token: str) -> str | None:
"""Return the label matching *token* using constant-time comparison, or None."""
"""Return the label matching *token* using constant-time comparison, or None.
Compares UTF-8 byte encodings so a non-ASCII token (a JSON string in the WS
auth message trivially carries non-ASCII) cannot raise TypeError out of
``secrets.compare_digest`` it simply fails to match and yields a clean
``auth_error`` instead of crashing the handler.
"""
config = get_config()
if not token:
return None
token_b = token.encode("utf-8")
for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
return label
return None
@@ -271,6 +465,7 @@ async def verify_ws_auth(
return None
return "anonymous"
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
_record_auth_failure("WebSocket auth timeout", client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
except _WS_SEND_BENIGN_EXC:
@@ -328,6 +523,7 @@ async def verify_ws_auth(
await websocket.send_json({"type": "auth_ok"})
return "anonymous"
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
_record_auth_failure("LAN WebSocket rejected: no API key configured", client_host)
try:
await websocket.send_json(
{
@@ -339,10 +535,11 @@ async def verify_ws_auth(
pass
return None
# Keys configured: require a matching token.
# Keys configured: require a matching token. NEVER log the token value.
label = _match_api_key(token or "")
if not label:
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
_record_auth_failure("invalid WebSocket token", client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
except _WS_SEND_BENIGN_EXC:
+142 -2
View File
@@ -19,6 +19,7 @@ from ledgrab.storage.audio_template_store import AudioTemplateStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
from ledgrab.storage.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
@@ -27,6 +28,7 @@ from ledgrab.storage.gradient_store import GradientStore
from ledgrab.storage.weather_source_store import WeatherSourceStore
from ledgrab.storage.asset_store import AssetStore
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
@@ -35,11 +37,17 @@ from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
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.pattern_template_store import PatternTemplateStore
from ledgrab.core.activity_log.recorder import ActivityRecorder, get_module_recorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
T = TypeVar("T")
@@ -110,6 +118,14 @@ def get_automation_engine() -> AutomationEngine:
return _get("automation_engine", "Automation engine")
def get_scene_playlist_store() -> ScenePlaylistStore:
return _get("scene_playlist_store", "Scene playlist store")
def get_playlist_engine() -> PlaylistEngine:
return _get("playlist_engine", "Playlist engine")
def get_auto_backup_engine() -> AutoBackupEngine:
return _get("auto_backup_engine", "Auto-backup engine")
@@ -158,6 +174,15 @@ def get_game_event_bus() -> GameEventBus:
return _get("game_event_bus", "Game event bus")
def get_lol_poll_manager() -> LoLPollManager | None:
"""LoL poll manager, or None if not wired (e.g. minimal test harnesses).
Polling is a best-effort background feature, so callers guard on None
rather than 500-ing a CRUD request when the manager is absent.
"""
return _deps.get("lol_poll_manager")
def get_mqtt_store() -> MQTTSourceStore:
return _get("mqtt_store", "MQTT source store")
@@ -186,16 +211,87 @@ def get_update_service() -> UpdateService:
return _get("update_service", "Update service")
def get_activity_recorder() -> ActivityRecorder:
return _get("activity_recorder", "Activity recorder")
def get_activity_log_repo() -> ActivityLogRepository:
return _get("activity_log_repo", "Activity log repository")
def get_activity_log_retention_engine() -> ActivityLogRetentionEngine:
return _get("activity_log_retention_engine", "Activity log retention engine")
# ── Event helper ────────────────────────────────────────────────────────
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus.
# entity_type → (_deps key, store method name) for human-name resolution.
# Module-level constant: built once at import rather than per audited mutation
# (``_resolve_entity_name`` is the create/update audit choke point).
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
"output_target": ("output_target_store", "get_target"),
"device": ("device_store", "get_device"),
"picture_source": ("picture_source_store", "get_source"),
"audio_source": ("audio_source_store", "get_source"),
"color_strip_source": ("color_strip_store", "get_source"),
"template": ("template_store", "get_template"),
"capture_template": ("template_store", "get_template"),
"pp_template": ("pp_template_store", "get_template"),
"automation": ("automation_store", "get_automation"),
"scene_preset": ("scene_preset_store", "get_preset"),
"scene_playlist": ("scene_playlist_store", "get_playlist"),
"sync_clock": ("sync_clock_store", "get_clock"),
"gradient": ("gradient_store", "get_gradient"),
"audio_template": ("audio_template_store", "get_template"),
"value_source": ("value_source_store", "get_source"),
"cspt": ("cspt_store", "get_template"),
"audio_processing_template": ("audio_processing_template_store", "get_template"),
"pattern_template": ("pattern_template_store", "get_template"),
"home_assistant_source": ("ha_store", "get_source"),
"mqtt_source": ("mqtt_store", "get_source"),
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
}
def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None:
"""Best-effort: look up a human name for *entity_id* from the matching store.
Returns ``None`` when the store is missing, the entity is gone, or any
exception occurs (e.g. during delete the entity may have just been removed).
"""
entry = _STORE_LOOKUP.get(entity_type)
if entry is None:
return None
store_key, method_name = entry
store = _deps.get(store_key)
if store is None:
return None
try:
obj = getattr(store, method_name)(entity_id)
if obj is not None:
return getattr(obj, "name", None)
except Exception:
pass
return None
def fire_entity_event(
entity_type: str,
action: str,
entity_id: str,
entity_name: str | None = None,
) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus and
record an audit entry.
Args:
entity_type: e.g. "device", "output_target", "color_strip_source"
action: "created", "updated", or "deleted"
entity_id: The entity's unique ID
entity_name: Human-readable name. For deletes: **must** be passed
explicitly (entity is already gone when we get here).
For create/update: resolved from the store when not supplied.
"""
pm = _deps.get("processor_manager")
if pm is not None:
@@ -208,6 +304,38 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
}
)
# ── Audit record (best-effort) ──────────────────────────────────────────
rec = get_module_recorder()
if rec is None:
return
# Resolve name when not explicitly provided (create / update paths).
# For deleted: entity already gone — rely on the explicitly passed name.
resolved_name = entity_name
if resolved_name is None and action != "deleted":
resolved_name = _resolve_entity_name(entity_type, entity_id)
# Build a concise human message.
# Sanitize the display name before interpolating into the free-text message
# (user-authored names hit the CSV/export trust surface).
safe_display_name = sanitize_display(resolved_name) if resolved_name else None
display_name = f"'{safe_display_name}'" if safe_display_name else entity_id
action_word = {"created": "created", "updated": "updated", "deleted": "deleted"}.get(
action, action
)
entity_label = entity_type.replace("_", " ")
message = f"{entity_label.capitalize()} {display_name} {action_word}"
rec.record(
category=ActivityCategory.ENTITY,
action=f"entity.{action}",
severity=ActivitySeverity.INFO,
entity_type=entity_type,
entity_id=entity_id,
entity_name=sanitize_display(resolved_name) if resolved_name else None,
message=message,
)
# ── Initialization ──────────────────────────────────────────────────────
@@ -226,7 +354,9 @@ def init_dependencies(
value_source_store: ValueSourceStore | None = None,
automation_store: AutomationStore | None = None,
scene_preset_store: ScenePresetStore | None = None,
scene_playlist_store: ScenePlaylistStore | None = None,
automation_engine: AutomationEngine | None = None,
playlist_engine: PlaylistEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None,
sync_clock_store: SyncClockStore | None = None,
sync_clock_manager: SyncClockManager | None = None,
@@ -240,11 +370,15 @@ def init_dependencies(
ha_manager: HomeAssistantManager | None = None,
game_integration_store: GameIntegrationStore | None = None,
game_event_bus: GameEventBus | None = None,
lol_poll_manager: LoLPollManager | None = None,
mqtt_store: MQTTSourceStore | None = None,
mqtt_manager: MQTTManager | None = None,
http_endpoint_store: HTTPEndpointStore | None = None,
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
pattern_template_store: PatternTemplateStore | None = None,
activity_recorder: ActivityRecorder | None = None,
activity_log_repo: ActivityLogRepository | None = None,
activity_log_retention_engine: ActivityLogRetentionEngine | None = None,
):
"""Initialize global dependencies."""
_deps.update(
@@ -262,7 +396,9 @@ def init_dependencies(
"value_source_store": value_source_store,
"automation_store": automation_store,
"scene_preset_store": scene_preset_store,
"scene_playlist_store": scene_playlist_store,
"automation_engine": automation_engine,
"playlist_engine": playlist_engine,
"auto_backup_engine": auto_backup_engine,
"sync_clock_store": sync_clock_store,
"sync_clock_manager": sync_clock_manager,
@@ -276,10 +412,14 @@ def init_dependencies(
"ha_manager": ha_manager,
"game_integration_store": game_integration_store,
"game_event_bus": game_event_bus,
"lol_poll_manager": lol_poll_manager,
"mqtt_store": mqtt_store,
"mqtt_manager": mqtt_manager,
"http_endpoint_store": http_endpoint_store,
"audio_processing_template_store": audio_processing_template_store,
"pattern_template_store": pattern_template_store,
"activity_recorder": activity_recorder,
"activity_log_repo": activity_log_repo,
"activity_log_retention_engine": activity_log_retention_engine,
}
)
+609
View File
@@ -0,0 +1,609 @@
"""Authoritative wiring-graph schema and topology engine.
This module is the single source of truth for **which reference fields connect
which entity kinds**. The frontend graph editor historically hard-coded the same
information in two places (``graph-connections.ts`` ``CONNECTION_MAP`` and
``graph-layout.ts`` ``buildGraph``); the ``GET /api/v1/graph/schema`` endpoint
now serves this registry so the client can render ports and edges generically
and the two never drift.
This registry is a *superset* of the current frontend ``buildGraph``: it also
declares real references that ``buildGraph`` does not yet draw (e.g.
``value_source.value_source_id`` chaining and ``value_source.color_strip_source_id``).
The backend is authoritative; the client is expected to converge on it.
Everything in this module is pure (operates on plain dicts), so the topology
build, dependency lookup, cycle and dangling-reference detection are all unit
testable without booting the app or any store.
Field-path grammar (the ``field`` of a :class:`ConnectionField`):
* ``"device_id"`` a top-level string id.
* ``"brightness.source_id"`` a nested object; ``brightness`` may be a
plain number (unbound :class:`BindableFloat`) or ``{"value", "source_id"}``.
* ``"settings.pattern_template_id"`` arbitrarily deep object access.
* ``"layers[].source_id"`` ``layers`` is a list; read ``source_id``
from every element.
* ``"calibration.lines[].picture_source_id"`` object list field.
"""
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass, is_dataclass
from typing import Any
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ConnectionField:
"""One connectable reference: ``target_kind.field`` points at ``source_kind``."""
target_kind: str
"""Entity kind that *holds* the reference (the consumer / referrer)."""
field: str
"""Dot-path to the reference value (see module docstring grammar)."""
source_kind: str
"""Entity kind being referenced (the producer / source)."""
edge_type: str
"""Edge category, used by the client for colour and port grouping."""
bindable: bool = False
"""True when the slot is a :class:`BindableFloat`/``BindableColor`` value binding."""
nested: bool = False
"""True when the field lives inside a nested object/list (dotted path)."""
@property
def is_list(self) -> bool:
"""True when any path segment iterates a list (``foo[]``)."""
return "[]" in self.field
# ── Entity kinds & their human "type" attribute ────────────────────────────
# Mirrors the frontend buildGraph(): kind → the serialized field that carries
# the entity's subtype (used only for the node label / icon).
NODE_TYPE_FIELD: dict[str, str] = {
"device": "device_type",
"capture_template": "engine_type",
"pp_template": "",
"audio_template": "engine_type",
"pattern_template": "",
"picture_source": "stream_type",
"audio_source": "source_type",
"value_source": "source_type",
"color_strip_source": "source_type",
"sync_clock": "",
"output_target": "target_type",
"scene_preset": "",
"automation": "",
"cspt": "",
}
ENTITY_KINDS: tuple[str, ...] = tuple(NODE_TYPE_FIELD.keys())
# ── The registry ───────────────────────────────────────────────────────────
# NOTE: ``gradient`` and ``ha_source`` reference fields are intentionally
# omitted — they are not first-class graph node kinds, so wiring them would
# only ever produce dangling-reference noise.
CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
# ── Picture sources ──
ConnectionField("picture_source", "capture_template_id", "capture_template", "template"),
ConnectionField("picture_source", "source_stream_id", "picture_source", "picture"),
ConnectionField("picture_source", "postprocessing_template_id", "pp_template", "template"),
# ── Audio sources ──
ConnectionField("audio_source", "audio_template_id", "audio_template", "audio"),
ConnectionField("audio_source", "audio_source_id", "audio_source", "audio"),
# ── Value sources ──
ConnectionField("value_source", "audio_source_id", "audio_source", "audio"),
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
ConnectionField("value_source", "value_source_id", "value_source", "value"),
ConnectionField("value_source", "color_strip_source_id", "color_strip_source", "colorstrip"),
# AnimatedColorValueSource references a sync clock for shared timing.
ConnectionField("value_source", "clock_id", "sync_clock", "clock"),
# ── Color strip sources (top-level) ──
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
ConnectionField("color_strip_source", "clock_id", "sync_clock", "clock"),
ConnectionField("color_strip_source", "input_source_id", "color_strip_source", "colorstrip"),
ConnectionField("color_strip_source", "processing_template_id", "cspt", "template"),
# ── Color strip sources (BindableFloat value bindings) ──
*(
ConnectionField(
"color_strip_source",
f"{prop}.source_id",
"value_source",
"value",
bindable=True,
nested=True,
)
for prop in (
"smoothing",
"sensitivity",
"intensity",
"scale",
"speed",
"wind_strength",
"temperature_influence",
"sound_volume",
"timeout",
"brightness",
)
),
# ── Color strip sources (BindableColor value bindings) ──
# NOTE: `bindable` here is *structural* (these are BindableColor fields). They
# are NOT usefully wireable from the graph: a ValueStream yields a scalar
# (`get_value() -> float`) and every colour consumer reads the static RGB via
# `bcolor()` (source_id ignored at runtime). The graph editor keeps them
# read-only; do not enable them without a colour-producing value source.
*(
ConnectionField(
"color_strip_source",
f"{prop}.source_id",
"value_source",
"value",
bindable=True,
nested=True,
)
for prop in ("color", "color_peak", "fallback_color", "default_color")
),
# ── Color strip sources (composite layers / mapped zones / calibration) ──
ConnectionField(
"color_strip_source", "layers[].source_id", "color_strip_source", "colorstrip", nested=True
),
ConnectionField(
"color_strip_source",
"layers[].brightness_source_id",
"value_source",
"value",
bindable=True,
nested=True,
),
ConnectionField(
"color_strip_source", "layers[].processing_template_id", "cspt", "template", nested=True
),
ConnectionField(
"color_strip_source", "zones[].source_id", "color_strip_source", "colorstrip", nested=True
),
ConnectionField(
"color_strip_source",
"calibration.lines[].picture_source_id",
"picture_source",
"picture",
nested=True,
),
# ── Output targets ──
ConnectionField("output_target", "device_id", "device", "device"),
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
ConnectionField(
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
),
ConnectionField(
"output_target", "transition.source_id", "value_source", "value", bindable=True, nested=True
),
ConnectionField(
"output_target", "settings.pattern_template_id", "pattern_template", "template", nested=True
),
ConnectionField(
"output_target",
"settings.brightness.source_id",
"value_source",
"value",
bindable=True,
nested=True,
),
# ── Scene presets ──
ConnectionField("scene_preset", "targets[].target_id", "output_target", "scene", nested=True),
# ── Automations ──
ConnectionField("automation", "scene_preset_id", "scene_preset", "scene"),
ConnectionField("automation", "deactivation_scene_preset_id", "scene_preset", "scene"),
# ── Devices ──
ConnectionField("device", "default_css_processing_template_id", "cspt", "template"),
)
def schema_for_kind(kind: str) -> list[ConnectionField]:
"""Every connectable field whose *referrer* is ``kind``."""
return [c for c in CONNECTION_SCHEMA if c.target_kind == kind]
# BindableColor slots are structurally bindable but NOT graph-editable: a
# ValueStream yields a scalar (``get_value() -> float``) and colour consumers
# read the static RGB via ``bcolor()`` (source_id ignored at runtime), so a
# value source cannot drive a colour.
_COLOR_BINDABLE_FIELDS: frozenset[str] = frozenset(
{
"color.source_id",
"color_peak.source_id",
"fallback_color.source_id",
"default_color.source_id",
}
)
def is_editable(cf: ConnectionField) -> bool:
"""Whether a field can be wired from the graph.
Editable = a top-level reference, or a single-level ``BindableFloat`` slot.
List slots (need an element index), double-nested fields, and the dead
colour bindings stay read-only.
"""
if cf.is_list:
return False
if not cf.nested:
return True
return cf.bindable and cf.field.count(".") == 1 and cf.field not in _COLOR_BINDABLE_FIELDS
def schema_as_dicts() -> list[dict[str, Any]]:
"""Serialize the registry for the ``/graph/schema`` endpoint."""
return [
{
"target_kind": c.target_kind,
"field": c.field,
"source_kind": c.source_kind,
"edge_type": c.edge_type,
"bindable": c.bindable,
"nested": c.nested,
"is_list": c.is_list,
"editable": is_editable(c),
}
for c in CONNECTION_SCHEMA
]
# ── Reference extraction ────────────────────────────────────────────────────
def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]:
"""Resolve a (possibly nested/list) ``field_path`` to its referenced ids.
Returns only non-empty string ids. Tolerant of missing keys, ``None``
values and unbound bindables (a plain number where an object was expected).
"""
current: list[Any] = [entity]
for segment in field_path.split("."):
is_list = segment.endswith("[]")
key = segment[:-2] if is_list else segment
nxt: list[Any] = []
for obj in current:
if not isinstance(obj, dict):
continue
val = obj.get(key)
if is_list:
if isinstance(val, list):
nxt.extend(val)
elif val is not None:
nxt.append(val)
current = nxt
return [v for v in current if isinstance(v, str) and v]
def remap_refs(entity: dict[str, Any], field_path: str, id_map: dict[str, str]) -> int:
"""Rewrite referenced ids under ``field_path`` *in place*, using ``id_map``.
The write-twin of :func:`extract_refs`: it walks the same dot/list/bindable
grammar and replaces any leaf id present in ``id_map`` with its mapped value.
Ids absent from ``id_map`` (references to entities outside the remap set) are
left untouched, so a clone keeps sharing its un-cloned dependencies. Unbound
bindables (a plain number where an object was expected) and missing keys are
tolerated. Returns the number of ids rewritten.
"""
segments = field_path.split(".")
# Descend to the container(s) that hold the final key.
parents: list[Any] = [entity]
for segment in segments[:-1]:
is_list = segment.endswith("[]")
key = segment[:-2] if is_list else segment
nxt: list[Any] = []
for obj in parents:
if not isinstance(obj, dict):
continue
val = obj.get(key)
if is_list:
if isinstance(val, list):
nxt.extend(val)
elif isinstance(val, dict):
nxt.append(val)
parents = nxt
last = segments[-1]
last_is_list = last.endswith("[]")
key = last[:-2] if last_is_list else last
count = 0
for obj in parents:
if not isinstance(obj, dict):
continue
val = obj.get(key)
if last_is_list:
if isinstance(val, list):
for i, item in enumerate(val):
if isinstance(item, str) and item in id_map:
val[i] = id_map[item]
count += 1
elif isinstance(val, str) and val in id_map:
obj[key] = id_map[val]
count += 1
return count
def serialize_entity(model: Any) -> dict[str, Any]:
"""Best-effort serialize a storage model to a plain dict for graph use.
Prefers ``dataclasses.asdict`` (pure structural, recurses bindables/lists,
invokes no managers), falling back to ``to_dict()`` then ``{}``.
"""
if is_dataclass(model) and not isinstance(model, type):
try:
return asdict(model)
except Exception as exc: # noqa: BLE001 — defensive: never let one model break the graph
logger.debug("graph: asdict failed for %r: %s", type(model).__name__, exc)
to_dict = getattr(model, "to_dict", None)
if callable(to_dict):
try:
result = to_dict()
if isinstance(result, dict):
return result
except Exception as exc: # noqa: BLE001
logger.debug("graph: to_dict failed for %r: %s", type(model).__name__, exc)
logger.warning(
"graph: could not serialize model %r; excluding from graph", type(model).__name__
)
return {}
def graph_field_roots(kind: str) -> set[str]:
"""Top-level keys the graph needs for ``kind``: ``id``/``name``, the subtype
field, and the root segment of every reference path for that kind."""
roots: set[str] = {"id", "name"}
type_field = NODE_TYPE_FIELD.get(kind, "")
if type_field:
roots.add(type_field)
for cf in CONNECTION_SCHEMA:
if cf.target_kind == kind:
roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
return roots
def serialize_entity_for_graph(kind: str, model: Any) -> dict[str, Any]:
"""Serialize a model and project it to ONLY the keys the graph needs.
This projection is a **security boundary**: a full ``asdict``/``to_dict``
can carry secrets (webhook tokens, device/HA/MQTT credentials), so every
field except ``id``/``name``, the subtype field and reference-path roots is
dropped before the data reaches the graph API.
"""
full = serialize_entity(model)
roots = graph_field_roots(kind)
return {k: v for k, v in full.items() if k in roots}
# ── Topology / validation ───────────────────────────────────────────────────
def _node_from(kind: str, entity: dict[str, Any]) -> dict[str, Any] | None:
eid = entity.get("id")
if not isinstance(eid, str) or not eid:
return None
type_field = NODE_TYPE_FIELD.get(kind, "")
subtype = entity.get(type_field, "") if type_field else ""
return {
"id": eid,
"kind": kind,
"name": entity.get("name") or eid,
"type": subtype if isinstance(subtype, str) else "",
}
def build_topology(entities_by_kind: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
"""Build the full wiring graph + a validation report.
Args:
entities_by_kind: ``{kind: [serialized_entity_dict, ...]}``.
Returns a dict with ``nodes``, ``edges`` and ``issues`` (``orphans``,
``broken_refs``, ``cycles``).
"""
nodes: list[dict[str, Any]] = []
node_ids: set[str] = set()
for kind in ENTITY_KINDS:
for entity in entities_by_kind.get(kind, []):
node = _node_from(kind, entity)
if node and node["id"] not in node_ids:
node_ids.add(node["id"])
nodes.append(node)
edges: list[dict[str, Any]] = []
broken_refs: list[dict[str, str]] = []
for cf in CONNECTION_SCHEMA:
for entity in entities_by_kind.get(cf.target_kind, []):
referrer = entity.get("id")
if not isinstance(referrer, str) or not referrer:
continue
for ref in extract_refs(entity, cf.field):
if ref not in node_ids:
broken_refs.append({"ref": ref, "by": referrer, "field": cf.field})
continue
edges.append(
{
"from": ref,
"to": referrer,
"field": cf.field,
"edge_type": cf.edge_type,
"nested": cf.nested,
}
)
connected: set[str] = set()
for e in edges:
connected.add(e["from"])
connected.add(e["to"])
orphans = sorted(nid for nid in node_ids if nid not in connected)
cycles = sorted(detect_cycles(edges))
return {
"nodes": nodes,
"edges": edges,
"issues": {
"orphans": orphans,
"broken_refs": broken_refs,
"cycles": cycles,
},
}
def find_dependents(
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
) -> list[dict[str, str]]:
"""Return every entity that references ``(kind, entity_id)``.
``kind`` is the kind of the *referenced* entity; matching schema entries are
those whose ``source_kind == kind``.
"""
name_by_id: dict[str, str] = {}
for k in ENTITY_KINDS:
for entity in entities_by_kind.get(k, []):
eid = entity.get("id")
if isinstance(eid, str):
name_by_id[eid] = entity.get("name") or eid
dependents: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for cf in CONNECTION_SCHEMA:
if cf.source_kind != kind:
continue
for entity in entities_by_kind.get(cf.target_kind, []):
referrer = entity.get("id")
if not isinstance(referrer, str):
continue
if entity_id in extract_refs(entity, cf.field):
key = (referrer, cf.field)
if key in seen:
continue
seen.add(key)
dependents.append(
{
"id": referrer,
"kind": cf.target_kind,
"name": name_by_id.get(referrer, referrer),
"field": cf.field,
}
)
return dependents
def detect_cycles(edges: list[dict[str, Any]]) -> set[str]:
"""Return every node id that participates in a directed cycle (from→to)."""
adj: dict[str, list[str]] = {}
for e in edges:
adj.setdefault(e["from"], []).append(e["to"])
WHITE, GRAY, BLACK = 0, 1, 2
color: dict[str, int] = {}
in_cycle: set[str] = set()
for start in list(adj.keys()):
if color.get(start, WHITE) != WHITE:
continue
stack: list[tuple[str, int]] = [(start, 0)]
path: list[str] = [start]
color[start] = GRAY
while stack:
node, idx = stack[-1]
neighbors = adj.get(node, [])
if idx < len(neighbors):
stack[-1] = (node, idx + 1)
nxt = neighbors[idx]
c = color.get(nxt, WHITE)
if c == GRAY:
if nxt in path:
i = path.index(nxt)
in_cycle.update(path[i:])
elif c == WHITE:
color[nxt] = GRAY
path.append(nxt)
stack.append((nxt, 0))
else:
color[node] = BLACK
if path and path[-1] == node:
path.pop()
stack.pop()
return in_cycle
def _reachable(edges: list[dict[str, Any]], start: str, goal: str) -> bool:
"""True if ``goal`` is reachable from ``start`` following from→to edges."""
if start == goal:
return True
adj: dict[str, list[str]] = {}
for e in edges:
adj.setdefault(e["from"], []).append(e["to"])
seen = {start}
queue = [start]
while queue:
cur = queue.pop()
for nxt in adj.get(cur, []):
if nxt == goal:
return True
if nxt not in seen:
seen.add(nxt)
queue.append(nxt)
return False
def would_create_cycle(edges: list[dict[str, Any]], source_id: str, target_id: str) -> bool:
"""Would wiring ``source_id`` into ``target_id`` (edge source→target) loop?
A cycle forms if ``source_id`` is already reachable from ``target_id`` via
the existing data-flow edges (so the new edge would close the loop), or the
two are the same node.
"""
if source_id == target_id:
return True
return _reachable(edges, target_id, source_id)
def _entity_exists(
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
) -> bool:
return any(e.get("id") == entity_id for e in entities_by_kind.get(kind, []))
def validate_connection(
entities_by_kind: dict[str, list[dict[str, Any]]],
target_kind: str,
target_id: str,
field: str,
source_id: str,
) -> tuple[bool, str | None]:
"""Validate a proposed wiring edit before it is persisted.
Checks, in order: the field is a known connectable reference; the target
exists; (when not detaching) the source exists and is of the registry's
expected kind; and the edit would not create a dependency cycle. Returns
``(ok, error_message)``. Detaching (empty ``source_id``) is always allowed.
"""
cf = next(
(c for c in CONNECTION_SCHEMA if c.target_kind == target_kind and c.field == field),
None,
)
if cf is None:
return False, f"Unknown connection field: {target_kind}.{field}"
if not is_editable(cf):
# List slots (need an element index), double-nested fields, and dead
# colour bindings can't be wired from the graph — edit via the entity
# editor instead.
return False, f"Field '{field}' is not editable via the graph"
if not _entity_exists(entities_by_kind, target_kind, target_id):
return False, f"Target entity not found: {target_id}"
if not source_id:
return True, None # detaching a slot is always valid
if not _entity_exists(entities_by_kind, cf.source_kind, source_id):
return False, f"Source {cf.source_kind} not found: {source_id}"
# Cycle check: ignore the edge currently occupying this slot, since the
# write replaces it.
topo = build_topology(entities_by_kind)
edges = [e for e in topo["edges"] if not (e["to"] == target_id and e["field"] == field)]
if would_create_cycle(edges, source_id, target_id):
return False, "Connection would create a dependency cycle"
return True, None
@@ -0,0 +1,25 @@
"""Shared MQTT-source validation for route handlers.
Both the device routes and the output-target routes accept an
``mqtt_source_id`` that must reference an existing ``MQTTSource``. This module
is the single source of truth for that check so the two callers cannot drift.
"""
from fastapi import HTTPException
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
def validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str | None) -> None:
"""Ensure a referenced MQTT source exists.
Empty / ``None`` is allowed (unconfigured = "first available broker").
Raises ``HTTPException(422)`` if a non-empty id does not resolve.
"""
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")
@@ -0,0 +1,468 @@
"""Activity-log REST API — query / filter / export / settings / clear.
Endpoints
---------
GET /api/v1/activity-log List (filterable, keyset-paginated)
GET /api/v1/activity-log/export Streaming CSV or JSON export
GET /api/v1/activity-log/settings Retention settings
PUT /api/v1/activity-log/settings Update retention settings (requires non-anonymous auth)
DELETE /api/v1/activity-log Clear all entries (requires non-anonymous auth)
Auth posture
------------
- List + read settings (``GET``): ``AuthRequired`` (loopback-anonymous is fine).
- Export, update settings (``PUT``), and clear: ``require_authenticated()``
(loopback-anonymous is rejected; mirrors the backup download / secret-reveal
pattern from ``backup.py``). Updating settings can disable auditing or prune
the trail, so it is gated like the destructive clear.
CSV injection
-------------
Cells that begin with =, +, -, @, TAB, or CR can trigger formula execution in
spreadsheet apps (OWASP Formula Injection). ``_csv_safe`` prefixes any such cell
with a single quote so formulas are inert. Fields already go through
``sanitize_display`` in Phase 3 instrumentation, but the CSV writer applies its
own guard as defence-in-depth.
Export generator + lock
-----------------------
``repo.iter_export()`` fetches rows in bounded batches, holding the DB ``_lock``
only around each batch fetch and releasing it before yielding so a slow or
stalled client never blocks other DB operations. The ``StreamingResponse``
generator is wrapped in a ``try/finally`` block so the batch generator is closed
even when the client disconnects mid-stream.
"""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from typing import Annotated, Iterator
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from ledgrab.api.auth import AuthRequired, require_authenticated
from ledgrab.api.dependencies import (
get_activity_log_repo,
get_activity_log_retention_engine,
get_activity_recorder,
)
from ledgrab.api.schemas.activity_log import (
ActivityLogPageResponse,
ActivityLogSettingsResponse,
UpdateActivityLogSettingsRequest,
)
from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogFilters, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
router = APIRouter(prefix="/api/v1/activity-log", tags=["Activity Log"])
# Hard cap on the per-request limit to prevent runaway queries.
_MAX_LIMIT = 200
_DEFAULT_LIMIT = 50
# Bounds on the text filter params so a multi-KB ``q`` / actor / entity filter
# can't enlarge the LIKE pattern and bound params per page (FastAPI returns 422
# on overflow). The free-text ``q`` gets a larger budget than the id filters.
_MAX_TEXT_FILTER = 256
_MAX_ID_FILTER = 128
# CSV export columns (matches entry_to_dict key order)
_CSV_COLUMNS = [
"id",
"ts",
"category",
"action",
"severity",
"actor",
"entity_type",
"entity_id",
"entity_name",
"message",
"metadata",
]
# Characters that trigger formula injection in spreadsheet apps (OWASP).
# Leading TAB and CR are also recognised triggers by Excel / Google Sheets.
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
# Cap for export-cell sanitization. Effectively no truncation (a single audit
# field never approaches this) — we reuse sanitize_display only to strip
# NUL/control/ANSI from CSV cells, not to shorten them.
_EXPORT_CELL_MAXLEN = 1_000_000
def _csv_safe(value: str) -> str:
"""Prefix formula-injection triggers with a literal single-quote.
A cell starting with =, +, -, or @ can execute as a formula in Excel /
Google Sheets. OWASP recommends prepending a single quote to neutralise it.
"""
if value and value[0] in _FORMULA_PREFIXES:
return "'" + value
return value
def _redact_for_anon(entry_dict: dict, auth_label: str) -> dict:
"""Redact the source-IP metadata for anonymous (loopback) callers.
The streaming export is gated by ``require_authenticated`` precisely because
the log can contain client IPs (e.g. ``auth.rejected`` / ``auth.ws_connected``
store ``metadata.client``). The list endpoint allows loopback-anonymous
callers, so to keep the posture consistent we mask that one field for the
``"anonymous"`` label rather than handing it back what export withholds.
"""
if auth_label != "anonymous":
return entry_dict
meta = entry_dict.get("metadata")
if isinstance(meta, dict) and "client" in meta:
return {**entry_dict, "metadata": {**meta, "client": "[redacted]"}}
return entry_dict
def _build_filters(
categories: list[str] | None,
severities: list[str] | None,
actor: str | None,
entity_type: str | None,
entity_id: str | None,
since: datetime | None,
until: datetime | None,
q: str | None,
) -> ActivityLogFilters:
"""Assemble an ``ActivityLogFilters`` dataclass from query parameters."""
return ActivityLogFilters(
categories=categories or None,
severities=severities or None,
actor=actor or None,
entity_type=entity_type or None,
entity_id=entity_id or None,
since=since,
until=until,
message_like=q or None,
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log — list
# ---------------------------------------------------------------------------
@router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries")
def list_activity_log(
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Filters ────────────────────────────────────────────────────────────
categories: Annotated[
list[str] | None,
Query(
description=(
"Filter by category (repeatable or comma-separated). "
"Values: auth, device, entity, capture, system"
)
),
] = None,
severities: Annotated[
list[str] | None,
Query(description="Filter by severity (repeatable). Values: info, warning, error"),
] = None,
actor: Annotated[
str | None,
Query(max_length=_MAX_ID_FILTER, description="Filter by actor label (exact match)"),
] = None,
entity_type: Annotated[
str | None,
Query(max_length=_MAX_ID_FILTER, description="Filter by entity type (exact match)"),
] = None,
entity_id: Annotated[
str | None,
Query(max_length=_MAX_ID_FILTER, description="Filter by entity id (exact match)"),
] = None,
since: Annotated[
datetime | None,
Query(description="Return entries at or after this ISO-8601 datetime"),
] = None,
until: Annotated[
datetime | None,
Query(description="Return entries at or before this ISO-8601 datetime"),
] = None,
q: Annotated[
str | None,
Query(
max_length=_MAX_TEXT_FILTER,
description="Free-text search in the message field (substring)",
),
] = None,
# ── Pagination ─────────────────────────────────────────────────────────
before_seq: Annotated[
int | None,
Query(
description=(
"Keyset cursor: pass the 'next_before_seq' from the previous page "
"to get the following (older) page. Omit for the first (newest) page."
)
),
] = None,
limit: Annotated[
int,
Query(
ge=1,
le=_MAX_LIMIT,
description=f"Max entries per page (default {_DEFAULT_LIMIT}, max {_MAX_LIMIT})",
),
] = _DEFAULT_LIMIT,
) -> ActivityLogPageResponse:
"""Return the newest matching entries, oldest-first within the page.
Keyset pagination: the response includes ``next_before_seq`` pass it
as ``before_seq`` in the next request to get the next (older) page.
The ``total`` field is the count of all entries matching the current
filters across all pages.
"""
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
# Fetch limit+1 rows to detect whether an older page exists.
#
# query() fetches DESC internally (newest-first) then reverses to ascending.
# With limit+1, the result is ascending: [oldest_probe, ..., newest].
# When we got exactly limit+1 rows, has_more is True and the probe row
# (index 0 — the oldest) is the extra one. We keep the newest `limit` rows
# by slicing [1:], which is the actual page content for the client.
# When we got <= limit rows, this is the last page and all rows are included.
effective_limit = min(limit, _MAX_LIMIT)
# query_with_seq returns (seq, entry) ascending (oldest-first within page),
# so the seq is already in hand — no extra get_seq_for_id round-trip.
rows_plus = repo.query_with_seq(filters, before_seq=before_seq, limit=effective_limit + 1)
has_more = len(rows_plus) > effective_limit
# When over-fetched, drop the oldest probe row (index 0) and keep the newest.
rows = rows_plus[1:] if has_more else rows_plus
total = repo.count(filters)
# next_before_seq: the seq of the oldest entry on this page (rows[0]).
# The next request passes before_seq=X to get entries with seq < X.
next_before_seq: int | None = rows[0][0] if (has_more and rows) else None
return ActivityLogPageResponse(
entries=[_redact_for_anon(entry_to_dict(e), auth) for _seq, e in rows], # type: ignore[arg-type]
next_before_seq=next_before_seq,
has_more=has_more,
total=total,
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log/export — streaming export (CSV or JSON)
# ---------------------------------------------------------------------------
def _export_csv_generator(
repo: ActivityLogRepository,
filters: ActivityLogFilters,
) -> Iterator[bytes]:
"""Yield UTF-8-encoded CSV chunks one row at a time.
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
lock is released even on early client disconnect (which triggers
``GeneratorExit``).
"""
gen = repo.iter_export(filters)
try:
# Header
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(_CSV_COLUMNS)
yield buf.getvalue().encode("utf-8")
for entry in gen:
d = entry_to_dict(entry)
row = []
for col in _CSV_COLUMNS:
if col == "metadata":
# json.dumps escapes control chars (<0x20) as \uXXXX, so the
# metadata cell can't carry raw NUL/CR/ANSI into the file.
cell = json.dumps(d.get(col) or {})
else:
# Defense-in-depth: strip NUL/control/ANSI from string cells
# at the export boundary so a (current or future) un-sanitized
# call site can't leak control chars into the CSV. csv.writer
# quotes embedded newlines but does not strip control chars.
cell = sanitize_display(str(d.get(col, "") or ""), maxlen=_EXPORT_CELL_MAXLEN)
row.append(_csv_safe(cell))
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(row)
yield buf.getvalue().encode("utf-8")
finally:
gen.close()
def _export_json_generator(
repo: ActivityLogRepository,
filters: ActivityLogFilters,
) -> Iterator[bytes]:
"""Yield a streamed JSON array, one entry per chunk.
Format: ``[\\n{entry},\\n{entry},\\n...]\\n``
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
lock is released even on early client disconnect.
"""
gen = repo.iter_export(filters)
try:
first = True
yield b"[\n"
for entry in gen:
d = entry_to_dict(entry)
chunk = json.dumps(d, ensure_ascii=False, default=str)
if first:
yield chunk.encode("utf-8")
first = False
else:
yield b",\n" + chunk.encode("utf-8")
yield b"\n]\n"
finally:
gen.close()
@router.get("/export", summary="Export activity-log entries (streaming CSV or JSON)")
def export_activity_log(
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Format ────────────────────────────────────────────────────────────
format: Annotated[
str,
Query(description="Export format: 'csv' or 'json'"),
] = "csv",
# ── Same filters as list ───────────────────────────────────────────────
categories: Annotated[list[str] | None, Query()] = None,
severities: Annotated[list[str] | None, Query()] = None,
actor: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
entity_type: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
entity_id: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
since: Annotated[datetime | None, Query()] = None,
until: Annotated[datetime | None, Query()] = None,
q: Annotated[str | None, Query(max_length=_MAX_TEXT_FILTER)] = None,
) -> StreamingResponse:
"""Stream all matching entries as CSV or JSON.
Requires a non-anonymous API key (loopback-anonymous access is rejected
because the log may contain IP addresses and entity names).
"""
require_authenticated(auth)
if format not in ("csv", "json"):
from fastapi import HTTPException
raise HTTPException(
status_code=422,
detail="'format' must be 'csv' or 'json'",
)
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
if format == "csv":
filename = f"activity-log-{timestamp}.csv"
media_type = "text/csv; charset=utf-8"
generator = _export_csv_generator(repo, filters)
else:
filename = f"activity-log-{timestamp}.json"
media_type = "application/json"
generator = _export_json_generator(repo, filters)
return StreamingResponse(
generator,
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log/settings
# PUT /api/v1/activity-log/settings
# ---------------------------------------------------------------------------
@router.get(
"/settings",
response_model=ActivityLogSettingsResponse,
summary="Get activity-log retention settings",
)
def get_activity_log_settings(
_: AuthRequired,
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
) -> ActivityLogSettingsResponse:
"""Return the current activity-log retention settings."""
return ActivityLogSettingsResponse(**engine.get_settings())
@router.put(
"/settings",
response_model=ActivityLogSettingsResponse,
summary="Update activity-log retention settings",
)
async def update_activity_log_settings(
auth: AuthRequired,
body: UpdateActivityLogSettingsRequest,
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
) -> ActivityLogSettingsResponse:
"""Update the activity-log retention settings (applied immediately).
Requires a non-anonymous API key (loopback-anonymous access is rejected)
because disabling the log or pruning retention is equivalent in impact to
clearing the audit trail.
Setting ``enabled=false`` records an audit entry BEFORE the flag takes
effect so the last entry in the log shows who disabled recording.
"""
require_authenticated(auth)
result = await engine.update_settings(
enabled=body.enabled,
max_days=body.max_days,
max_entries=body.max_entries,
)
return ActivityLogSettingsResponse(**result)
# ---------------------------------------------------------------------------
# DELETE /api/v1/activity-log — clear
# ---------------------------------------------------------------------------
@router.delete("", summary="Clear all activity-log entries")
def clear_activity_log(
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
recorder: ActivityRecorder = Depends(get_activity_recorder),
) -> dict:
"""Delete all activity-log entries.
Requires a non-anonymous API key (loopback-anonymous access is rejected).
The clear operation itself is audited a ``system/activity_log_cleared``
entry is recorded AFTER the wipe, so the log shows who cleared it and how
many rows were removed.
Returns ``{"deleted": <count>}``.
"""
require_authenticated(auth)
deleted = repo.clear()
# Record the clear action (best-effort — recorder never raises).
recorder.record(
category=ActivityCategory.SYSTEM,
action="activity_log.cleared",
severity=ActivitySeverity.INFO,
actor=auth,
message=f"Activity log cleared ({deleted} entries removed)",
metadata={"deleted_count": deleted},
)
return {"deleted": deleted}
@@ -182,6 +182,12 @@ async def delete_audio_source(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete an audio source."""
_entity_name: str | None = None
try:
_entity_name = store.get_source(source_id).name
except Exception:
pass
try:
# Check if any CSS entities reference this audio source
from ledgrab.storage.color_strip_source import AudioColorStripSource
@@ -194,7 +200,7 @@ async def delete_audio_source(
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
fire_entity_event("audio_source", "deleted", source_id, entity_name=_entity_name)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
+113 -1
View File
@@ -12,28 +12,35 @@ from ledgrab.api.dependencies import (
get_scene_preset_store,
)
from ledgrab.api.schemas.automations import (
ActionSchema,
AutomationCreate,
AutomationListResponse,
AutomationResponse,
AutomationTriggerResponse,
AutomationUpdate,
RuleSchema,
)
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.storage.automation import (
Action,
ApplicationRule,
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
ManualTriggerRule,
MQTTRule,
Rule,
SolarRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
WebhookAction,
WebhookRule,
)
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger
from ledgrab.utils.safe_source import validate_polling_url
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -52,6 +59,22 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
"time_of_day": lambda: TimeOfDayRule(
start_time=s.start_time or "00:00",
end_time=s.end_time or "23:59",
days_of_week=s.days_of_week or [],
timezone=s.timezone or "",
),
# SolarRule.from_dict validates events, clamps offsets/coords, and
# filters weekdays — route the raw schema values through it.
"solar": lambda: SolarRule.from_dict(
{
"start_event": s.start_event,
"start_offset_minutes": s.start_offset_minutes,
"end_event": s.end_event,
"end_offset_minutes": s.end_offset_minutes,
"latitude": s.latitude,
"longitude": s.longitude,
"days_of_week": s.days_of_week or [],
"timezone": s.timezone or "",
}
),
"system_idle": lambda: SystemIdleRule(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
@@ -70,6 +93,7 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
token=s.token or secrets.token_hex(16),
),
"startup": lambda: StartupRule(),
"manual_trigger": lambda: ManualTriggerRule(),
"home_assistant": lambda: HomeAssistantRule(
ha_source_id=s.ha_source_id or "",
entity_id=s.entity_id or "",
@@ -93,6 +117,43 @@ def _rule_to_schema(r: Rule) -> RuleSchema:
return RuleSchema(**d)
def _action_from_schema(s: ActionSchema) -> Action:
"""Build a domain Action from its request schema, validating the webhook URL.
The SSRF gate runs here (save time) AND again at fire time, closing the
DNS-rebinding window. A bad/blocked URL rejects the whole save with 400.
"""
if s.action_type != "webhook":
raise ValueError(f"Unknown action type: {s.action_type}")
url = (s.webhook_url or "").strip()
if not url:
raise ValueError("webhook action requires a webhook_url")
method = (s.method or "POST").upper()
if method not in ("POST", "PUT", "GET"):
raise ValueError(f"Invalid webhook method: {method}. Must be POST, PUT or GET.")
fire_on = s.fire_on or "activate"
if fire_on not in ("activate", "deactivate", "both"):
raise ValueError(f"Invalid fire_on: {fire_on}. Must be activate, deactivate or both.")
# content_type is emitted verbatim as the outbound Content-Type header — reject
# control chars (CR/LF) so it can't be used to inject additional HTTP headers.
content_type = (s.content_type or "application/json").strip()
if len(content_type) > 128 or any(ord(c) < 0x20 or ord(c) > 0x7E for c in content_type):
raise ValueError("Invalid content_type: control or non-ASCII characters are not allowed.")
# Raises HTTPException(400) on a blocked/loopback/metadata target.
validate_polling_url(url)
return WebhookAction(
webhook_url=url,
method=method,
body_template=s.body_template or "",
content_type=content_type,
fire_on=fire_on,
)
def _action_to_schema(a: Action) -> ActionSchema:
return ActionSchema(**a.to_dict())
def _automation_to_response(
automation, engine: AutomationEngine, request: Request = None
) -> AutomationResponse:
@@ -128,6 +189,7 @@ def _automation_to_response(
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
tags=automation.tags,
actions=[_action_to_schema(a) for a in getattr(automation, "actions", [])],
icon=getattr(automation, "icon", "") or "",
icon_color=getattr(automation, "icon_color", "") or "",
created_at=automation.created_at,
@@ -184,6 +246,7 @@ async def create_automation(
try:
rules = [_rule_from_schema(r) for r in data.rules]
actions = [_action_from_schema(a) for a in data.actions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -199,6 +262,7 @@ async def create_automation(
deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags,
actions=actions,
icon=data.icon,
icon_color=data.icon_color,
)
@@ -281,6 +345,13 @@ async def update_automation(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
actions = None
if data.actions is not None:
try:
actions = [_action_from_schema(a) for a in data.actions]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
try:
# If disabling, deactivate first
if data.enabled is False:
@@ -295,6 +366,7 @@ async def update_automation(
rules=rules,
deactivation_mode=data.deactivation_mode,
tags=data.tags,
actions=actions,
icon=data.icon,
icon_color=data.icon_color,
)
@@ -327,6 +399,12 @@ async def delete_automation(
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Delete an automation."""
_entity_name: str | None = None
try:
_entity_name = store.get_automation(automation_id).name
except Exception:
pass
# Deactivate first
await engine.deactivate_if_active(automation_id)
@@ -335,7 +413,7 @@ async def delete_automation(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("automation", "deleted", automation_id)
fire_entity_event("automation", "deleted", automation_id, entity_name=_entity_name)
# ===== Enable/Disable =====
@@ -386,3 +464,37 @@ async def disable_automation(
raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine, request)
# ===== Manual trigger =====
@router.post(
"/api/v1/automations/{automation_id}/trigger",
response_model=AutomationTriggerResponse,
tags=["Automations"],
)
async def trigger_automation(
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Manually fire an automation.
Evaluates the automation's rules with its manual trigger satisfied — so it
"still checks all of the rules" under the automation's ``rule_logic`` — and,
if it should activate, applies its scene once. Independent of the ``enabled``
flag (that gates only the background evaluation loop).
"""
try:
automation = store.get_automation(automation_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
try:
status, errors = await engine.fire_manual_trigger(automation)
except Exception as e: # noqa: BLE001 — surface a structured error, never a bare 500
logger.error("Manual trigger failed for automation %s: %s", automation_id, e)
return AutomationTriggerResponse(status="error", errors=[str(e)])
return AutomationTriggerResponse(status=status, errors=errors)
+78 -17
View File
@@ -11,6 +11,7 @@ import sys
import threading
import zipfile
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
@@ -26,6 +27,7 @@ from ledgrab.api.schemas.system import (
)
from ledgrab.config import get_config
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.database import Database, freeze_writes
from ledgrab.utils import get_logger, read_upload_capped
@@ -34,32 +36,79 @@ logger = get_logger(__name__)
router = APIRouter()
def _record_system(action: str, message: str, metadata: dict | None = None) -> None:
"""Best-effort audit record for a system-level event."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
message=message,
metadata=metadata or {},
)
_SERVER_DIR = Path(__file__).resolve().parents[4]
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
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":
subprocess.Popen(
[
"powershell",
"-ExecutionPolicy",
"Bypass",
"-File",
str(_SERVER_DIR / "restart.ps1"),
],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
script = _SERVER_DIR / "restart.ps1"
cmd = ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(script)]
popen_kwargs = {
"creationflags": (
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
),
}
else:
subprocess.Popen(
["bash", str(_SERVER_DIR / "restart.sh")],
start_new_session=True,
)
script = _SERVER_DIR / "restart.sh"
cmd = ["bash", str(script)]
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()
@@ -111,6 +160,8 @@ def backup_config(
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.zip"
_record_system("backup.created", f"Backup downloaded: {filename}", {"filename": filename})
return StreamingResponse(
zip_buffer,
media_type="application/zip",
@@ -211,6 +262,7 @@ async def restore_config(
freeze_writes()
logger.info("Database restored from uploaded backup. Scheduling restart...")
_record_system("backup.restored", "Database restored from uploaded backup")
_schedule_restart()
return RestoreResponse(
@@ -225,6 +277,7 @@ def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
from ledgrab.server_ref import _broadcast_restarting
_record_system("server.restarting", "Server restart requested by user")
_broadcast_restarting()
_schedule_restart()
return {"status": "restarting"}
@@ -235,6 +288,7 @@ def shutdown_server(_: AuthRequired):
"""Gracefully shut down the server."""
from ledgrab.server_ref import request_shutdown
_record_system("server.shutdown_requested", "Server shutdown requested by user")
request_shutdown()
return {"status": "shutting_down"}
@@ -268,11 +322,17 @@ async def update_auto_backup_settings(
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings(
result = await engine.update_settings(
enabled=body.enabled,
interval_hours=body.interval_hours,
max_backups=body.max_backups,
)
_record_system(
"settings.changed",
f"Auto-backup settings updated (enabled={body.enabled})",
{"setting_key": "auto_backup", "enabled": body.enabled},
)
return result
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
@@ -333,4 +393,5 @@ async def delete_saved_backup(
engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
_record_system("backup.deleted", f"Saved backup deleted: {filename}", {"filename": filename})
return {"status": "deleted", "filename": filename}
@@ -0,0 +1,272 @@
"""Calibration session and solver API routes.
Endpoints
---------
POST /api/v1/calibration/session
Start a calibration session on a device (stops any running target on that
device and remembers it for restore on stop).
POST /api/v1/calibration/session/position
Advance the chase pixel to a specific LED index on the active device.
POST /api/v1/calibration/session/stop
End the session: clear the device to black and restore the prior target.
POST /api/v1/calibration/session/cancel
Alias for stop (does not apply any solved calibration).
GET /api/v1/calibration/session/state
Return the current session state (active, device, last_activity, ).
POST /api/v1/calibration/solve
Pure-logic: solve a CalibrationConfig from 4 corner tap indices.
Does NOT persist the caller must follow up with
``PUT /api/v1/color-strip-sources/{id}`` to persist.
Persist path
------------
The existing ``PUT /api/v1/color-strip-sources/{id}`` already accepts a
``calibration`` field on ``PictureCSSUpdate`` / ``PictureAdvancedCSSUpdate``
and hot-reloads running streams automatically (see
``api/routes/color_strip_sources/crud.py``). There is NO duplicate endpoint
here. Phase 3 UI calls the existing PUT to persist.
"""
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_processor_manager
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.api.schemas.calibration import (
CalibrationSessionPositionRequest,
CalibrationSessionStartRequest,
CalibrationSessionStateResponse,
CalibrationSolveRequest,
CalibrationSolvedResponse,
)
from ledgrab.core.capture.calibration import solve_calibration
from ledgrab.core.capture.calibration_session import get_calibration_session
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ── Session endpoints ─────────────────────────────────────────────────────────
@router.post(
"/api/v1/calibration/session",
response_model=CalibrationSessionStateResponse,
tags=["Calibration"],
status_code=201,
)
async def start_calibration_session(
body: CalibrationSessionStartRequest,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
) -> CalibrationSessionStateResponse:
"""Start a calibration session on a device.
Stops any target currently processing on that device (it will be restored
when the session ends). Only one session can be active at a time; starting
a new one terminates the previous one first.
"""
session = get_calibration_session()
try:
await session.start(body.device_id, manager)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
logger.error("Failed to start calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.started",
severity=ActivitySeverity.INFO,
entity_type="device",
entity_id=body.device_id,
message=f"Calibration session started for device '{body.device_id}'",
)
return CalibrationSessionStateResponse(**session.get_state())
@router.post(
"/api/v1/calibration/session/position",
response_model=CalibrationSessionStateResponse,
tags=["Calibration"],
)
async def calibration_session_position(
body: CalibrationSessionPositionRequest,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
) -> CalibrationSessionStateResponse:
"""Advance the chase pixel to a specific LED index on the active device.
``index`` must be 0-based and < ``led_count``. Returns 422 when out of
range (Pydantic ``ge=0``) or 400 if the session is not active / index
exceeds led_count.
"""
session = get_calibration_session()
try:
await session.position(body.index, body.window)
except RuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
logger.error("Failed to set calibration pixel index=%d: %s", body.index, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
return CalibrationSessionStateResponse(**session.get_state())
@router.post(
"/api/v1/calibration/session/stop",
response_model=CalibrationSessionStateResponse,
tags=["Calibration"],
)
async def stop_calibration_session(
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
) -> CalibrationSessionStateResponse:
"""End the calibration session.
Clears the device to black and restores the previously-running target (if
any). Safe to call even when no session is active (returns inactive state).
"""
session = get_calibration_session()
try:
await session.stop()
except Exception as exc:
logger.error("Failed to stop calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.stopped",
severity=ActivitySeverity.INFO,
message="Calibration session stopped",
)
return CalibrationSessionStateResponse(**session.get_state())
@router.post(
"/api/v1/calibration/session/cancel",
response_model=CalibrationSessionStateResponse,
tags=["Calibration"],
)
async def cancel_calibration_session(
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
) -> CalibrationSessionStateResponse:
"""Cancel the calibration session (alias for stop — no calibration is applied)."""
session = get_calibration_session()
try:
await session.cancel()
except Exception as exc:
logger.error("Failed to cancel calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.cancelled",
severity=ActivitySeverity.INFO,
message="Calibration session cancelled",
)
return CalibrationSessionStateResponse(**session.get_state())
@router.get(
"/api/v1/calibration/session/state",
response_model=CalibrationSessionStateResponse,
tags=["Calibration"],
)
async def get_calibration_session_state(
_auth: AuthRequired,
) -> CalibrationSessionStateResponse:
"""Return the current calibration session state."""
return CalibrationSessionStateResponse(**get_calibration_session().get_state())
# ── Solver endpoint ───────────────────────────────────────────────────────────
@router.post(
"/api/v1/calibration/solve",
response_model=CalibrationSolvedResponse,
tags=["Calibration"],
)
async def solve_calibration_endpoint(
body: CalibrationSolveRequest,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
) -> CalibrationSolvedResponse:
"""Solve a CalibrationConfig from 4 corner tap indices.
Returns the computed per-edge LED counts. Does NOT persist call
``PUT /api/v1/color-strip-sources/{id}`` with ``calibration`` in the body
to save.
Provide either *device_id* (preferred, server derives led_count) or
*led_count* directly. Returns 404 if *device_id* is not found, 422 on
invalid enum values, 400 on logical errors (e.g. corner_indices length).
"""
# Resolve led_count
led_count = body.led_count
if body.device_id is not None:
if body.device_id not in manager._devices:
raise HTTPException(
status_code=404,
detail=f"Device {body.device_id!r} not found",
)
ds = manager._devices[body.device_id]
led_count = ds.led_count
if led_count is None or led_count <= 0:
raise HTTPException(
status_code=400,
detail="led_count must be a positive integer",
)
try:
cfg = solve_calibration(
led_count=led_count,
start_position=body.start_position,
layout=body.layout,
corner_indices=body.corner_indices,
offset=body.offset,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
logger.error("Failed to solve calibration: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
return CalibrationSolvedResponse(
mode="simple",
layout=cfg.layout,
start_position=cfg.start_position,
leds_top=cfg.leds_top,
leds_right=cfg.leds_right,
leds_bottom=cfg.leds_bottom,
leds_left=cfg.leds_left,
offset=cfg.offset,
)
@@ -167,6 +167,12 @@ async def delete_color_strip_source(
target_store: OutputTargetStore = Depends(get_output_target_store),
):
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
_entity_name: str | None = None
try:
_entity_name = store.get_source(source_id).name
except Exception:
pass
try:
target_names = target_store.get_targets_referencing_css(source_id)
if target_names:
@@ -201,7 +207,7 @@ async def delete_color_strip_source(
"Delete or reassign the processed source(s) first.",
)
store.delete_source(source_id)
fire_entity_event("color_strip_source", "deleted", source_id)
fire_entity_event("color_strip_source", "deleted", source_id, entity_name=_entity_name)
except HTTPException:
raise
except ValueError as e:
+60 -1
View File
@@ -13,6 +13,7 @@ from ledgrab.core.devices.led_client import (
from ledgrab.api.dependencies import (
fire_entity_event,
get_device_store,
get_mqtt_store,
get_output_target_store,
get_processor_manager,
)
@@ -33,10 +34,13 @@ from ledgrab.api.schemas.devices import (
)
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
from ledgrab.utils.url_scheme import infer_http_scheme
from ._mqtt_validation import validate_mqtt_source_exists
logger = get_logger(__name__)
router = APIRouter()
@@ -92,19 +96,23 @@ def _device_to_response(device) -> DeviceResponse:
espnow_channel=device.espnow_channel,
hue_paired=bool(device.hue_username and device.hue_client_key),
hue_entertainment_group_id=device.hue_entertainment_group_id,
hue_gradient_mode=device.hue_gradient_mode,
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,
lifx_per_zone=device.lifx_per_zone,
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,
nanoleaf_per_panel=device.nanoleaf_per_panel,
spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type,
gamesense_device_type=device.gamesense_device_type,
ble_family=device.ble_family,
ble_govee_key=device.ble_govee_key,
mqtt_source_id=getattr(device, "mqtt_source_id", "") or "",
default_css_processing_template_id=device.default_css_processing_template_id,
group_device_ids=device.group_device_ids,
group_mode=device.group_mode,
@@ -124,11 +132,13 @@ async def create_device(
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
):
"""Create and attach a new LED device."""
try:
device_type = device_data.device_type
logger.info(f"Creating {device_type} device: {device_data.name}")
validate_mqtt_source_exists(mqtt_store, device_data.mqtt_source_id)
# ── Group device: validate children + compute LED count ──
if device_type == "group":
@@ -254,6 +264,9 @@ async def create_device(
hue_username=device_data.hue_username or "",
hue_client_key=device_data.hue_client_key or "",
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
hue_gradient_mode=(
device_data.hue_gradient_mode if device_data.hue_gradient_mode is not None else True
),
yeelight_min_interval_ms=(
device_data.yeelight_min_interval_ms
if device_data.yeelight_min_interval_ms is not None
@@ -269,6 +282,7 @@ async def create_device(
if device_data.lifx_min_interval_ms is not None
else 50
),
lifx_per_zone=bool(device_data.lifx_per_zone),
govee_min_interval_ms=(
device_data.govee_min_interval_ms
if device_data.govee_min_interval_ms is not None
@@ -281,12 +295,14 @@ async def create_device(
if device_data.nanoleaf_min_interval_ms is not None
else 100
),
nanoleaf_per_panel=bool(device_data.nanoleaf_per_panel),
spi_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink",
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
ble_family=device_data.ble_family or "",
ble_govee_key=device_data.ble_govee_key or "",
mqtt_source_id=device_data.mqtt_source_id or "",
group_device_ids=group_device_ids,
group_mode=group_mode,
)
@@ -543,12 +559,14 @@ async def update_device(
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
):
"""Update device information."""
try:
# Group-specific validation before applying update
existing = store.get_device(device_id)
is_group = existing.device_type == "group"
validate_mqtt_source_exists(mqtt_store, update_data.mqtt_source_id)
# Normalize URL the same way we do on create:
# * always rstrip trailing slashes (so PUT-with-trailing-/ matches
@@ -621,19 +639,23 @@ async def update_device(
hue_username=update_data.hue_username,
hue_client_key=update_data.hue_client_key,
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
hue_gradient_mode=update_data.hue_gradient_mode,
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,
lifx_per_zone=update_data.lifx_per_zone,
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,
nanoleaf_per_panel=update_data.nanoleaf_per_panel,
spi_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type,
gamesense_device_type=update_data.gamesense_device_type,
ble_family=update_data.ble_family,
ble_govee_key=update_data.ble_govee_key,
mqtt_source_id=update_data.mqtt_source_id,
group_device_ids=update_data.group_device_ids,
group_mode=update_data.group_mode,
icon=update_data.icon,
@@ -669,6 +691,10 @@ async def update_device(
fire_entity_event("device", "updated", device_id)
return _device_to_response(device)
except HTTPException:
# Intentional 4xx (e.g. unknown mqtt_source_id, group validation)
# must propagate unchanged — not be masked as a 500.
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
@@ -686,6 +712,13 @@ async def delete_device(
):
"""Delete/detach a device. Returns 409 if referenced by a target."""
try:
# Resolve name before deletion for the audit record.
_entity_name: str | None = None
try:
_entity_name = store.get_device(device_id).name
except Exception:
pass
# Check if any target references this device
refs = target_store.get_targets_for_device(device_id)
if refs:
@@ -713,7 +746,7 @@ async def delete_device(
# Delete from storage
store.delete_device(device_id)
fire_entity_event("device", "deleted", device_id)
fire_entity_event("device", "deleted", device_id, entity_name=_entity_name)
logger.info(f"Deleted device {device_id}")
except HTTPException:
@@ -777,6 +810,32 @@ async def ping_device(
# ===== WLED BRIGHTNESS ENDPOINTS =====
async def resolve_device_brightness(device, manager: ProcessorManager) -> int | None:
"""Resolve a device's current brightness for aggregate/batch reads.
Mirrors GET /brightness but degrades to ``None`` instead of raising, so one
unreachable device can't fail a whole snapshot. Reads the server-side cache
first and only touches hardware when the cache is cold, then populates it so
subsequent reads are I/O-free.
"""
if "brightness_control" not in get_device_capabilities(device.device_type):
return None
ds = manager.find_device_state(device.id)
if ds and ds.hardware_brightness is not None:
return ds.hardware_brightness
try:
provider = get_provider(device.device_type)
bri = await provider.get_brightness(device.url)
if ds:
ds.hardware_brightness = bri
return bri
except NotImplementedError:
return device.software_brightness
except Exception as e:
logger.warning("Failed to resolve brightness for device %s: %s", device.id, e)
return None
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
async def get_device_brightness(
device_id: str,
+155 -61
View File
@@ -6,6 +6,7 @@ adapter metadata, and diagnostics.
import threading
import time
from collections import defaultdict
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -16,6 +17,7 @@ from ledgrab.api.dependencies import (
get_database,
get_game_integration_store,
get_game_event_bus,
get_lol_poll_manager,
)
from ledgrab.api.schemas.game_integration import (
AdapterInfoResponse,
@@ -36,9 +38,16 @@ from ledgrab.api.schemas.game_integration import (
)
from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.events import GameEvent
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
from ledgrab.core.game_integration.runtime_state import (
cleanup_state as _cleanup_state,
get_prev_state as _get_prev_state,
get_stats as _get_stats,
record_events as _record_events,
set_prev_state as _set_prev_state,
)
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.game_integration import EventMapping
from ledgrab.storage.game_integration import _SECRET_CONFIG_KEYS, EventMapping
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.utils import get_logger
@@ -46,15 +55,77 @@ logger = get_logger(__name__)
router = APIRouter()
# ── Per-integration runtime state (in-memory, not persisted) ──────────────
# Per-integration runtime state (prev-state + stats + payload processing) lives
# in ``core/game_integration/runtime_state.py`` and is imported above under the
# legacy ``_get_prev_state`` / ``_record_events`` / … names so both this route
# and the LoL poll manager share one set of counters.
_integration_state_lock = threading.Lock()
# integration_id -> prev_state dict for diff-based trigger detection
_prev_states: dict[str, dict[str, Any]] = {}
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
#
# The ingest route is high-frequency (games push at 16-64 Hz), so we do NOT
# rate-limit every event — that would throttle legitimate gameplay traffic.
# Instead we throttle only FAILED-auth attempts per source IP (the only thing
# an attacker without the token can produce). This mirrors the IP-based
# limiter in routes/webhooks.py (~30/min) but scopes it to failures so a
# brute-forcer is locked out after _AUTH_FAIL_LIMIT bad tokens per minute
# while authenticated high-rate ingestion is completely unaffected.
_AUTH_FAIL_LIMIT = 30
_AUTH_FAIL_WINDOW = 60.0 # seconds
_AUTH_FAIL_HITS_HARD_CAP = 1024
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
_auth_fail_hits: dict[str, list[float]] = defaultdict(list)
_auth_fail_lock = threading.Lock()
# integration_id -> runtime stats
_integration_stats: dict[str, dict[str, Any]] = {}
def _rate_limit_key(request: Request) -> str:
"""Pick a stable client identifier for rate-limiting.
When the immediate peer is loopback (assumed reverse-proxy), use the
first ``X-Forwarded-For`` entry; otherwise use the peer's IP.
"""
peer = request.client.host if request.client else "unknown"
if peer in _LOOPBACK_HOSTS:
xff = request.headers.get("x-forwarded-for", "")
if xff:
return xff.split(",", 1)[0].strip() or peer
return peer
def _check_auth_fail_rate_limit(client_ip: str) -> None:
"""Raise 429 if *client_ip* exceeded the failed-auth attempt limit."""
now = time.time()
window_start = now - _AUTH_FAIL_WINDOW
with _auth_fail_lock:
timestamps = [t for t in _auth_fail_hits[client_ip] if t > window_start]
_auth_fail_hits[client_ip] = timestamps
if len(timestamps) >= _AUTH_FAIL_LIMIT:
raise HTTPException(
status_code=429,
detail="Too many failed authentication attempts. Try again later.",
)
def _record_auth_failure(client_ip: str) -> None:
"""Record a failed-auth attempt for *client_ip* (bounded memory)."""
now = time.time()
window_start = now - _AUTH_FAIL_WINDOW
with _auth_fail_lock:
_auth_fail_hits[client_ip].append(now)
# Periodic cleanup of stale IPs to prevent unbounded growth.
if len(_auth_fail_hits) > 100:
stale = [ip for ip, ts in _auth_fail_hits.items() if not ts or ts[-1] < window_start]
for ip in stale:
del _auth_fail_hits[ip]
# Hard cap against an attacker spraying many distinct X-Forwarded-For
# values; drop the oldest-touched IPs.
if len(_auth_fail_hits) > _AUTH_FAIL_HITS_HARD_CAP:
ordered = sorted(
_auth_fail_hits.items(),
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
)
for ip, _ in ordered[: len(ordered) - _AUTH_FAIL_HITS_HARD_CAP]:
_auth_fail_hits.pop(ip, None)
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
@@ -82,59 +153,47 @@ def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
return fields
def _get_prev_state(integration_id: str) -> dict[str, Any]:
"""Get or create the prev_state dict for an integration."""
with _integration_state_lock:
if integration_id not in _prev_states:
_prev_states[integration_id] = {}
return _prev_states[integration_id]
def _set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
"""Update the prev_state dict for an integration."""
with _integration_state_lock:
_prev_states[integration_id] = state
def _record_events(integration_id: str, events: list[GameEvent]) -> None:
"""Record event stats for an integration."""
with _integration_state_lock:
if integration_id not in _integration_stats:
_integration_stats[integration_id] = {
"event_count": 0,
"event_counts_by_type": {},
"last_event_time": None,
}
stats = _integration_stats[integration_id]
for event in events:
stats["event_count"] += 1
stats["event_counts_by_type"][event.event_type] = (
stats["event_counts_by_type"].get(event.event_type, 0) + 1
)
stats["last_event_time"] = event.timestamp
def _get_stats(integration_id: str) -> dict[str, Any]:
"""Get runtime stats for an integration."""
with _integration_state_lock:
return _integration_stats.get(
integration_id,
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
)
def _cleanup_state(integration_id: str) -> None:
"""Remove runtime state for a deleted integration."""
with _integration_state_lock:
_prev_states.pop(integration_id, None)
_integration_stats.pop(integration_id, None)
# ── Helper: convert config to response ────────────────────────────────────
def _redact_secrets(adapter_config: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of *adapter_config* with secret values masked.
The adapter ``auth_token`` is a live shared secret (it authenticates the
ingest endpoint). It is encrypted at rest, but the response builder echoes
the in-memory *decrypted* config, so without masking any API caller
(loopback-anonymous by default) could read the cleartext token. We never
return the secret over the API the edit form submits a blank value to
keep the existing secret (see ``_merge_preserved_secrets``).
"""
cfg = dict(adapter_config)
for key in _SECRET_CONFIG_KEYS:
if cfg.get(key):
cfg[key] = "" # mask — never echo the secret to the client
return cfg
def _merge_preserved_secrets(
incoming: dict[str, Any] | None, existing: Any
) -> dict[str, Any] | None:
"""Preserve a stored secret when an update submits a blank/absent one.
Because the API masks secrets in responses, the edit form re-submits a
blank value for an unchanged secret. Without this merge that blank would
overwrite (and destroy) the stored token. A non-empty incoming value is a
deliberate change and is kept as-is.
"""
if incoming is None:
return None
merged = dict(incoming)
for key in _SECRET_CONFIG_KEYS:
if not merged.get(key) and existing.adapter_config.get(key):
merged[key] = existing.adapter_config[key]
return merged
def _config_to_response(config: Any) -> GameIntegrationResponse:
"""Convert a GameIntegrationConfig to its API response."""
"""Convert a GameIntegrationConfig to its API response (secrets redacted)."""
from ledgrab.api.schemas.game_integration import EventMappingSchema
return GameIntegrationResponse(
@@ -142,7 +201,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
name=config.name,
adapter_type=config.adapter_type,
enabled=config.enabled,
adapter_config=config.adapter_config,
adapter_config=_redact_secrets(config.adapter_config),
event_mappings=[
EventMappingSchema(
event_type=m.event_type,
@@ -234,6 +293,7 @@ async def create_integration(
data: GameIntegrationCreate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Create a new game integration config."""
try:
@@ -262,6 +322,8 @@ async def create_integration(
)
fire_entity_event("game_integration", "created", config.id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
return _config_to_response(config)
except EntityNotFoundError as e:
@@ -301,6 +363,7 @@ async def update_integration(
data: GameIntegrationUpdate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Update a game integration config."""
try:
@@ -318,12 +381,20 @@ async def update_integration(
for m in data.event_mappings
]
# Preserve a stored secret when the update submits a blank token
# (the API masks secrets, so the edit form re-sends a blank value
# for an unchanged secret — see _merge_preserved_secrets).
adapter_config = data.adapter_config
if adapter_config is not None:
existing = store.get_integration(integration_id)
adapter_config = _merge_preserved_secrets(adapter_config, existing)
config = store.update_integration(
integration_id=integration_id,
name=data.name,
adapter_type=data.adapter_type,
enabled=data.enabled,
adapter_config=data.adapter_config,
adapter_config=adapter_config,
event_mappings=mappings,
description=data.description,
tags=data.tags,
@@ -332,6 +403,8 @@ async def update_integration(
)
fire_entity_event("game_integration", "updated", integration_id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
return _config_to_response(config)
except EntityNotFoundError as e:
@@ -352,11 +425,14 @@ async def delete_integration(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Delete a game integration config."""
try:
store.delete_integration(integration_id)
_cleanup_state(integration_id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
fire_entity_event("game_integration", "deleted", integration_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -387,7 +463,16 @@ async def ingest_event(
called before standard API auth.
No AuthRequired dependency adapter-level auth is used instead.
Rate limiting is scoped to FAILED-auth attempts per source IP (see
``_check_auth_fail_rate_limit``) so legitimate high-rate ingestion is
never throttled, but a brute-forcer is locked out after the threshold.
"""
client_ip = _rate_limit_key(request)
# Block IPs that have already burned through the failed-auth budget,
# before doing any work (cheap brute-force lockout).
_check_auth_fail_rate_limit(client_ip)
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
@@ -402,9 +487,18 @@ async def ingest_event(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Adapter-level auth check
# Adapter-level auth check. Treat ANY exception from validate_auth as an
# auth failure (rate-limited + 403), never a 500 — a malformed/attacker-
# controlled token must not crash the handler nor bypass the brute-force
# lockout counter.
headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
try:
authed = adapter_cls.validate_auth(headers, payload.data, config.adapter_config)
except Exception as exc:
logger.warning("validate_auth raised for %s: %s", integration_id, exc)
authed = False
if not authed:
_record_auth_failure(client_ip)
raise HTTPException(status_code=403, detail="Adapter authentication failed")
# Parse payload through adapter
+7 -1
View File
@@ -152,13 +152,19 @@ async def delete_gradient(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete a gradient (fails if built-in or referenced by sources)."""
_entity_name: str | None = None
try:
_entity_name = store.get_gradient(gradient_id).name
except Exception:
pass
try:
# Check references
for source in css_store.get_all_sources():
if getattr(source, "gradient_id", None) == gradient_id:
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
store.delete_gradient(gradient_id)
fire_entity_event("gradient", "deleted", gradient_id)
fire_entity_event("gradient", "deleted", gradient_id, entity_name=_entity_name)
except (ValueError, EntityNotFoundError) as e:
status = 404 if "not found" in str(e).lower() else 400
raise HTTPException(status_code=status, detail=str(e))
+249
View File
@@ -0,0 +1,249 @@
"""Wiring-graph endpoints: schema registry, full topology, and dependents.
These power the visual graph editor (and any other client) with a single
authoritative view of how entities are wired together:
* ``GET /api/v1/graph/schema`` the connectable-field registry.
* ``GET /api/v1/graph`` nodes + edges + validation.
* ``GET /api/v1/graph/dependents/{kind}/{id}`` what references an entity.
All heavy logic lives in :mod:`ledgrab.api.graph_schema` (pure, unit-tested);
this layer only gathers serialized entities from the stores and delegates.
"""
from __future__ import annotations
import logging
from typing import Any, Callable
from fastapi import APIRouter, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field
from ledgrab.api import dependencies as deps
from ledgrab.api.auth import AuthRequired
from ledgrab.api.graph_schema import (
ENTITY_KINDS,
NODE_TYPE_FIELD,
build_topology,
extract_refs,
find_dependents,
remap_refs,
schema_as_dicts,
schema_for_kind,
serialize_entity,
serialize_entity_for_graph,
validate_connection,
)
logger = logging.getLogger(__name__)
class ConnectionValidationRequest(BaseModel):
"""A proposed wiring edit: set ``target_kind.field`` to ``source_id``."""
target_kind: str
target_id: str
field: str
source_id: str = Field(default="", description="Empty string detaches the slot.")
router = APIRouter()
# kind → dependency getter for the store that owns that entity kind.
_KIND_STORES: dict[str, Callable[[], Any]] = {
"device": deps.get_device_store,
"capture_template": deps.get_template_store,
"pp_template": deps.get_pp_template_store,
"audio_template": deps.get_audio_template_store,
"pattern_template": deps.get_pattern_template_store,
"picture_source": deps.get_picture_source_store,
"audio_source": deps.get_audio_source_store,
"value_source": deps.get_value_source_store,
"color_strip_source": deps.get_color_strip_store,
"sync_clock": deps.get_sync_clock_store,
"output_target": deps.get_output_target_store,
"scene_preset": deps.get_scene_preset_store,
"automation": deps.get_automation_store,
"cspt": deps.get_cspt_store,
}
def _gather_entities() -> dict[str, list[dict[str, Any]]]:
"""Serialize every entity, keyed by kind. Missing stores yield ``[]``."""
out: dict[str, list[dict[str, Any]]] = {}
for kind, getter in _KIND_STORES.items():
try:
store = getter()
models = store.get_all()
except (
Exception
) as exc: # noqa: BLE001 — an uninitialized/failing store must not 500 the graph
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
out[kind] = []
continue
out[kind] = [serialize_entity_for_graph(kind, m) for m in models]
return out
@router.get("/api/v1/graph/schema", tags=["Graph"])
async def get_graph_schema(_auth: AuthRequired) -> dict[str, Any]:
"""Return the authoritative registry of connectable reference fields."""
return {
"kinds": list(ENTITY_KINDS),
"node_type_field": NODE_TYPE_FIELD,
"connections": schema_as_dicts(),
}
@router.get("/api/v1/graph", tags=["Graph"])
async def get_graph(_auth: AuthRequired) -> dict[str, Any]:
"""Return the full wiring topology (nodes + edges) and a validation report."""
entities = await run_in_threadpool(_gather_entities)
return build_topology(entities)
@router.get("/api/v1/graph/dependents/{kind}/{entity_id}", tags=["Graph"])
async def get_graph_dependents(kind: str, entity_id: str, _auth: AuthRequired) -> dict[str, Any]:
"""Return every entity that references ``(kind, entity_id)``."""
if kind not in ENTITY_KINDS:
raise HTTPException(status_code=404, detail=f"Unknown entity kind: {kind}")
entities = await run_in_threadpool(_gather_entities)
return {"dependents": find_dependents(entities, kind, entity_id)}
@router.post("/api/v1/graph/validate-connection", tags=["Graph"])
async def validate_graph_connection(
body: ConnectionValidationRequest, _auth: AuthRequired
) -> dict[str, Any]:
"""Validate a proposed wiring edit (existence + source kind + no cycle).
The graph editor calls this before persisting a drag-connect so it can
refuse edits that would dangle a reference or create a dependency loop.
"""
entities = await run_in_threadpool(_gather_entities)
ok, error = validate_connection(
entities, body.target_kind, body.target_id, body.field, body.source_id
)
return {"ok": ok, "error": error}
# ── Subgraph duplication (server-side blueprint instantiate) ─────────────────
# Only these kinds are cloned. They carry no inline secrets — they *reference*
# shared secret-bearing entities (devices, HA sources, HTTP endpoints) by id,
# and those are NOT cloned — and they have no hardware identity to conflict
# over. Output targets, automations, devices and integrations are out of scope.
_DUPLICABLE_KINDS: tuple[str, ...] = ("value_source", "color_strip_source")
_MAX_DUPLICATE = 200
class DuplicateRequest(BaseModel):
"""Duplicate a selected subgraph of value / colour-strip sources."""
node_ids: list[str] = Field(..., min_length=1, max_length=_MAX_DUPLICATE)
name_suffix: str = Field(default=" (copy)", max_length=40)
def _unique_name(existing: set[str], desired: str) -> str:
"""A name not already in ``existing`` (appends ' 2', ' 3', … on collision)."""
if desired not in existing:
return desired
i = 2
while f"{desired} {i}" in existing:
i += 1
return f"{desired} {i}"
def _duplicate_subgraph(node_ids: list[str], name_suffix: str) -> dict[str, Any]:
"""Deep-clone selected value/colour-strip sources with new ids, rewiring
references that point *within* the selection (shared deps are left alone)."""
# Index every duplicable entity by id → (kind, store, model); track names.
index: dict[str, tuple[str, Any, Any]] = {}
existing_names: dict[str, set[str]] = {}
for kind in _DUPLICABLE_KINDS:
try:
store = _KIND_STORES[kind]()
models = store.get_all()
except Exception as exc: # noqa: BLE001 — a failing store must not 500 the request
logger.warning("graph.duplicate: store for %s unavailable: %s", kind, exc)
continue
names = existing_names.setdefault(kind, set())
for m in models:
mid = getattr(m, "id", None)
mname = getattr(m, "name", None)
if isinstance(mname, str):
names.add(mname)
if isinstance(mid, str) and mid:
index[mid] = (kind, store, m)
selected: list[str] = []
skipped: list[dict[str, str]] = []
for nid in dict.fromkeys(node_ids): # de-dupe, preserve order
if nid in index:
selected.append(nid)
else:
skipped.append(
{"id": nid, "reason": "only value and colour-strip sources can be duplicated"}
)
# Pass 1 — create clones; their refs still point at the originals (valid).
id_map: dict[str, str] = {}
created: list[dict[str, str]] = []
clones: list[tuple[str, Any, str]] = []
for old_id in selected:
kind, store, model = index[old_id]
base = (getattr(model, "name", None) or old_id) + name_suffix
name = _unique_name(existing_names[kind], base)
existing_names[kind].add(name)
try:
new = store.clone(old_id, name)
except Exception as exc: # noqa: BLE001
logger.warning("graph.duplicate: clone of %s %s failed: %s", kind, old_id, exc)
skipped.append({"id": old_id, "reason": f"clone failed: {exc}"})
continue
id_map[old_id] = new.id
created.append({"id": new.id, "kind": kind, "name": new.name})
clones.append((kind, store, new.id))
# Pass 2 — rewrite references that point within the cloned set.
warnings: list[dict[str, str]] = []
for kind, store, new_id in clones:
clone = serialize_entity(store.get(new_id))
changed_roots: set[str] = set()
for cf in schema_for_kind(kind):
if remap_refs(clone, cf.field, id_map):
changed_roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
if not changed_roots:
continue
# `clone` is the FULL serialized entity, so each changed root carries a
# complete, structurally-intact value (the whole `layers` list / bindable
# dict) that ``update_source`` replaces or merges wholesale. (Within the
# duplicable set the only roots that change are scalar ids, `layers` and
# bindable slots — never a partially-built nested object.)
updates = {root: clone[root] for root in changed_roots if root in clone}
try:
store.update_source(new_id, **updates)
except Exception as exc: # noqa: BLE001
logger.warning("graph.duplicate: ref remap of %s failed: %s", new_id, exc)
warnings.append({"id": new_id, "reason": f"reference remap failed: {exc}"})
# Safety net — a clone must never still reference an OLD (in-selection) id.
for kind, store, new_id in clones:
clone = serialize_entity(store.get(new_id))
for cf in schema_for_kind(kind):
if any(ref in id_map for ref in extract_refs(clone, cf.field)):
warnings.append({"id": new_id, "reason": f"unremapped reference at {cf.field}"})
return {"id_map": id_map, "created": created, "skipped": skipped, "warnings": warnings}
@router.post("/api/v1/graph/duplicate", tags=["Graph"])
async def duplicate_subgraph(body: DuplicateRequest, _auth: AuthRequired) -> dict[str, Any]:
"""Deep-clone the selected value/colour-strip sources (new ids, wiring remapped).
References that point *within* the selection are rewired to the new clones;
references to entities outside it (devices, HA sources, ) stay shared with
the originals. Only value and colour-strip sources are cloned they carry no
inline secrets so any other kind in the selection is reported in ``skipped``.
"""
return await run_in_threadpool(_duplicate_subgraph, body.node_ids, body.name_suffix)
@@ -2,6 +2,7 @@
import asyncio
import json
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -28,6 +29,7 @@ from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.home_assistant_source import HomeAssistantSource
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import validate_lan_host
logger = get_logger(__name__)
@@ -37,6 +39,23 @@ router = APIRouter()
_REDACTED_TOKEN = "***"
def _validate_ha_host(host: str | None) -> None:
"""Reject literal public/link-local/metadata IPs for a HA source host.
HA sources are LAN-by-design (loopback + private ranges allowed), so we
gate the user-supplied ``host`` with the same shared classifier the LED
device providers use (``validate_lan_host``). The HA host is stored as
``host:port`` (e.g. ``192.168.1.100:8123``), so strip the port first via
``urlparse`` which also handles bracketed IPv6 literals. Hostnames /
mDNS labels pass through (classified UNPARSEABLE). Raises ``ValueError``
on a literal public IP, which the callers translate to HTTP 400.
"""
if not host:
return
bare_host = urlparse(f"//{host.strip()}").hostname or host.strip()
validate_lan_host(bare_host)
def _to_response(
source: HomeAssistantSource,
manager: HomeAssistantManager,
@@ -99,6 +118,7 @@ async def create_ha_source(
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
_validate_ha_host(data.host)
source = store.create_source(
name=data.name,
host=data.host,
@@ -153,6 +173,7 @@ async def update_ha_source(
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
_validate_ha_host(data.host)
source = store.update_source(
source_id,
name=data.name,
+13
View File
@@ -42,6 +42,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
password_set=bool(source.password),
client_id=source.client_id,
base_topic=source.base_topic,
publish_ha_discovery=getattr(source, "publish_ha_discovery", False),
discovery_prefix=getattr(source, "discovery_prefix", "homeassistant"),
connected=runtime.is_connected if runtime else False,
description=source.description,
tags=source.tags,
@@ -90,6 +92,8 @@ async def create_mqtt_source(
password=data.password,
client_id=data.client_id,
base_topic=data.base_topic,
publish_ha_discovery=data.publish_ha_discovery,
discovery_prefix=data.discovery_prefix,
description=data.description,
tags=data.tags,
icon=data.icon,
@@ -97,6 +101,8 @@ async def create_mqtt_source(
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Publish HA discovery if the new source opted in.
await manager.sync_discovery(source.id)
fire_entity_event("mqtt_source", "created", source.id)
return _to_response(source, manager)
@@ -141,6 +147,8 @@ async def update_mqtt_source(
password=data.password,
client_id=data.client_id,
base_topic=data.base_topic,
publish_ha_discovery=data.publish_ha_discovery,
discovery_prefix=data.discovery_prefix,
description=data.description,
tags=data.tags,
icon=data.icon,
@@ -151,6 +159,8 @@ async def update_mqtt_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await manager.update_source(source_id)
# Reconcile HA discovery (publish if enabled, clear if turned off).
await manager.sync_discovery(source_id)
fire_entity_event("mqtt_source", "updated", source.id)
return _to_response(source, manager)
@@ -162,6 +172,9 @@ async def delete_mqtt_source(
store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager),
):
# Clear any HA discovery configs (needs the source still present to build
# the exact retained topics) before deleting the row.
await manager.disable_discovery(source_id)
try:
store.delete_source(source_id)
except EntityNotFoundError:
+20 -13
View File
@@ -49,6 +49,8 @@ from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
from ._mqtt_validation import validate_mqtt_source_exists
logger = get_logger(__name__)
router = APIRouter()
@@ -68,6 +70,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
max_milliamps=target.max_milliamps,
milliamps_per_led=target.milliamps_per_led,
description=target.description,
tags=target.tags,
icon=getattr(target, "icon", "") or "",
@@ -270,16 +274,6 @@ def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
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(
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
)
@@ -310,6 +304,8 @@ async def create_target(
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
max_milliamps=data.max_milliamps,
milliamps_per_led=data.milliamps_per_led,
)
case HALightOutputTargetCreate():
if data.source_kind == "color_vs":
@@ -333,7 +329,7 @@ async def create_target(
case Z2MLightOutputTargetCreate():
if data.source_kind == "color_vs":
_validate_color_value_source(value_source_store, data.color_value_source_id)
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.create_z2m_light_target(
name=data.name,
description=data.description,
@@ -472,6 +468,8 @@ async def update_target(
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
max_milliamps=data.max_milliamps,
milliamps_per_led=data.milliamps_per_led,
)
css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None
@@ -484,6 +482,8 @@ async def update_target(
data.min_brightness_threshold,
data.adaptive_fps,
data.brightness,
data.max_milliamps,
data.milliamps_per_led,
)
)
device_changed = data.device_id is not None
@@ -540,7 +540,7 @@ async def update_target(
)
_validate_color_value_source(value_source_store, effective_id)
if data.mqtt_source_id:
_validate_mqtt_source_exists(mqtt_store, 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,
@@ -624,6 +624,13 @@ async def delete_target(
):
"""Delete a output target. Stops processing first if active."""
try:
# Resolve name before deletion for the audit record.
_entity_name: str | None = None
try:
_entity_name = target_store.get_target(target_id).name
except Exception:
pass
# Stop processing if running
try:
await manager.stop_processing(target_id)
@@ -641,7 +648,7 @@ async def delete_target(
# Delete from store
target_store.delete_target(target_id)
fire_entity_event("output_target", "deleted", target_id)
fire_entity_event("output_target", "deleted", target_id, entity_name=_entity_name)
logger.info(f"Deleted target {target_id}")
except ValueError as e:
@@ -12,6 +12,7 @@ from ledgrab.api.dependencies import (
get_picture_source_store,
get_processor_manager,
)
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.api.schemas.output_targets import (
BulkTargetRequest,
BulkTargetResponse,
@@ -28,6 +29,7 @@ from ledgrab.storage.color_strip_source import (
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.wled_output_target import WledOutputTarget
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -35,6 +37,23 @@ logger = get_logger(__name__)
router = APIRouter()
def _record_capture(action: str, target_id: str, target_name: str | None, message: str) -> None:
"""Best-effort audit record for a capture start/stop action."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.CAPTURE,
action=action,
severity=ActivitySeverity.INFO,
entity_type="output_target",
entity_id=target_id,
entity_name=sanitize_display(target_name) if target_name else None,
message=message,
)
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
@@ -53,10 +72,18 @@ async def bulk_start_processing(
for target_id in body.ids:
try:
target_store.get_target(target_id)
_tgt = target_store.get_target(target_id)
await manager.start_processing(target_id)
started.append(target_id)
logger.info(f"Bulk start: started processing for target {target_id}")
_tgt_name_raw = getattr(_tgt, "name", None)
_tgt_safe = sanitize_display(_tgt_name_raw) if _tgt_name_raw else None
_record_capture(
"capture.started",
target_id,
_tgt_safe,
f"Capture started for target '{_tgt_safe or target_id}' (bulk)",
)
except ValueError as e:
errors[target_id] = str(e)
except RuntimeError as e:
@@ -78,6 +105,7 @@ async def bulk_start_processing(
async def bulk_stop_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
@@ -89,6 +117,18 @@ async def bulk_stop_processing(
await manager.stop_processing(target_id)
stopped.append(target_id)
logger.info(f"Bulk stop: stopped processing for target {target_id}")
_tgt_name: str | None = None
try:
_tgt_name = target_store.get_target(target_id).name
except Exception:
pass
_tgt_name_safe = sanitize_display(_tgt_name) if _tgt_name else None
_record_capture(
"capture.stopped",
target_id,
_tgt_name_safe,
f"Capture stopped for target '{_tgt_name_safe or target_id}' (bulk)",
)
except ValueError as e:
errors[target_id] = str(e)
except Exception as e:
@@ -112,11 +152,19 @@ async def start_processing(
logger.info("Start processing requested for target %s", target_id)
try:
# Verify target exists in store
target_store.get_target(target_id)
target = target_store.get_target(target_id)
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}")
_tgt_name_raw2 = getattr(target, "name", None)
_tgt_safe2 = sanitize_display(_tgt_name_raw2) if _tgt_name_raw2 else None
_record_capture(
"capture.started",
target_id,
_tgt_safe2,
f"Capture started for target '{_tgt_safe2 or target_id}'",
)
return {"status": "started", "target_id": target_id}
except ValueError as e:
@@ -137,6 +185,7 @@ async def start_processing(
async def stop_processing(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for a output target."""
@@ -144,6 +193,18 @@ async def stop_processing(
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}")
_target_name: str | None = None
try:
_target_name = target_store.get_target(target_id).name
except Exception:
pass
_target_name_safe = sanitize_display(_target_name) if _target_name else None
_record_capture(
"capture.stopped",
target_id,
_target_name_safe,
f"Capture stopped for target '{_target_name_safe or target_id}'",
)
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
@@ -374,6 +374,12 @@ async def delete_picture_source(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete a picture source."""
_entity_name: str | None = None
try:
_entity_name = store.get_stream(stream_id).name
except Exception:
pass
try:
# Check if any target transitively references this stream via a CSS
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
@@ -395,7 +401,7 @@ async def delete_picture_source(
f"{css_names}. Please reassign or delete those first.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
fire_entity_event("picture_source", "deleted", stream_id, entity_name=_entity_name)
except HTTPException:
raise
except EntityNotFoundError as e:
@@ -51,6 +51,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
is_builtin=getattr(t, "is_builtin", False),
)
@@ -15,6 +15,7 @@ daylight value-source / color-strip-source. Stored as
empty/missing meaning "use system local time".
"""
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
@@ -38,6 +39,7 @@ router = APIRouter()
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
_NOTIFICATION_PREFS_KEY = "notification_preferences"
_CARD_MODES_KEY = "card_modes"
_ONBOARDING_KEY = "onboarded"
class DaylightTimezonePreference(BaseModel):
@@ -285,4 +287,75 @@ async def put_daylight_timezone_preference(
return DaylightTimezonePreference(timezone=saved)
# ---------------------------------------------------------------------------
# Onboarding flag
# ---------------------------------------------------------------------------
class OnboardingPreference(BaseModel):
"""Persistent first-run onboarding flag."""
onboarded: bool = Field(
False,
description="True once the user has completed the first-run wizard.",
)
completed_at: str | None = Field(
None,
description="ISO timestamp of when onboarding was first marked complete; null otherwise.",
)
@router.get(
"/api/v1/preferences/onboarding",
response_model=OnboardingPreference,
tags=["Preferences"],
)
async def get_onboarding(
_: AuthRequired,
db: Database = Depends(get_database),
) -> OnboardingPreference:
"""Return the first-run onboarding status.
Defaults to ``{onboarded: false, completed_at: null}`` when the flag has
never been set.
"""
raw = db.get_setting(_ONBOARDING_KEY)
if not raw:
return OnboardingPreference()
try:
return OnboardingPreference.model_validate(raw)
except Exception as exc:
logger.warning("Stored onboarding preference invalid (%s); using default", exc)
return OnboardingPreference()
@router.put(
"/api/v1/preferences/onboarding",
response_model=OnboardingPreference,
tags=["Preferences"],
)
async def put_onboarding(
_: AuthRequired,
body: OnboardingPreference,
db: Database = Depends(get_database),
) -> OnboardingPreference:
"""Persist the onboarding flag.
When ``onboarded`` is set to ``true`` and ``completed_at`` is not provided,
the server stamps the current UTC time automatically.
When ``onboarded`` is ``false``, ``completed_at`` is cleared.
"""
if body.onboarded and body.completed_at is None:
body = OnboardingPreference(
onboarded=True,
completed_at=datetime.now(timezone.utc).isoformat(),
)
elif not body.onboarded:
body = OnboardingPreference(onboarded=False, completed_at=None)
db.set_setting(_ONBOARDING_KEY, body.model_dump())
logger.info("Onboarding flag updated: onboarded=%s", body.onboarded)
return body
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
@@ -0,0 +1,328 @@
"""Scene playlist API routes — CRUD plus start/stop/state cycling control."""
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.api.dependencies import (
fire_entity_event,
get_playlist_engine,
get_scene_playlist_store,
get_scene_preset_store,
)
from ledgrab.api.schemas.scene_playlists import (
PlaylistRuntimeStateSchema,
ScenePlaylistCreate,
ScenePlaylistListResponse,
ScenePlaylistResponse,
ScenePlaylistUpdate,
)
from ledgrab.core.scenes.playlist_engine import PlaylistEngine, PlaylistError
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _playlist_to_response(playlist: ScenePlaylist, engine: PlaylistEngine) -> ScenePlaylistResponse:
return ScenePlaylistResponse(
id=playlist.id,
name=playlist.name,
description=playlist.description,
items=[
{"scene_preset_id": i.scene_preset_id, "duration_seconds": i.duration_seconds}
for i in playlist.items
],
loop=playlist.loop,
shuffle=playlist.shuffle,
order=playlist.order,
tags=playlist.tags,
icon=getattr(playlist, "icon", "") or "",
icon_color=getattr(playlist, "icon_color", "") or "",
is_running=engine.get_running_playlist_id() == playlist.id,
created_at=playlist.created_at,
updated_at=playlist.updated_at,
)
def _items_from_schema(items) -> list[PlaylistItem]:
return [
PlaylistItem(scene_preset_id=i.scene_preset_id, duration_seconds=i.duration_seconds)
for i in items
]
def _validate_preset_refs(items, preset_store: ScenePresetStore) -> None:
"""Reject playlist items that reference a non-existent scene preset."""
for item in items:
try:
preset_store.get_preset(item.scene_preset_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(
status_code=400,
detail=f"Scene preset not found: {item.scene_preset_id}",
)
# ===== CRUD =====
@router.post(
"/api/v1/scene-playlists",
response_model=ScenePlaylistResponse,
tags=["Scene Playlists"],
status_code=201,
)
async def create_scene_playlist(
data: ScenePlaylistCreate,
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Create a new scene playlist."""
_validate_preset_refs(data.items, preset_store)
now = datetime.now(timezone.utc)
playlist = ScenePlaylist(
id=f"playlist_{uuid.uuid4().hex[:8]}",
name=data.name,
description=data.description,
items=_items_from_schema(data.items),
loop=data.loop,
shuffle=data.shuffle,
order=store.count(),
tags=data.tags if data.tags is not None else [],
icon=data.icon or "",
icon_color=data.icon_color or "",
created_at=now,
updated_at=now,
)
try:
playlist = store.create_playlist(playlist)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_playlist", "created", playlist.id)
return _playlist_to_response(playlist, engine)
@router.get(
"/api/v1/scene-playlists",
response_model=ScenePlaylistListResponse,
tags=["Scene Playlists"],
)
async def list_scene_playlists(
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""List all scene playlists plus the current cycling state."""
playlists = store.get_all_playlists()
return ScenePlaylistListResponse(
playlists=[_playlist_to_response(p, engine) for p in playlists],
count=len(playlists),
state=PlaylistRuntimeStateSchema(**engine.get_state()),
)
# NOTE: the static ``/state`` path is declared before ``/{playlist_id}`` so it
# is matched first and not swallowed by the path parameter.
@router.get(
"/api/v1/scene-playlists/state",
response_model=PlaylistRuntimeStateSchema,
tags=["Scene Playlists"],
)
async def get_playlist_state(
_auth: AuthRequired,
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Get the current playlist cycling state (idle if nothing is running)."""
return PlaylistRuntimeStateSchema(**engine.get_state())
@router.get(
"/api/v1/scene-playlists/{playlist_id}",
response_model=ScenePlaylistResponse,
tags=["Scene Playlists"],
)
async def get_scene_playlist(
playlist_id: str,
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Get a single scene playlist."""
try:
playlist = store.get_playlist(playlist_id)
except (ValueError, EntityNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
return _playlist_to_response(playlist, engine)
@router.put(
"/api/v1/scene-playlists/{playlist_id}",
response_model=ScenePlaylistResponse,
tags=["Scene Playlists"],
)
async def update_scene_playlist(
playlist_id: str,
data: ScenePlaylistUpdate,
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Update a scene playlist's metadata, items, and playback flags."""
new_items = None
if data.items is not None:
_validate_preset_refs(data.items, preset_store)
new_items = _items_from_schema(data.items)
try:
playlist = store.update_playlist(
playlist_id,
name=data.name,
description=data.description,
items=new_items,
loop=data.loop,
shuffle=data.shuffle,
order=data.order,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except (ValueError, EntityNotFoundError) as e:
raise HTTPException(
status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)
)
fire_entity_event("scene_playlist", "updated", playlist_id)
return _playlist_to_response(playlist, engine)
@router.delete(
"/api/v1/scene-playlists/{playlist_id}",
status_code=204,
tags=["Scene Playlists"],
)
async def delete_scene_playlist(
playlist_id: str,
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Delete a scene playlist (stops it first if it is currently cycling)."""
_entity_name: str | None = None
try:
_entity_name = store.get_playlist(playlist_id).name
except Exception:
pass
try:
store.delete_playlist(playlist_id)
except (ValueError, EntityNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
await engine.stop_if_running(playlist_id)
fire_entity_event("scene_playlist", "deleted", playlist_id, entity_name=_entity_name)
# ===== Cycling control =====
@router.post(
"/api/v1/scene-playlists/{playlist_id}/start",
response_model=PlaylistRuntimeStateSchema,
tags=["Scene Playlists"],
)
async def start_scene_playlist(
playlist_id: str,
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Start cycling a playlist (stops any currently-running playlist first)."""
try:
store.get_playlist(playlist_id)
except (ValueError, EntityNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
try:
await engine.start_playlist(playlist_id)
except PlaylistError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_playlist", "updated", playlist_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_pl_name: str | None = None
try:
_pl_name = store.get_playlist(playlist_id).name
except Exception:
pass
_safe_pl_name = sanitize_display(_pl_name) if _pl_name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="playlist.started",
severity=ActivitySeverity.INFO,
entity_type="scene_playlist",
entity_id=playlist_id,
entity_name=_safe_pl_name,
message=f"Playlist '{_safe_pl_name or playlist_id}' started",
)
return PlaylistRuntimeStateSchema(**engine.get_state())
@router.post(
"/api/v1/scene-playlists/stop",
response_model=PlaylistRuntimeStateSchema,
tags=["Scene Playlists"],
)
async def stop_scene_playlist(
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Stop the active playlist (leaves the last applied scene in place)."""
stopped_id = engine.get_running_playlist_id()
_stopped_name: str | None = None
if stopped_id:
try:
_stopped_name = store.get_playlist(stopped_id).name
except Exception:
pass
await engine.stop()
if stopped_id:
fire_entity_event("scene_playlist", "updated", stopped_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_stopped_name = sanitize_display(_stopped_name) if _stopped_name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="playlist.stopped",
severity=ActivitySeverity.INFO,
entity_type="scene_playlist",
entity_id=stopped_id,
entity_name=_safe_stopped_name,
message=f"Playlist '{_safe_stopped_name or stopped_id}' stopped",
)
return PlaylistRuntimeStateSchema(**engine.get_state())
+25 -1
View File
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.api.dependencies import (
fire_entity_event,
get_output_target_store,
@@ -208,12 +209,18 @@ async def delete_scene_preset(
store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Delete a scene preset."""
_entity_name: str | None = None
try:
_entity_name = store.get_preset(preset_id).name
except Exception:
pass
try:
store.delete_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("scene_preset", "deleted", preset_id)
fire_entity_event("scene_preset", "deleted", preset_id, entity_name=_entity_name)
# ===== Recapture =====
@@ -282,4 +289,21 @@ async def activate_scene_preset(
logger.info(f"Scene preset '{preset.name}' activated successfully")
fire_entity_event("scene_preset", "updated", preset_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_preset_name = sanitize_display(preset.name) if preset.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="scene.activated",
severity=ActivitySeverity.INFO,
entity_type="scene_preset",
entity_id=preset_id,
entity_name=_safe_preset_name,
message=f"Scene preset '{_safe_preset_name or preset_id}' activated",
)
return ActivateResponse(status=status, errors=errors)
+330
View File
@@ -0,0 +1,330 @@
"""Setup scaffold endpoint.
Wires a complete capture color-strip output chain in one call, with
automatic rollback if any step fails so no orphan entities are left behind.
POST /api/v1/setup/scaffold
Body: ScaffoldRequest device_id (required, must already exist),
display_index, optional calibration dict.
Returns: ScaffoldResponse ids of every created/reused entity.
Fires ``entity_changed`` events for every entity created in this call,
but ONLY after the full chain succeeds (no mid-chain events).
Does NOT auto-start the target (the frontend starts it after calibration).
Rollback contract
-----------------
Entities created during THIS request are tracked in a local list. If any
step raises, they are deleted in reverse-creation order before re-raising.
Because "created" events are deferred until after the chain completes, a
rollback never produces ghost UI cards no event for a rolled-back entity
is ever emitted.
The device is never part of the rollback set: scaffold requires an existing
device (created via ``POST /api/v1/devices`` which runs full validation).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_device_store,
get_output_target_store,
get_picture_source_store,
get_processor_manager,
get_template_store,
)
from ledgrab.api.schemas.setup import ScaffoldRequest, ScaffoldResponse
from ledgrab.core.capture.calibration import calibration_from_dict, create_default_calibration
from ledgrab.core.capture_engines.factory import EngineRegistry
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage import DeviceStore
from ledgrab.storage.template_store import TemplateStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
_DEFAULT_TARGET_FPS = 30
# ---------------------------------------------------------------------------
# Helper: capture template
# ---------------------------------------------------------------------------
def _get_or_create_capture_template(
template_store: TemplateStore,
created_ids: list[tuple[str, str]],
) -> tuple[str, bool]:
"""Return (template_id, reused).
Tries to find an existing template whose engine_type matches the platform's
best available engine. Falls back to creating a fresh one.
"""
best_engine = EngineRegistry.get_best_available_engine()
if not best_engine:
raise HTTPException(
status_code=503,
detail="No capture engine available on this platform; cannot scaffold.",
)
# Try to reuse an existing template with the same engine
for tpl in template_store.get_all_templates():
if tpl.engine_type == best_engine:
logger.info(
"Scaffold: reusing existing capture template %s (engine=%s)",
tpl.id,
best_engine,
)
return tpl.id, True
# None found — create a fresh one
engine_class = EngineRegistry.get_engine(best_engine)
default_config = engine_class.get_default_config()
try:
tpl = template_store.create_template(
name=f"Scaffold capture ({best_engine})",
engine_type=best_engine,
engine_config=default_config,
description="Auto-created by first-run scaffold",
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
created_ids.append(("capture_template", tpl.id))
logger.info("Scaffold: created capture template %s (engine=%s)", tpl.id, best_engine)
return tpl.id, False
# ---------------------------------------------------------------------------
# Helper: rollback
# ---------------------------------------------------------------------------
def _rollback(
created_ids: list[tuple[str, str]],
*,
template_store: TemplateStore,
picture_source_store: PictureSourceStore,
css_store: ColorStripStore,
output_target_store: OutputTargetStore,
manager: ProcessorManager | None = None,
) -> None:
"""Delete entities created during this call, in reverse order.
Only entities listed in ``created_ids`` are deleted; reused/pre-existing
entities (including the device) are never touched.
If *manager* is provided, any ``output_target`` entity in the rollback set
is also unregistered from the ProcessorManager before store deletion, so no
half-registered target is left behind.
"""
store_map: dict[str, Any] = {
"capture_template": template_store,
"picture_source": picture_source_store,
"color_strip_source": css_store,
"output_target": output_target_store,
}
for entity_type, entity_id in reversed(created_ids):
# Unregister output targets from the processor manager first
if entity_type == "output_target" and manager is not None:
try:
manager.remove_target(entity_id)
logger.info("Scaffold rollback: unregistered target %s from manager", entity_id)
except (ValueError, RuntimeError) as exc:
logger.debug(
"Scaffold rollback: manager unregister skipped for %s%s",
entity_id,
exc,
)
store = store_map.get(entity_type)
if store is None:
logger.warning("Scaffold rollback: unknown entity type %r — skipping", entity_type)
continue
try:
store.delete(entity_id)
logger.info("Scaffold rollback: deleted %s %s", entity_type, entity_id)
except Exception as exc:
logger.error(
"Scaffold rollback: failed to delete %s %s%s",
entity_type,
entity_id,
exc,
)
# ---------------------------------------------------------------------------
# Route
# ---------------------------------------------------------------------------
@router.post(
"/api/v1/setup/scaffold",
response_model=ScaffoldResponse,
status_code=201,
tags=["Setup"],
)
async def scaffold_setup(
data: ScaffoldRequest,
_auth: AuthRequired,
device_store: DeviceStore = Depends(get_device_store),
template_store: TemplateStore = Depends(get_template_store),
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
output_target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
) -> ScaffoldResponse:
"""Create a ready-to-start LED capture chain.
Steps (each uses the real store create method for validation and ID gen):
1. Look up the existing device (404 if not found).
2. Find or create a capture template for the platform-best engine.
3. Create a raw picture source (``display_index`` + ``capture_template_id``).
4. Create a picture color-strip source with either the provided calibration
or ``create_default_calibration(led_count)``.
5. Create a LED output target linking the device to the CSS.
All created entities emit ``entity_changed`` events, but ONLY after the
full chain succeeds events are collected and fired at the very end.
On any error the entities created so far are deleted in reverse order
(rollback), and no "created" events are emitted (no ghost UI cards).
The output target is NOT started the frontend starts it after the
optional calibration step.
"""
created_ids: list[tuple[str, str]] = []
# Deferred "created" events: (entity_type, entity_id) — fired only on success.
pending_events: list[tuple[str, str]] = []
rollback_stores = dict(
template_store=template_store,
picture_source_store=picture_source_store,
css_store=css_store,
output_target_store=output_target_store,
manager=manager,
)
try:
# ── Step 1: resolve existing device ─────────────────────────────────
try:
device = device_store.get(data.device_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Device not found: {data.device_id}")
device_id = device.id
led_count = device.led_count
# ── Step 2: capture template ─────────────────────────────────────────
capture_template_id, template_reused = _get_or_create_capture_template(
template_store, created_ids
)
if not template_reused:
pending_events.append(("capture_template", capture_template_id))
# ── Step 3: picture source ───────────────────────────────────────────
ps_name = f"Screen {data.display_index} (scaffold)"
try:
picture_source = picture_source_store.create_stream(
name=ps_name,
stream_type="raw",
display_index=data.display_index,
capture_template_id=capture_template_id,
target_fps=_DEFAULT_TARGET_FPS,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
created_ids.append(("picture_source", picture_source.id))
pending_events.append(("picture_source", picture_source.id))
logger.info("Scaffold: created picture source %s", picture_source.id)
# ── Step 4: color-strip source ───────────────────────────────────────
if data.calibration is not None:
try:
calibration = calibration_from_dict(data.calibration)
except Exception as exc:
raise HTTPException(
status_code=422,
detail=f"Invalid calibration dict: {exc}",
)
else:
try:
calibration = create_default_calibration(led_count)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
css_name = "Screen capture (scaffold)"
try:
css = css_store.create_source(
name=css_name,
source_type="picture",
picture_source_id=picture_source.id,
calibration=calibration,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
created_ids.append(("color_strip_source", css.id))
pending_events.append(("color_strip_source", css.id))
logger.info("Scaffold: created color-strip source %s", css.id)
# ── Step 5: LED output target ────────────────────────────────────────
target_name = "LED output (scaffold)"
try:
target = output_target_store.create_wled_target(
name=target_name,
device_id=device_id,
color_strip_source_id=css.id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
created_ids.append(("output_target", target.id))
pending_events.append(("output_target", target.id))
logger.info("Scaffold: created output target %s", target.id)
# ── Step 5b: register target with ProcessorManager ───────────────────
try:
target.register_with_manager(manager)
except ValueError as exc:
logger.warning(
"Scaffold: could not register target %s in processor manager: %s",
target.id,
exc,
)
except HTTPException:
_rollback(created_ids, **rollback_stores)
raise
except Exception as exc:
logger.error("Scaffold: unexpected error — rolling back: %s", exc, exc_info=True)
_rollback(created_ids, **rollback_stores)
raise HTTPException(status_code=500, detail="Internal server error during scaffold")
# ── Full chain succeeded — fire all deferred "created" events ───────────
for entity_type, entity_id in pending_events:
fire_entity_event(entity_type, "created", entity_id)
logger.info(
"Scaffold complete: device=%s tpl=%s ps=%s css=%s target=%s",
device_id,
capture_template_id,
picture_source.id,
css.id,
target.id,
)
return ScaffoldResponse(
device_id=device_id,
capture_template_id=capture_template_id,
picture_source_id=picture_source.id,
color_strip_source_id=css.id,
output_target_id=target.id,
capture_template_reused=template_reused,
)
+219
View File
@@ -0,0 +1,219 @@
"""Aggregated snapshot endpoint for low-overhead polling clients.
Returns, in a single response, everything the Home Assistant integration's
coordinator needs per poll: all output targets with processing state + metrics,
all devices with brightness, the color-strip / value-source / scene-preset /
sync-clock lists, and the system block (performance, health, update).
This collapses the integration's previous ~2N+M request fan-out (per-target
``/state`` + ``/metrics`` and per-device ``/brightness``) into one round trip.
The handler delegates to the existing list/batch route handlers so the response
sub-shapes stay byte-identical to the individual endpoints no shaping logic is
duplicated here.
Callers that don't need the whole payload can pass ``?include=`` with a
comma-separated subset of section names (the response keys). Omitting it returns
every section. Gating is per section, so an excluded section also skips its
server-side work dropping ``device_brightness`` avoids cold-cache hardware
probes, and dropping ``system`` skips the (blocking) NVML performance query.
"""
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
get_color_strip_store,
get_device_store,
get_output_target_store,
get_playlist_engine,
get_processor_manager,
get_scene_playlist_store,
get_scene_preset_store,
get_sync_clock_manager,
get_sync_clock_store,
get_update_service,
get_value_source_store,
)
from ledgrab.api.schemas.update import UpdateStatusResponse
from ledgrab.utils import get_logger
from .color_strip_sources.crud import list_color_strip_sources
from .devices import list_devices, resolve_device_brightness
from .output_targets import batch_target_metrics, batch_target_states, list_targets
from .scene_playlists import list_scene_playlists
from .scene_presets import list_scene_presets
from .sync_clocks import list_sync_clocks
from .system import get_system_performance, health_check
from .update import get_update_status
from .value_sources import list_value_sources
logger = get_logger(__name__)
router = APIRouter()
# Selectable snapshot sections — these are exactly the response top-level keys,
# except ``scene_playlists`` which also emits a companion ``playlist_state`` key
# (the single global cycling state; see the handler).
SNAPSHOT_SECTIONS = (
"targets",
"target_states",
"target_metrics",
"devices",
"device_brightness",
"css_sources",
"value_sources",
"scene_presets",
"scene_playlists",
"sync_clocks",
"system",
)
_SECTION_SET = frozenset(SNAPSHOT_SECTIONS)
def _resolve_sections(include: str | None) -> frozenset[str]:
"""Validate the ``include`` query param into the set of sections to emit.
``None``/empty every section. Unknown names are rejected with 422 so a
typo fails loudly instead of silently returning a smaller payload.
"""
if not include:
return _SECTION_SET
requested = {part.strip() for part in include.split(",") if part.strip()}
unknown = requested - _SECTION_SET
if unknown:
raise HTTPException(
status_code=422,
detail=(
f"Unknown snapshot section(s): {', '.join(sorted(unknown))}. "
f"Valid sections: {', '.join(SNAPSHOT_SECTIONS)}."
),
)
return frozenset(requested)
async def _safe_section(awaitable, label: str):
"""Await a section, degrading to ``None`` on failure instead of 500-ing.
The snapshot is a resilience-oriented poll surface: one failing section
(e.g. NVML performance probing) must not fail the whole response. This
preserves the per-section fault isolation the HA coordinator relied on
before these calls were merged into one request the coordinator already
tolerates a ``None`` section.
"""
try:
return await awaitable
except Exception:
logger.warning("snapshot: section %r failed, returning null", label, exc_info=True)
return None
async def _update_status_model(_auth, update_service) -> UpdateStatusResponse:
"""Fetch update status and coerce it through the response model.
The standalone ``/system/update/status`` endpoint declares
``response_model=UpdateStatusResponse``; coercing here keeps the snapshot's
``system.update`` field identical to that endpoint rather than emitting the
service's raw dict unfiltered.
"""
raw = await get_update_status(_auth, update_service)
return UpdateStatusResponse.model_validate(raw)
@router.get("/api/v1/snapshot", tags=["Snapshot"])
async def get_snapshot(
request: Request,
_auth: AuthRequired,
include: str | None = Query(
None,
description=(
"Comma-separated subset of sections to include. Omit for all. "
"Valid: " + ", ".join(SNAPSHOT_SECTIONS)
),
),
manager=Depends(get_processor_manager),
target_store=Depends(get_output_target_store),
device_store=Depends(get_device_store),
css_store=Depends(get_color_strip_store),
value_store=Depends(get_value_source_store),
preset_store=Depends(get_scene_preset_store),
playlist_store=Depends(get_scene_playlist_store),
playlist_engine=Depends(get_playlist_engine),
clock_store=Depends(get_sync_clock_store),
clock_manager=Depends(get_sync_clock_manager),
update_service=Depends(get_update_service),
) -> dict[str, Any]:
"""Return the full poll payload (or a requested subset) in one response.
Shape (a key is present only when its section is requested)::
{
"targets": [<OutputTargetResponse>, ...],
"target_states": {target_id: <state>, ...},
"target_metrics": {target_id: <metrics>, ...},
"devices": [<DeviceResponse>, ...],
"device_brightness": {device_id: int | null, ...},
"css_sources": [...],
"value_sources": [...],
"scene_presets": [...],
"scene_playlists": [...],
"playlist_state": {...}, # companion to scene_playlists
"sync_clocks": [...],
"system": {"performance": {...}, "health": {...}, "update": {...}}
}
"""
sections = _resolve_sections(include)
result: dict[str, Any] = {}
if "targets" in sections:
result["targets"] = (await list_targets(_auth, target_store)).targets
if "target_states" in sections:
result["target_states"] = (await batch_target_states(_auth, manager))["states"]
if "target_metrics" in sections:
result["target_metrics"] = (await batch_target_metrics(_auth, manager))["metrics"]
if "devices" in sections:
result["devices"] = (await list_devices(_auth, device_store)).devices
if "device_brightness" in sections:
device_models = device_store.get_all_devices()
brightness_values = await asyncio.gather(
*(resolve_device_brightness(d, manager) for d in device_models),
return_exceptions=True,
)
result["device_brightness"] = {
model.id: (None if isinstance(value, BaseException) else value)
for model, value in zip(device_models, brightness_values)
}
if "css_sources" in sections:
css = await list_color_strip_sources(_auth, css_store, manager)
result["css_sources"] = css.sources
if "value_sources" in sections:
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
if "scene_presets" in sections:
result["scene_presets"] = (await list_scene_presets(_auth, preset_store)).presets
if "scene_playlists" in sections:
# One call returns both the playlist list (each with ``is_running``) and
# the single global cycling state (current index / preset / dwell). The
# state is emitted as a companion top-level key because it describes the
# one running playlist, not any individual list entry.
playlists = await list_scene_playlists(_auth, playlist_store, playlist_engine)
result["scene_playlists"] = playlists.playlists
result["playlist_state"] = playlists.state
if "sync_clocks" in sections:
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
result["sync_clocks"] = clocks.clocks
if "system" in sections:
result["system"] = {
"performance": await _safe_section(
run_in_threadpool(get_system_performance, _auth), "system.performance"
),
"health": await _safe_section(health_check(request), "system.health"),
"update": await _safe_section(
_update_status_model(_auth, update_service), "system.update"
),
}
return result
+7 -1
View File
@@ -149,6 +149,12 @@ async def delete_sync_clock(
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
_entity_name: str | None = None
try:
_entity_name = store.get_clock(clock_id).name
except Exception:
pass
try:
# Check references
for source in css_store.get_all_sources():
@@ -159,7 +165,7 @@ async def delete_sync_clock(
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
fire_entity_event("sync_clock", "deleted", clock_id, entity_name=_entity_name)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
+49
View File
@@ -39,8 +39,11 @@ from ledgrab.api.schemas.system import (
DisplayListResponse,
GpuInfo,
HealthResponse,
InstalledAppItem,
InstalledAppsResponse,
PerformanceResponse,
ProcessListResponse,
SystemInfoResponse,
VersionResponse,
)
from ledgrab.config import get_config, is_demo_mode
@@ -278,6 +281,52 @@ async def get_running_processes(_: AuthRequired):
raise HTTPException(status_code=500, detail="Internal server error")
@router.get(
"/api/v1/system/installed-apps",
response_model=InstalledAppsResponse,
tags=["Config"],
)
def get_installed_apps(_: AuthRequired):
"""List launchable apps for the application-rule app picker (Android only).
Returns launchable apps (package + human label) on Android, where the
foreground-app automation rule matches package names. Returns an empty list
on desktop, where the process picker (``/system/processes``) is used instead.
Sync ``def`` so FastAPI runs the (potentially blocking) bridge call in a
thread pool.
"""
from ledgrab.core.automations import platform_detector as pd
try:
apps = pd.list_installed_apps()
items = [InstalledAppItem(package=a["package"], label=a["label"]) for a in apps]
return InstalledAppsResponse(apps=items, count=len(items))
except Exception as e:
logger.error("Failed to list installed apps: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/system/info", response_model=SystemInfoResponse, tags=["Info"])
def get_system_info(_: AuthRequired):
"""Platform capability signal for the automation editor.
Tells the frontend whether the server is on Android (so the application-rule
editor uses the launchable-app picker + package matching and surfaces the
Usage-Access banner) vs desktop (process picker + process names), and whether
Usage Access is currently granted. Sync ``def`` so the bridge call runs in a
thread pool.
"""
from ledgrab.core.automations import platform_detector as pd
from ledgrab.utils.platform import is_android
android = is_android()
return SystemInfoResponse(
is_android=android,
app_match_kind="package" if android else "process",
usage_access_granted=(pd.has_usage_access() if android else True),
)
@router.get(
"/api/v1/system/performance",
response_model=PerformanceResponse,
@@ -1,4 +1,4 @@
"""System routes: MQTT, external URL, ADB, logs WebSocket, log level.
"""System routes: external URL, shutdown action, ADB, logs WebSocket, log level.
Extracted from system.py to keep files under 800 lines.
"""
@@ -17,100 +17,36 @@ from ledgrab.api.schemas.system import (
ExternalUrlResponse,
LogLevelRequest,
LogLevelResponse,
MQTTSettingsRequest,
MQTTSettingsResponse,
ShutdownAction,
ShutdownActionRequest,
ShutdownActionResponse,
)
from ledgrab.config import get_config
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
logger = get_logger(__name__)
def _record_setting(action: str, key: str, message: str) -> None:
"""Best-effort audit record for a high-value settings change."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
message=message,
metadata={"setting_key": key},
)
router = APIRouter()
# ---------------------------------------------------------------------------
# MQTT settings
# ---------------------------------------------------------------------------
def _load_mqtt_settings(db: Database) -> dict:
"""Load MQTT settings: YAML config defaults overridden by DB settings."""
cfg = get_config()
defaults = {
"enabled": cfg.mqtt.enabled,
"broker_host": cfg.mqtt.broker_host,
"broker_port": cfg.mqtt.broker_port,
"username": cfg.mqtt.username,
"password": cfg.mqtt.password,
"client_id": cfg.mqtt.client_id,
"base_topic": cfg.mqtt.base_topic,
}
overrides = db.get_setting("mqtt")
if overrides:
defaults.update(overrides)
return defaults
@router.get(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def get_mqtt_settings(_: AuthRequired, db: Database = Depends(get_database)):
"""Get current MQTT broker settings. Password is masked."""
s = _load_mqtt_settings(db)
return MQTTSettingsResponse(
enabled=s["enabled"],
broker_host=s["broker_host"],
broker_port=s["broker_port"],
username=s["username"],
password_set=bool(s.get("password")),
client_id=s["client_id"],
base_topic=s["base_topic"],
)
@router.put(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(
_: AuthRequired, body: MQTTSettingsRequest, db: Database = Depends(get_database)
):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings(db)
# If caller sends an empty password, keep the existing one
password = body.password if body.password else current.get("password", "")
new_settings = {
"enabled": body.enabled,
"broker_host": body.broker_host,
"broker_port": body.broker_port,
"username": body.username,
"password": password,
"client_id": body.client_id,
"base_topic": body.base_topic,
}
db.set_setting("mqtt", new_settings)
logger.info("MQTT settings updated")
return MQTTSettingsResponse(
enabled=new_settings["enabled"],
broker_host=new_settings["broker_host"],
broker_port=new_settings["broker_port"],
username=new_settings["username"],
password_set=bool(new_settings["password"]),
client_id=new_settings["client_id"],
base_topic=new_settings["base_topic"],
)
# ---------------------------------------------------------------------------
# External URL setting
# ---------------------------------------------------------------------------
@@ -199,6 +135,11 @@ async def update_shutdown_action(
"""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)
_record_setting(
"settings.changed",
"shutdown_action",
f"Shutdown action set to '{body.action}'",
)
return ShutdownActionResponse(action=body.action)
@@ -328,6 +269,17 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = (stdout.decode() + stderr.decode()).strip()
if "connected" in output.lower():
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.DEVICE,
action="device.adb_connected",
severity=ActivitySeverity.INFO,
message=f"ADB device connected: {sanitize_display(address)}",
metadata={"address": address},
)
return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed")
except FileNotFoundError:
@@ -358,6 +310,17 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.DEVICE,
action="device.adb_disconnected",
severity=ActivitySeverity.INFO,
message=f"ADB device disconnected: {sanitize_display(address)}",
metadata={"address": address},
)
return {"status": "disconnected", "message": stdout.decode().strip()}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH")
+7 -1
View File
@@ -183,6 +183,12 @@ async def delete_template(
Validates that no streams are currently using this template before deletion.
"""
_entity_name: str | None = None
try:
_entity_name = template_store.get_template(template_id).name
except Exception:
pass
try:
# Check if any streams are using this template
streams_using_template = []
@@ -203,7 +209,7 @@ async def delete_template(
# Proceed with deletion
template_store.delete_template(template_id)
fire_entity_event("capture_template", "deleted", template_id)
fire_entity_event("capture_template", "deleted", template_id, entity_name=_entity_name)
except HTTPException:
raise # Re-raise HTTP exceptions as-is
+37 -1
View File
@@ -12,6 +12,7 @@ from ledgrab.api.schemas.update import (
UpdateStatusResponse,
)
from ledgrab.core.update.update_service import UpdateService
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -42,6 +43,17 @@ async def dismiss_update(
service: UpdateService = Depends(get_update_service),
):
service.dismiss(body.version)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="update.dismissed",
severity=ActivitySeverity.INFO,
message=f"Update dismissed: {body.version}",
metadata={"version": body.version},
)
return {"ok": True}
@@ -63,6 +75,18 @@ async def apply_update(
)
try:
await service.apply_update()
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
version = status.get("available_version", "unknown")
rec.record(
category=ActivityCategory.SYSTEM,
action="update.applied",
severity=ActivitySeverity.INFO,
message=f"Update applied: {version}",
metadata={"version": version},
)
return {"ok": True, "message": "Update applied, server shutting down"}
except Exception as exc:
logger.error("Failed to apply update: %s", exc, exc_info=True)
@@ -83,8 +107,20 @@ async def update_update_settings(
body: UpdateSettingsRequest,
service: UpdateService = Depends(get_update_service),
):
return await service.update_settings(
result = await service.update_settings(
enabled=body.enabled,
check_interval_hours=body.check_interval_hours,
include_prerelease=body.include_prerelease,
)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="settings.changed",
severity=ActivitySeverity.INFO,
message=f"Update settings changed (enabled={body.enabled})",
metadata={"setting_key": "update", "enabled": body.enabled},
)
return result
+161 -4
View File
@@ -4,6 +4,7 @@ import asyncio
from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
@@ -27,6 +28,8 @@ from ledgrab.api.schemas.value_sources import (
StaticColorValueSourceResponse,
StaticValueSourceResponse,
SystemMetricsValueSourceResponse,
TemplateInput,
TemplateValueSourceResponse,
ValueSourceCreate,
ValueSourceListResponse,
ValueSourceResponse,
@@ -46,6 +49,7 @@ from ledgrab.storage.value_source import (
StaticColorValueSource,
StaticValueSource,
SystemMetricsValueSource,
TemplateValueSource,
ValueSource,
)
from ledgrab.storage.value_source_store import ValueSourceStore
@@ -170,6 +174,7 @@ _RESPONSE_MAP = {
min_ha_value=s.min_ha_value,
max_ha_value=s.max_ha_value,
smoothing=s.smoothing,
normalize=s.normalize,
),
GradientMapValueSource: lambda s: GradientMapValueSourceResponse(
id=s.id,
@@ -214,6 +219,7 @@ _RESPONSE_MAP = {
sensor_label=s.sensor_label,
poll_interval=s.poll_interval,
smoothing=s.smoothing,
normalize=s.normalize,
),
HTTPValueSource: lambda s: HTTPValueSourceResponse(
id=s.id,
@@ -230,6 +236,23 @@ _RESPONSE_MAP = {
min_value=s.min_value,
max_value=s.max_value,
smoothing=s.smoothing,
normalize=s.normalize,
),
TemplateValueSource: lambda s: TemplateValueSourceResponse(
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,
template=s.template,
inputs=[
TemplateInput(name=i["name"], value_source_id=i["value_source_id"]) for i in s.inputs
],
default_value=s.default_value,
eval_interval=s.eval_interval,
),
}
@@ -395,6 +418,13 @@ async def delete_value_source(
if getattr(target, "brightness_value_source_id", "") == source_id:
raise ValueError(f"Cannot delete: referenced by target '{target.name}'")
# Check if any other value source (template / gradient_map) references it.
referencing = store.find_referencing_sources(source_id)
if referencing:
raise ValueError(
"Cannot delete: referenced by value source(s) " + ", ".join(referencing)
)
store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id)
except EntityNotFoundError as e:
@@ -404,6 +434,121 @@ async def delete_value_source(
raise HTTPException(status_code=400, detail=str(e))
class ValidateTemplateRequest(BaseModel):
"""Request body for the advisory template-validation endpoint."""
template: str = Field(description="Jinja2 expression to validate", max_length=2000)
inputs: list[TemplateInput] = Field(default_factory=list, description="Named input bindings")
id: str | None = Field(None, description="Source id when editing (enables cycle detection)")
@router.post("/api/v1/value-sources/validate-template", tags=["Value Sources"])
async def validate_template_value_source(
payload: ValidateTemplateRequest,
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
):
"""Validate a template expression + inputs without persisting anything.
Advisory: always returns HTTP 200 with ``{valid, error, errors, warnings,
variables}``. Powers the live editor validator (which must run before a
source exists), reusing the exact factory/store validation so the client and
server can never disagree. ``errors`` are blocking (save disabled);
``warnings`` are non-blocking (e.g. unknown/unbound inputs create is
lenient about those).
"""
from ledgrab.utils.template_expr import (
TemplateValidationError,
extract_variables,
validate_input_name,
validate_template_expression,
)
errors: list[str] = []
warnings: list[str] = []
# 1) Expression compiles and is safe (cost-guarded).
try:
validate_template_expression(payload.template)
except TemplateValidationError as e:
errors.append(str(e))
# 2) Input names valid / unique / non-reserved (blocking).
seen: set[str] = set()
for inp in payload.inputs:
try:
validate_input_name(inp.name)
except TemplateValidationError as e:
errors.append(str(e))
continue
if inp.name in seen:
errors.append(f"duplicate input name: {inp.name}")
seen.add(inp.name)
# 3) Referenced sources exist (non-blocking warning — create is lenient).
missing = [
inp.value_source_id
for inp in payload.inputs
if inp.value_source_id and not _source_exists(store, inp.value_source_id)
]
if missing:
warnings.append("unknown value source(s): " + ", ".join(sorted(set(missing))))
# 4) Variables referenced in the expression but not bound to an input
# (blocking): at runtime they raise UndefinedError, so the template would
# silently always return default_value. This is almost always a typo, so
# flag it as an error rather than letting "valid" mislead the user.
used = set(extract_variables(payload.template))
undeclared = used - seen
if undeclared:
errors.append("unbound variable(s): " + ", ".join(sorted(undeclared)))
# 5) Cycle check when editing an existing source (blocking).
if payload.id:
child_ids = [i.value_source_id for i in payload.inputs if i.value_source_id]
try:
store.validate_nesting(payload.id, child_ids)
except ValueError as e:
errors.append(str(e))
return {
"valid": not errors,
"error": errors[0] if errors else None,
"errors": errors,
"warnings": warnings,
"variables": extract_variables(payload.template),
}
def _source_exists(store: ValueSourceStore, source_id: str) -> bool:
try:
store.get_source(source_id)
return True
except Exception:
return False
# Per-stream (min, max) attribute pairs for the normalization range, so the
# preview can show where the raw value maps. Attribute names differ per stream
# type (historical), so probe each pair rather than assume one.
_RAW_RANGE_ATTRS: tuple[tuple[str, str], ...] = (
("_min_ha", "_max_ha"), # HAEntityValueStream
("_min_value", "_max_value"), # HTTPValueStream
("_min_val", "_max_val"), # SystemMetricsValueStream
("_min_game", "_max_game"), # GameEventValueStream
)
def _stream_raw_range(stream) -> list | None:
"""Return ``[min, max]`` for the stream's normalization range, or None."""
for lo_attr, hi_attr in _RAW_RANGE_ATTRS:
lo = getattr(stream, lo_attr, None)
hi = getattr(stream, hi_attr, None)
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
return [lo, hi]
return None
# ===== REAL-TIME VALUE SOURCE TEST WEBSOCKET =====
@@ -467,10 +612,22 @@ async def test_value_source_ws(
msg["input_value"] = round(stream.get_input_value(), 4)
if hasattr(stream, "get_raw_value"):
raw = stream.get_raw_value()
if raw is not None:
msg["raw_value"] = round(raw, 4)
if hasattr(stream, "_min_ha"):
msg["raw_range"] = [stream._min_ha, stream._max_ha]
if isinstance(raw, bool):
# bool is a subclass of int — send as-is (don't coerce/round).
msg["raw_value"] = raw
elif isinstance(raw, (int, float)):
msg["raw_value"] = round(float(raw), 4)
elif raw is not None:
# Non-numeric raw (e.g. an HTTP string payload) — send verbatim
# rather than crash the socket on round().
msg["raw_value"] = raw
rng = _stream_raw_range(stream)
if rng is not None:
msg["raw_range"] = rng
# Tell the client whether this source is currently normalizing, so the
# preview can render the value as a fraction vs a clamped passthrough.
if hasattr(stream, "_normalize_enabled"):
msg["normalized"] = bool(stream._normalize_enabled)
await websocket.send_json(msg)
await asyncio.sleep(0.05)
except WebSocketDisconnect:
@@ -0,0 +1,93 @@
"""Pydantic schemas for the activity-log API (Phase 4)."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Entry + page response
# ---------------------------------------------------------------------------
class ActivityLogEntryResponse(BaseModel):
"""Single audit-log entry.
Shape matches ``entry_to_dict()`` from
``ledgrab.core.activity_log.recorder`` exactly that function is the
single source of truth for serialisation; this schema documents the wire
format.
"""
id: str = Field(description="Entry id — 'al_<8-hex>'")
ts: str = Field(description="ISO-8601 UTC timestamp")
category: str = Field(description="Broad bucket (auth, device, entity, capture, system)")
action: str = Field(description="Verb-object label, e.g. 'entity.created'")
severity: str = Field(description="info | warning | error")
actor: str = Field(description="API-key label or 'system' / 'anonymous'")
entity_type: str | None = Field(default=None, description="Affected entity type, if applicable")
entity_id: str | None = Field(default=None, description="Affected entity id, if applicable")
entity_name: str | None = Field(
default=None, description="Entity name at time of event, if applicable"
)
message: str = Field(description="Human-readable description")
metadata: dict[str, Any] = Field(default_factory=dict, description="Extra structured context")
class ActivityLogPageResponse(BaseModel):
"""Paginated list of audit-log entries (keyset cursor)."""
entries: list[ActivityLogEntryResponse] = Field(description="Entries on this page")
next_before_seq: int | None = Field(
default=None,
description=(
"Pass as 'before_seq' in the next request to get the following page. "
"None when this is the last page."
),
)
has_more: bool = Field(
description="True when there are more entries before the first entry on this page"
)
total: int = Field(description="Total entries matching the current filters (all pages)")
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
_MAX_DAYS_CAP = 3650 # 10 years — sanity upper bound
_MAX_ENTRIES_CAP = 10_000_000 # 10 M rows — sanity upper bound
class ActivityLogSettingsResponse(BaseModel):
"""Current activity-log retention settings."""
enabled: bool = Field(description="Whether the activity log is recording")
max_days: int = Field(
ge=0,
le=_MAX_DAYS_CAP,
description="Retain entries for at most this many days (0 = no age-based pruning)",
)
max_entries: int = Field(
ge=0,
le=_MAX_ENTRIES_CAP,
description="Keep at most this many entries (0 = no count-based pruning)",
)
class UpdateActivityLogSettingsRequest(BaseModel):
"""Request body for PUT /settings."""
enabled: bool = Field(description="Enable or disable activity-log recording")
max_days: int = Field(
ge=0,
le=_MAX_DAYS_CAP,
description="Retain entries for at most this many days (0 = no age-based pruning)",
)
max_entries: int = Field(
ge=0,
le=_MAX_ENTRIES_CAP,
description="Keep at most this many entries (0 = no count-based pruning)",
)
+94 -2
View File
@@ -11,13 +11,55 @@ class RuleSchema(BaseModel):
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
# Application rule fields
apps: List[str] | None = Field(None, description="Process names (for application rule)")
apps: List[str] | None = Field(
None,
description=(
"App identifiers for the application rule. Platform-specific and not "
"portable: process names on Windows (e.g. 'chrome.exe'), package names "
"on Android (e.g. 'com.android.chrome'). Matched case-insensitively."
),
)
match_type: str | None = Field(
None, description="'running' or 'topmost' (for application rule)"
None,
description=(
"'running', 'topmost', 'fullscreen', or 'topmost_fullscreen' (application "
"rule). On Android only the foreground app is detectable, so all values "
"behave as 'foreground'."
),
)
# Time-of-day rule fields
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
days_of_week: list[int] | None = Field(
None,
description="Active weekdays for time_of_day rule (0=Mon..6=Sun). Empty/null = every day.",
)
timezone: str | None = Field(
None,
description=(
"IANA timezone for time_of_day / solar rules (e.g. 'Europe/Berlin'). "
"Empty = server local."
),
)
# Solar rule fields (days_of_week / timezone above are shared with time_of_day)
start_event: str | None = Field(
None, description="'sunrise' or 'sunset' — window start anchor (for solar rule)"
)
start_offset_minutes: int | None = Field(
None, description="Minutes added to the start event, ±1439 (for solar rule)"
)
end_event: str | None = Field(
None, description="'sunrise' or 'sunset' — window end anchor (for solar rule)"
)
end_offset_minutes: int | None = Field(
None, description="Minutes added to the end event, ±1439 (for solar rule)"
)
latitude: float | None = Field(
None, description="Latitude for solar timing, -90..90 (for solar rule)"
)
longitude: float | None = Field(
None, description="Longitude for solar timing, -180..180 (for solar rule)"
)
# System idle rule fields
idle_minutes: int | None = Field(
None, description="Idle timeout in minutes (for system_idle rule)"
@@ -66,6 +108,37 @@ class RuleSchema(BaseModel):
ConditionSchema = RuleSchema
class ActionSchema(BaseModel):
"""A single outbound action fired alongside scene activation/deactivation."""
action_type: str = Field(
max_length=32, description="Action type discriminator (e.g. 'webhook')"
)
# Webhook action fields
webhook_url: str | None = Field(
None, max_length=2048, description="Target URL for the webhook action"
)
method: str | None = Field(
None, max_length=8, description="'POST', 'PUT', or 'GET' (for webhook action)"
)
body_template: str | None = Field(
None,
max_length=8192,
description=(
"Request body template (for webhook action). Tokens: {{automation_name}}, "
"{{automation_id}}, {{event}}, {{timestamp}}."
),
)
content_type: str | None = Field(
None,
max_length=128,
description="Content-Type header for the webhook body (default application/json)",
)
fire_on: str | None = Field(
None, max_length=16, description="'activate', 'deactivate', or 'both' (for webhook action)"
)
class AutomationCreate(BaseModel):
"""Request to create an automation."""
@@ -81,6 +154,9 @@ class AutomationCreate(BaseModel):
None, description="Scene preset for fallback deactivation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
actions: List[ActionSchema] = Field(
default_factory=list, description="Outbound actions (e.g. webhooks)"
)
icon: str | None = Field(
None,
max_length=64,
@@ -106,6 +182,7 @@ class AutomationUpdate(BaseModel):
None, description="Scene preset for fallback deactivation"
)
tags: List[str] | None = None
actions: List[ActionSchema] | None = Field(None, description="Outbound actions (e.g. webhooks)")
icon: str | None = Field(
None,
max_length=64,
@@ -130,6 +207,9 @@ class AutomationResponse(BaseModel):
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
actions: List[ActionSchema] = Field(
default_factory=list, description="Outbound actions (e.g. webhooks)"
)
webhook_url: str | None = Field(
None, description="Webhook URL for the first webhook rule (if any)"
)
@@ -159,3 +239,15 @@ class AutomationListResponse(BaseModel):
automations: List[AutomationResponse] = Field(description="List of automations")
count: int = Field(description="Number of automations")
class AutomationTriggerResponse(BaseModel):
"""Result of manually triggering an automation."""
status: str = Field(
description="'triggered' (scene applied / nothing to apply), 'partial' "
"(applied with errors), 'skipped' (rules not satisfied), or 'error'."
)
errors: List[str] = Field(
default_factory=list, description="Per-target error messages, if any."
)
@@ -0,0 +1,104 @@
"""Pydantic schemas for the calibration session and solver API."""
from typing import Annotated, List, Literal
from pydantic import BaseModel, Field, model_validator
# ── Session lifecycle ─────────────────────────────────────────────────────────
class CalibrationSessionStartRequest(BaseModel):
"""Request to start a calibration session on a device."""
device_id: str = Field(description="ID of the device to drive during calibration")
class CalibrationSessionPositionRequest(BaseModel):
"""Request to advance the chase pixel to a specific LED index."""
index: int = Field(ge=0, description="LED index to illuminate (0-based)")
window: int = Field(
default=1,
ge=0,
le=10,
description="Number of dim neighbour LEDs to show on each side (0 = centre only)",
)
class CalibrationSessionStateResponse(BaseModel):
"""Current calibration session state."""
active: bool = Field(description="Whether a session is currently active")
device_id: str | None = Field(None, description="Device being driven (null if inactive)")
led_count: int = Field(0, description="LED count of the active device")
prior_target_id: str | None = Field(
None, description="Target that was running before the session (will be restored on stop)"
)
last_activity: str | None = Field(
None, description="ISO timestamp of the last position call (null if inactive)"
)
# ── Solver ────────────────────────────────────────────────────────────────────
class CalibrationSolveRequest(BaseModel):
"""Request to solve a CalibrationConfig from 4 corner tap indices.
Provide either *device_id* (the server derives led_count from the device)
or *led_count* directly. *device_id* takes precedence.
"""
device_id: str | None = Field(
None,
description=("Device ID to derive led_count from (preferred over led_count field)"),
)
led_count: int | None = Field(
None,
ge=1,
description="Total LED count (used when device_id is not provided)",
)
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
description="Starting corner of the strip"
)
layout: Literal["clockwise", "counterclockwise"] = Field(
description="Winding direction of the strip"
)
corner_indices: List[Annotated[int, Field(ge=0)]] = Field(
description=(
"Four strip indices — one per screen corner — in the strip-walk order "
"defined by (start_position, layout). Index 0 of the strip is the "
"start corner; the four tap positions are recorded in strip order "
"beginning from that start corner (the solver lays edges out from "
"led_start=0, so a non-zero physical start would require the `offset` "
"field rather than a shifted corner_indices[0]). Each element must be "
"non-negative (ge=0); out-of-range values yield a 422."
),
min_length=4,
max_length=4,
)
offset: int = Field(
default=0,
ge=0,
description="Physical LED offset (0 = no offset)",
)
@model_validator(mode="after")
def _require_device_or_led_count(self) -> "CalibrationSolveRequest":
if self.device_id is None and self.led_count is None:
raise ValueError("Either 'device_id' or 'led_count' must be provided")
return self
class CalibrationSolvedResponse(BaseModel):
"""Solved calibration config in simple-mode dict form."""
mode: Literal["simple"] = "simple"
layout: str = Field(description="Winding direction")
start_position: str = Field(description="Starting corner")
leds_top: int = Field(ge=0, description="LEDs on the top edge")
leds_right: int = Field(ge=0, description="LEDs on the right edge")
leds_bottom: int = Field(ge=0, description="LEDs on the bottom edge")
leds_left: int = Field(ge=0, description="LEDs on the left edge")
offset: int = Field(ge=0, description="Physical LED offset")
@@ -145,6 +145,10 @@ class EffectCSSResponse(_CSSResponseBase):
scale: Any = Field(description="Spatial scale")
mirror: bool = Field(description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool = Field(False, description="Modulate output by live audio loudness")
reactive_audio_source_id: str = Field("", description="AudioSource id driving reactivity")
reactive_mode: str = Field("brightness", description="brightness | saturation | both")
reactive_intensity: Any = Field(None, description="Reactive modulation strength (0-1)")
class CompositeCSSResponse(_CSSResponseBase):
@@ -332,6 +336,12 @@ class EffectCSSCreate(_CSSCreateBase):
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
reactive_mode: Literal["brightness", "saturation", "both"] | None = Field(
None, description="brightness | saturation | both"
)
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
class CompositeCSSCreate(_CSSCreateBase):
@@ -532,6 +542,12 @@ class EffectCSSUpdate(_CSSUpdateBase):
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
reactive_mode: Literal["brightness", "saturation", "both"] | None = Field(
None, description="brightness | saturation | both"
)
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
class CompositeCSSUpdate(_CSSUpdateBase):
+63
View File
@@ -59,6 +59,10 @@ class DeviceCreate(BaseModel):
hue_entertainment_group_id: str | None = Field(
None, description="Hue entertainment group/zone ID"
)
hue_gradient_mode: bool | None = Field(
None,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
# Yeelight fields
yeelight_min_interval_ms: int | None = Field(
None,
@@ -80,6 +84,10 @@ class DeviceCreate(BaseModel):
le=10000,
description="LIFX client-side rate limit between commands in ms (default 50)",
)
lifx_per_zone: bool | None = Field(
None,
description="Stream individual zones/tiles (multizone Z/Beam, Tile/Canvas) vs single colour",
)
# Govee fields
govee_min_interval_ms: int | None = Field(
None,
@@ -106,6 +114,9 @@ class DeviceCreate(BaseModel):
le=10000,
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
)
nanoleaf_per_panel: bool | None = Field(
None, description="Stream each panel individually via extControl UDP (vs single colour)"
)
# SPI Direct fields
spi_speed_hz: int | None = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
@@ -131,6 +142,11 @@ class DeviceCreate(BaseModel):
None,
description="Govee AES key (hex) — required for encrypted Govee firmware",
)
# MQTT (multi-broker) field
mqtt_source_id: str | None = Field(
None,
description="MQTT source (broker) ID for device_type=mqtt. Empty = first available broker.",
)
default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID"
)
@@ -190,6 +206,10 @@ class DeviceUpdate(BaseModel):
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")
hue_gradient_mode: bool | None = Field(
None,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
yeelight_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
)
@@ -199,6 +219,9 @@ class DeviceUpdate(BaseModel):
lifx_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
)
lifx_per_zone: bool | None = Field(
None, description="Stream individual zones/tiles (multizone/matrix) vs single colour"
)
govee_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
)
@@ -207,6 +230,9 @@ class DeviceUpdate(BaseModel):
nanoleaf_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
)
nanoleaf_per_panel: bool | None = Field(
None, description="Stream each panel individually via extControl UDP"
)
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")
@@ -217,6 +243,9 @@ class DeviceUpdate(BaseModel):
ble_govee_key: str | None = Field(
None, description="Govee AES key (hex) — required for encrypted Govee firmware"
)
mqtt_source_id: str | None = Field(
None, description="MQTT source (broker) ID for device_type=mqtt"
)
default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID"
)
@@ -336,6 +365,26 @@ class Calibration(BaseModel):
border_width: int = Field(
default=10, ge=1, le=100, description="Border width in pixels for edge sampling"
)
roi_x: float = Field(
default=0.0, ge=0.0, le=1.0, description="ROI left edge as a fraction of width (0..1)"
)
roi_y: float = Field(
default=0.0, ge=0.0, le=1.0, description="ROI top edge as a fraction of height (0..1)"
)
roi_width: float = Field(
default=1.0, gt=0.0, le=1.0, description="ROI width as a fraction of width (0..1)"
)
roi_height: float = Field(
default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)"
)
linear_blend: bool = Field(
default=False,
description="Blend border pixels in linear light instead of sRGB (perceptually correct)",
)
dither: bool = Field(
default=False,
description="Spatio-temporally dither the final 8-bit output to reduce gradient banding",
)
class CalibrationTestModeRequest(BaseModel):
@@ -408,11 +457,19 @@ class DeviceResponse(BaseModel):
),
)
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
hue_gradient_mode: bool = Field(
default=True,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
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")
lifx_per_zone: bool = Field(
default=False,
description="Stream individual zones/tiles (multizone/matrix) vs single colour",
)
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(
@@ -426,6 +483,9 @@ class DeviceResponse(BaseModel):
nanoleaf_min_interval_ms: int = Field(
default=100, description="Nanoleaf client-side rate limit in ms"
)
nanoleaf_per_panel: bool = Field(
default=False, description="Stream each panel individually via extControl UDP"
)
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
@@ -436,6 +496,9 @@ class DeviceResponse(BaseModel):
ble_govee_key: str = Field(
default="", description="Govee AES key (hex) — required for encrypted Govee firmware"
)
mqtt_source_id: str = Field(
default="", description="MQTT source (broker) ID for device_type=mqtt"
)
default_css_processing_template_id: str = Field(
default="", description="Default color strip processing template ID"
)
+20
View File
@@ -16,6 +16,15 @@ class MQTTSourceCreate(BaseModel):
password: str = Field(default="", description="Broker password (optional)")
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
publish_ha_discovery: bool = Field(
default=False, description="Publish Home Assistant MQTT auto-discovery configs"
)
discovery_prefix: str = Field(
default="homeassistant",
max_length=64,
pattern=r"^[A-Za-z0-9_\-/]+$",
description="HA MQTT discovery prefix (default 'homeassistant')",
)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: str | None = Field(
@@ -40,6 +49,15 @@ class MQTTSourceUpdate(BaseModel):
password: str | None = Field(None, description="Broker password")
client_id: str | None = Field(None, description="MQTT client ID")
base_topic: str | None = Field(None, description="Base topic prefix")
publish_ha_discovery: bool | None = Field(
None, description="Publish Home Assistant MQTT auto-discovery configs"
)
discovery_prefix: str | None = Field(
None,
max_length=64,
pattern=r"^[A-Za-z0-9_\-/]+$",
description="HA MQTT discovery prefix",
)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
@@ -65,6 +83,8 @@ class MQTTSourceResponse(BaseModel):
password_set: bool = Field(default=False, description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
publish_ha_discovery: bool = Field(default=False, description="HA MQTT discovery enabled")
discovery_prefix: str = Field(default="homeassistant", description="HA MQTT discovery prefix")
connected: bool = Field(default=False, description="Whether the broker connection is active")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -91,7 +91,11 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
protocol: str = Field(default="ddp", description="Send protocol (ddp, udp, or http)")
max_milliamps: int = Field(
default=0, description="ABL: PSU current budget in mA (0 = unlimited)"
)
milliamps_per_led: int = Field(default=55, description="ABL: full-white draw of one LED in mA")
class HALightOutputTargetResponse(_OutputTargetResponseBase):
@@ -233,8 +237,20 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
)
protocol: str = Field(
default="ddp",
pattern="^(ddp|http)$",
description="Send protocol: ddp (UDP) or http (JSON API)",
pattern="^(ddp|http|udp)$",
description="Send protocol: ddp (DDP/UDP), udp (WLED native realtime UDP), or http (JSON API)",
)
max_milliamps: int = Field(
default=0,
ge=0,
le=200000,
description="Automatic brightness limiting: PSU current budget in mA (0 = unlimited)",
)
milliamps_per_led: int = Field(
default=55,
ge=1,
le=200,
description="ABL: estimated full-white draw of a single LED, in mA",
)
@@ -370,7 +386,15 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase):
None, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str | None = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
None,
pattern="^(ddp|http|udp)$",
description="Send protocol: ddp (DDP/UDP), udp (WLED native realtime UDP), or http (JSON API)",
)
max_milliamps: int | None = Field(
None, ge=0, le=200000, description="ABL: PSU current budget in mA (0 = unlimited)"
)
milliamps_per_led: int | None = Field(
None, ge=1, le=200, description="ABL: full-white draw of one LED in mA"
)
@@ -70,6 +70,7 @@ class PostprocessingTemplateResponse(BaseModel):
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
is_builtin: bool = Field(default=False, description="True for read-only curated 'look' presets")
class PostprocessingTemplateListResponse(BaseModel):
@@ -0,0 +1,94 @@
"""Scene playlist API schemas."""
from datetime import datetime
from typing import List
from pydantic import BaseModel, Field
from ledgrab.storage.scene_playlist import (
MAX_DURATION_SECONDS,
MIN_DURATION_SECONDS,
)
class PlaylistItemSchema(BaseModel):
scene_preset_id: str = Field(min_length=1, description="Referenced scene preset id")
duration_seconds: float = Field(
default=30.0,
ge=MIN_DURATION_SECONDS,
le=MAX_DURATION_SECONDS,
description="How long to hold this scene before advancing",
)
class ScenePlaylistCreate(BaseModel):
"""Create a scene playlist."""
name: str = Field(description="Playlist name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
items: List[PlaylistItemSchema] = Field(
default_factory=list, description="Ordered playlist items"
)
loop: bool = Field(default=True, description="Restart from the first item after the last")
shuffle: bool = Field(default=False, description="Randomise item order each cycle")
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.",
)
class ScenePlaylistUpdate(BaseModel):
"""Update scene playlist metadata, items, and playback flags."""
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = Field(None, max_length=500)
items: List[PlaylistItemSchema] | None = Field(None, description="Replace the item list")
loop: bool | None = None
shuffle: bool | None = None
order: int | None = None
tags: List[str] | None = None
icon: str | None = Field(None, max_length=64)
icon_color: str | None = Field(None, max_length=32)
class PlaylistRuntimeStateSchema(BaseModel):
is_running: bool = False
playlist_id: str | None = None
playlist_name: str | None = None
current_index: int = 0
item_count: int = 0
current_preset_id: str | None = None
started_at: datetime | None = None
step_started_at: datetime | None = None
step_duration: float = 0.0
class ScenePlaylistResponse(BaseModel):
"""Scene playlist with items and runtime running flag."""
id: str
name: str
description: str
items: List[PlaylistItemSchema]
loop: bool
shuffle: bool
order: int
tags: List[str] = Field(default_factory=list)
icon: str | None = Field(None, max_length=64)
icon_color: str | None = Field(None, max_length=32)
is_running: bool = Field(default=False, description="True if this playlist is cycling now")
created_at: datetime
updated_at: datetime
class ScenePlaylistListResponse(BaseModel):
playlists: List[ScenePlaylistResponse]
count: int
state: PlaylistRuntimeStateSchema
+63
View File
@@ -0,0 +1,63 @@
"""Pydantic schemas for the setup scaffold endpoint."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class ScaffoldRequest(BaseModel):
"""Request body for ``POST /api/v1/setup/scaffold``.
Creates a full capture-to-output chain:
capture template picture source picture color-strip source LED output target
``device_id`` must reference an existing, validated device (created via
``POST /api/v1/devices``). The wizard flow is: discover/create the device
via the canonical device endpoint first, then call scaffold with the
resulting ``device_id``.
"""
# ── Existing device (required) ────────────────────────────────────────────
device_id: str = Field(
description="ID of an existing device to wire into the chain.",
)
# ── Capture / picture source ──────────────────────────────────────────────
display_index: int = Field(
0,
ge=0,
le=63,
description="Index of the monitor to capture (0 = primary; max 63).",
)
# ── Optional calibration override ─────────────────────────────────────────
calibration: dict[str, Any] | None = Field(
None,
description=(
"Optional CalibrationConfig dict to use for the color-strip source. "
"When omitted, ``create_default_calibration(led_count)`` is used."
),
)
class ScaffoldResponse(BaseModel):
"""IDs of every entity created (or reused) by the scaffold.
``capture_template_reused`` is ``True`` when the scaffold matched an
existing template by engine type instead of creating a new one.
The device is always pre-existing (created via the canonical device endpoint
before calling scaffold).
"""
device_id: str = Field(description="Device id (pre-existing).")
capture_template_id: str = Field(description="Capture template id.")
picture_source_id: str = Field(description="Raw picture source id.")
color_strip_source_id: str = Field(description="Picture color-strip source id.")
output_target_id: str = Field(description="LED output target id.")
capture_template_reused: bool = Field(
False,
description="True when an existing matching capture template was reused.",
)
+36 -29
View File
@@ -68,6 +68,42 @@ class ProcessListResponse(BaseModel):
count: int = Field(description="Number of unique processes")
class InstalledAppItem(BaseModel):
"""A launchable Android app, for the automation app picker."""
package: str = Field(description="Android package name, e.g. 'com.netflix.mediaclient'")
label: str = Field(description="Human-readable app label, e.g. 'Netflix'")
class InstalledAppsResponse(BaseModel):
"""Launchable apps for the application-rule picker (Android only; empty elsewhere)."""
apps: List[InstalledAppItem] = Field(description="Launchable apps, sorted by label")
count: int = Field(description="Number of apps")
class SystemInfoResponse(BaseModel):
"""Platform capability signal for the frontend (automation editor).
Lets the application-rule editor choose the right app source and matching
semantics per platform, and surface the Usage-Access permission state.
"""
is_android: bool = Field(description="True when the server runs on Android (Chaquopy)")
app_match_kind: Literal["process", "package"] = Field(
description=(
"What ApplicationRule.apps values represent: 'process' names on desktop, "
"'package' names on Android."
)
)
usage_access_granted: bool = Field(
description=(
"Android: whether PACKAGE_USAGE_STATS (Usage Access) is granted, gating "
"foreground-app detection. Always True (not applicable) off-Android."
)
)
class GpuInfo(BaseModel):
"""GPU performance information."""
@@ -158,35 +194,6 @@ class BackupListResponse(BaseModel):
count: int
# ─── MQTT schemas ──────────────────────────────────────────────
class MQTTSettingsResponse(BaseModel):
"""MQTT broker settings response (password is masked)."""
enabled: bool = Field(description="Whether MQTT is enabled")
broker_host: str = Field(description="MQTT broker hostname or IP")
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
username: str = Field(description="MQTT username (empty = anonymous)")
password_set: bool = Field(description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
class MQTTSettingsRequest(BaseModel):
"""MQTT broker settings update request."""
enabled: bool = Field(description="Whether MQTT is enabled")
broker_host: str = Field(description="MQTT broker hostname or IP")
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
username: str = Field(default="", description="MQTT username (empty = anonymous)")
password: str = Field(
default="", description="MQTT password (empty = keep existing if omitted)"
)
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
# ─── External URL schema ───────────────────────────────────────
@@ -10,6 +10,17 @@ from pydantic import BaseModel, Discriminator, Field, Tag
# =====================================================================
class TemplateInput(BaseModel):
"""A single ``{name -> value_source_id}`` binding for a template source."""
name: str = Field(
description="Variable name used in the expression (valid identifier)",
min_length=1,
max_length=64,
)
value_source_id: str = Field("", description="Bound value source ID (empty = unbound)")
class _ValueSourceResponseBase(BaseModel):
"""Shared fields for all value source responses."""
@@ -120,6 +131,9 @@ class HAEntityValueSourceResponse(_ValueSourceResponseBase):
min_ha_value: float = Field(description="Raw HA value mapped to output 0.0")
max_ha_value: float = Field(description="Raw HA value mapped to output 1.0")
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
normalize: bool = Field(
description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
)
class GradientMapValueSourceResponse(_ValueSourceResponseBase):
@@ -149,6 +163,9 @@ class SystemMetricsValueSourceResponse(_ValueSourceResponseBase):
sensor_label: str = Field(description="Sensor label for cpu_temp/fan_speed")
poll_interval: float = Field(description="Seconds between reads")
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
normalize: bool = Field(
description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
)
class HTTPValueSourceResponse(_ValueSourceResponseBase):
@@ -160,6 +177,22 @@ class HTTPValueSourceResponse(_ValueSourceResponseBase):
min_value: float = Field(description="Raw value mapped to output 0.0")
max_value: float = Field(description="Raw value mapped to output 1.0")
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
normalize: bool = Field(
description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
)
class TemplateValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["template"] = "template"
return_type: Literal["float"] = "float"
template: str = Field(description="Jinja2 expression")
inputs: List[TemplateInput] = Field(
default_factory=list, description="Named value-source bindings"
)
default_value: float = Field(description="Fallback when the expression errors (0.0-1.0)")
eval_interval: float | None = Field(
None, description="Re-eval throttle in seconds (None/0 = every poll)"
)
ValueSourceResponse = Annotated[
@@ -176,7 +209,8 @@ ValueSourceResponse = Annotated[
| Annotated[GradientMapValueSourceResponse, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceResponse, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")]
| Annotated[HTTPValueSourceResponse, Tag("http")],
| Annotated[HTTPValueSourceResponse, Tag("http")]
| Annotated[TemplateValueSourceResponse, Tag("template")],
Discriminator("source_type"),
]
@@ -292,6 +326,9 @@ class HAEntityValueSourceCreate(_ValueSourceCreateBase):
min_ha_value: float = Field(0.0, description="Raw HA value mapped to output 0.0")
max_ha_value: float = Field(100.0, description="Raw HA value mapped to output 1.0")
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
normalize: bool = Field(
True, description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
)
class GradientMapValueSourceCreate(_ValueSourceCreateBase):
@@ -318,6 +355,9 @@ class SystemMetricsValueSourceCreate(_ValueSourceCreateBase):
sensor_label: str = Field("", description="Sensor label for cpu_temp/fan_speed")
poll_interval: float = Field(1.0, description="Poll interval in seconds", ge=0.1, le=60.0)
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
normalize: bool = Field(
True, description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
)
class HTTPValueSourceCreate(_ValueSourceCreateBase):
@@ -328,6 +368,30 @@ class HTTPValueSourceCreate(_ValueSourceCreateBase):
min_value: float = Field(0.0, description="Raw value mapped to output 0.0")
max_value: float = Field(100.0, description="Raw value mapped to output 1.0")
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
normalize: bool = Field(
True, description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
)
class TemplateValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["template"] = "template"
template: str = Field(
description=(
"Jinja2 expression (no statements/blocks). Inputs are exposed by name and via "
"raw[name]; globals: min, max, abs, round, clamp(x, lo=0, hi=1)."
),
min_length=1,
max_length=2000,
)
inputs: List[TemplateInput] = Field(
default_factory=list, description="Named value-source bindings"
)
default_value: float = Field(
0.0, description="Fallback when the expression errors (0.0-1.0)", ge=0.0, le=1.0
)
eval_interval: float | None = Field(
None, description="Re-eval throttle in seconds (None/0 = every poll)", ge=0.0
)
ValueSourceCreate = Annotated[
@@ -344,7 +408,8 @@ ValueSourceCreate = Annotated[
| Annotated[GradientMapValueSourceCreate, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceCreate, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")]
| Annotated[HTTPValueSourceCreate, Tag("http")],
| Annotated[HTTPValueSourceCreate, Tag("http")]
| Annotated[TemplateValueSourceCreate, Tag("template")],
Discriminator("source_type"),
]
@@ -452,6 +517,9 @@ class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
min_ha_value: float | None = Field(None, description="Min HA value")
max_ha_value: float | None = Field(None, description="Max HA value")
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
normalize: bool | None = Field(
None, description="Rescale raw via min/max (false = clamp as-is)"
)
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
@@ -478,6 +546,9 @@ class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
sensor_label: str | None = Field(None, description="Sensor label")
poll_interval: float | None = Field(None, description="Poll interval", ge=0.1, le=60.0)
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
normalize: bool | None = Field(
None, description="Rescale raw via min/max (false = clamp as-is)"
)
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
@@ -488,6 +559,23 @@ class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
min_value: float | None = Field(None, description="Raw value mapped to 0.0")
max_value: float | None = Field(None, description="Raw value mapped to 1.0")
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
normalize: bool | None = Field(
None, description="Rescale raw via min/max (false = clamp as-is)"
)
class TemplateValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["template"] = "template"
template: str | None = Field(
None, description="Jinja2 expression", min_length=1, max_length=2000
)
inputs: List[TemplateInput] | None = Field(None, description="Named value-source bindings")
default_value: float | None = Field(
None, description="Fallback when the expression errors (0.0-1.0)", ge=0.0, le=1.0
)
eval_interval: float | None = Field(
None, description="Re-eval throttle in seconds (0 = every poll)", ge=0.0
)
ValueSourceUpdate = Annotated[
@@ -504,7 +592,8 @@ ValueSourceUpdate = Annotated[
| Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")]
| Annotated[HTTPValueSourceUpdate, Tag("http")],
| Annotated[HTTPValueSourceUpdate, Tag("http")]
| Annotated[TemplateValueSourceUpdate, Tag("template")],
Discriminator("source_type"),
]
+6
View File
@@ -68,6 +68,12 @@ class AuthConfig(BaseSettings):
"""Authentication configuration."""
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
# When True, the OpenAPI docs routes (/docs, /redoc, /openapi.json) load
# WITHOUT a Bearer token from any client (loopback and LAN). This exposes
# the API *surface* (route paths + parameter schemas), not data — actually
# invoking an endpoint from Swagger still requires the token via its
# "Authorize" button. All other endpoints stay protected. Default off.
expose_docs: bool = False
class AssetsConfig(BaseSettings):
@@ -0,0 +1,25 @@
"""Activity / audit log core — recorder, retention engine, and actor context.
Public surface
--------------
``ActivityRecorder`` thread-safe facade; persists entries and fires live events.
``ActivityLogRetentionEngine`` background pruning engine (mirrors AutoBackupEngine).
``current_actor`` ``ContextVar[str]`` set by the auth layer per request.
Quick start
-----------
Wired in ``main.py`` lifespan; injected via ``api/dependencies.py`` getters.
Phase 3 adds the instrumentation call sites.
"""
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
__all__ = [
"ActivityRecorder",
"ActivityLogRetentionEngine",
"current_actor",
"sanitize_display",
]
@@ -0,0 +1,34 @@
"""Actor context variable for the activity log.
``current_actor`` is set by ``api/auth.py:verify_api_key`` so that
``ActivityRecorder.record(...)`` can resolve the actor without requiring every
call site to pass it explicitly.
Default value is ``"system"`` used by background engines and any code path
that runs outside a request context (e.g. lifespan startup/shutdown, zeroconf
discovery thread).
Per-request isolation is provided by ASGI/anyio ContextVar copy semantics:
Starlette dispatches each request in its own task whose context is a copy of
the parent, so a ``current_actor.set(...)`` in one request is never visible to
another request, and each request starts from the ``"system"`` default.
The auth layer only *sets* (never resets) the actor: ``verify_api_key`` calls
``current_actor.set(...)`` on the authenticated path and on the loopback-
anonymous path. It is an ``async`` dependency on purpose an async dependency
runs in the same task/context as the route handler, so the ``set`` is visible
to ``record(...)`` (a sync dependency would set it in a throwaway threadpool
context that the handler never sees). Routes without the ``verify_api_key``
dependency (e.g. the unauthenticated ``POST /api/v1/webhooks/{token}``) never
set it and therefore record as ``"system"``.
There is intentionally no explicit per-request reset do not rely on one. If
you run a recorder call in a worker thread that inherited a parent request's
context, pass an explicit ``actor=`` to ``record(...)`` rather than trusting
the ContextVar default.
"""
from contextvars import ContextVar
#: The actor label for the current request — API-key label or ``"system"``.
current_actor: ContextVar[str] = ContextVar("current_actor", default="system")
@@ -0,0 +1,273 @@
"""Thread-safe ActivityRecorder facade.
Responsibilities
----------------
1. Build an ``ActivityLogEntry`` from the caller-supplied fields.
2. Resolve the ``actor`` from the ``current_actor`` ContextVar when not given.
3. Persist the entry via ``ActivityLogRepository.record()`` on the event-loop
thread inline if already on that thread, via
``loop.call_soon_threadsafe`` if called from another thread (e.g. the
zeroconf discovery thread that fires ``device_discovered/lost`` events).
4. Push a live ``activity_logged`` event via
``ProcessorManager.fire_event({"type": "activity_logged", "entry": {...}})``.
5. Never raise into the caller audit recording is best-effort. Failures are
logged at ``WARNING`` level so operators can diagnose without breaking the
audited action.
Thread-marshal pattern mirrors ``utils/log_broadcaster.py`` (``ensure_loop`` /
``call_soon_threadsafe``).
Module accessor
---------------
A module-level singleton ``_recorder`` is populated by
``set_module_recorder()`` during ``main.py`` lifespan startup and exposed via
``get_module_recorder()``. Background engines and other non-DI sites that need
to call ``record()`` without FastAPI DI can use this accessor. Phase 3
instrumentation uses it at the ``fire_entity_event`` choke-point.
"""
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from ledgrab.core.activity_log.context import current_actor
from ledgrab.storage.activity_log import ActivityLogEntry, ActivitySeverity
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage.activity_log_repository import ActivityLogRepository
logger = get_logger(__name__)
def _new_id() -> str:
"""Generate an activity-log entry id: ``al_<32-hex-chars>``.
Uses the full 128-bit uuid4 hex. The ``id`` column is ``UNIQUE`` and a
collision is silently dropped (best-effort recorder), so the entropy must
be high enough that a collision is astronomically unlikely even against the
full retention window (default 20k live rows).
"""
return "al_" + uuid.uuid4().hex
def entry_to_dict(entry: ActivityLogEntry) -> dict:
"""Serialise an ``ActivityLogEntry`` to the canonical API/event payload dict.
Reused by Phase 4 (API response serialisation) and Phase 5 (frontend).
The shape is identical to the flat row codec minus the DB-only ``seq``
field, but with ``ts`` kept as an ISO-8601 string and ``metadata`` as a
real ``dict`` (not a JSON string).
"""
return {
"id": entry.id,
"ts": entry.ts.isoformat(),
"category": entry.category,
"action": entry.action,
"severity": entry.severity,
"actor": entry.actor,
"entity_type": entry.entity_type,
"entity_id": entry.entity_id,
"entity_name": entry.entity_name,
"message": entry.message,
"metadata": entry.metadata,
}
class ActivityRecorder:
"""Thread-safe facade for persisting audit log entries.
Parameters
----------
repo:
``ActivityLogRepository`` used to persist entries.
processor_manager:
``ProcessorManager`` whose ``fire_event`` dispatches the live
``activity_logged`` event to WebSocket subscribers.
loop:
Optional: the running asyncio event loop. If ``None``, it is
captured lazily on the first call that originates from an async
context (mirroring ``LogBroadcaster.ensure_loop``). Pass it
explicitly in tests to avoid depending on a real running loop.
"""
def __init__(
self,
repo: "ActivityLogRepository",
processor_manager: "ProcessorManager",
*,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
self._repo = repo
self._pm = processor_manager
self._loop: asyncio.AbstractEventLoop | None = loop
self._enabled: bool = True
# ── Loop capture (mirrors LogBroadcaster.ensure_loop) ──────────────────
def ensure_loop(self) -> None:
"""Capture the running event loop if not already stored.
Call from an async context (e.g. lifespan startup) so that
``call_soon_threadsafe`` works when ``record()`` is later called from
non-async threads.
"""
if self._loop is None:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
pass
# ── Public API ──────────────────────────────────────────────────────────
@property
def enabled(self) -> bool:
"""Whether recording is currently active."""
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
self._enabled = value
def record(
self,
category: str,
action: str,
*,
severity: str = ActivitySeverity.INFO,
actor: str | None = None,
entity_type: str | None = None,
entity_id: str | None = None,
entity_name: str | None = None,
message: str,
metadata: dict | None = None,
_bypass_enabled: bool = False,
) -> None:
"""Append one audit entry — best-effort, never raises.
Parameters
----------
category:
Broad bucket one of :class:`~ledgrab.storage.activity_log.ActivityCategory`.
action:
Verb-object label, e.g. ``"entity.created"`` or ``"server.shutting_down"``.
severity:
One of :class:`~ledgrab.storage.activity_log.ActivitySeverity`. Defaults
to ``"info"``.
actor:
Who triggered the action. When ``None`` (the common case), the
value is resolved from :data:`~ledgrab.core.activity_log.context.current_actor`
with a default of ``"system"``.
entity_type / entity_id / entity_name:
Optional entity context for entity-domain events.
message:
Human-readable description suitable for display.
metadata:
Small JSON-serialisable dict with extra context. Defaults to ``{}``.
_bypass_enabled:
Internal flag used by the retention engine to record the
"audit log disabled" event even when ``enabled`` is ``False``.
"""
if not self._enabled and not _bypass_enabled:
return
# Resolve actor from ContextVar when not explicitly supplied.
resolved_actor = actor if actor is not None else current_actor.get()
entry = ActivityLogEntry(
id=_new_id(),
ts=datetime.now(timezone.utc),
category=category,
action=action,
severity=severity,
actor=resolved_actor,
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
message=message,
metadata=metadata or {},
)
# Determine whether we are on the event-loop thread or not.
loop = self._loop
if loop is None:
# Lazy capture — may fail if called before the loop is running.
try:
loop = asyncio.get_running_loop()
self._loop = loop
except RuntimeError:
pass
if loop is not None and loop.is_running():
try:
current = asyncio.get_event_loop()
except RuntimeError:
current = None
# If the current thread IS the event-loop thread, write inline.
if current is loop:
self._write_and_emit(entry)
else:
# Called from a non-loop thread (e.g. zeroconf discovery) —
# marshal onto the event-loop thread.
try:
loop.call_soon_threadsafe(self._write_and_emit, entry)
except RuntimeError:
# Loop has been closed (rare; happens during tests)
logger.warning(
"ActivityRecorder: event loop closed, dropping entry %s", entry.id
)
else:
# No running loop — fall back to a direct synchronous write.
# This path hits in synchronous unit tests that do not start a loop.
self._write_and_emit(entry)
def _write_and_emit(self, entry: ActivityLogEntry) -> None:
"""Persist *entry* and fire the live event — called on the loop thread."""
try:
self._repo.record(entry)
except Exception as exc:
logger.warning("ActivityRecorder: failed to persist entry %s: %s", entry.id, exc)
return # don't emit an event for an entry that failed to persist
try:
self._pm.fire_event(
{
"type": "activity_logged",
"entry": entry_to_dict(entry),
}
)
except Exception as exc:
logger.warning("ActivityRecorder: failed to fire live event for %s: %s", entry.id, exc)
# ── Module-level singleton accessor ────────────────────────────────────────
#
# Background engines and non-DI call sites (Phase 3's fire_entity_event hook,
# device discovery thread) need ``record()`` without going through FastAPI DI.
# ``set_module_recorder`` is called from ``main.py`` lifespan immediately after
# the recorder is wired into ``init_dependencies``.
_recorder: ActivityRecorder | None = None
def set_module_recorder(recorder: ActivityRecorder) -> None:
"""Store the application-level recorder in the module singleton.
Called once from ``main.py`` lifespan startup.
"""
global _recorder
_recorder = recorder
def get_module_recorder() -> ActivityRecorder | None:
"""Return the module-level recorder, or ``None`` if not yet initialised.
Callers must guard against ``None`` this returns ``None`` during module
import and early startup before ``main.py`` lifespan has run.
"""
return _recorder

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