Compare commits

...

37 Commits

Author SHA1 Message Date
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 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
164 changed files with 33498 additions and 9119 deletions
+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"
+13
View File
@@ -82,6 +82,19 @@ LedGrab speaks many protocols, so a single setup can drive everything from a DIY
- 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 (separate repository)
+69 -23
View File
@@ -1,54 +1,100 @@
## v0.8.1 (2026-05-28)
## v0.8.2 (2026-06-08)
### User-facing changes
#### Features
##### Multi-broker MQTT devices
##### WLED native realtime UDP output
- New realtime UDP sink speaking WLED's **DRGB / DRGBW / DNRGB** protocols, with automatic revert to the device's prior state when streaming stops ([7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec))
- The device editor now shows an MQTT **broker picker** for `device_type=mqtt` (in both the add-device and device-settings modals), wired into load / save / validate / dirty-check / clone. An empty selection means "first available broker"
- `mqtt_source_id` is now threaded end-to-end through `DeviceCreate` / `DeviceUpdate` / `DeviceResponse` and the device routes; the referenced broker is validated on create **and** update ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
##### Automatic brightness limiting (ABL) / power budget
- Per-LED power budgeting that caps total draw by scaling brightness to a configurable current/PSU limit, preventing brownouts on long strips ([ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156))
##### Schema-driven wiring-graph editor
##### Scene playlists
- Scenes can be grouped into **playlists with timed auto-cycling**, so a target can rotate through looks on a schedule ([f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e))
- Playlist + cycling state is included in the aggregated `/snapshot` response ([abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c))
- The visual graph editor now renders ports and edges generically from a backend-served schema (`GET /api/v1/graph/schema`) instead of hard-coding the connectable-field topology in two places — so client and server can no longer drift
- New `GET /api/v1/graph` returns the full nodes + edges + validation topology, and `GET /api/v1/graph/dependents/{kind}/{id}` reports what references an entity ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
##### Auto edge-calibration + guided first-run setup wizard
- Backend core for **automatic screen-edge calibration** ([0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8)), a one-call setup scaffold with an onboarding flag ([9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d)), and a browser-driven calibration UI ([9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688))
- A **guided first-run setup wizard** ties it together for new installs ([81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808)), with all-provider source discovery and a spatial corner picker ([dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38))
##### Aggregated snapshot endpoint
##### Region-of-interest (ROI) screen capture
- Screen sampling can now be cropped to a **region of interest** instead of the whole display ([ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546))
- New `GET /api/v1/snapshot` returns all output targets (with processing state + metrics), devices (with brightness), the source / preset / clock lists, and the system block in a **single response** — collapsing the Home Assistant integration's previous ~2N+M request fan-out into one round trip
- `?include=` fetches only a subset of sections, and an excluded section also skips its server-side work (e.g. cold-cache hardware brightness probes or the blocking NVML performance query) ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
##### Built-in "look" presets
- One-click looks: **Cinematic / Vivid / Cozy / Soft / Cool** ([e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c))
##### Weekday + timezone scheduling
- The time-of-day automation rule now supports **weekday selection and explicit timezones** ([1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac))
##### Value sources
- New **sandboxed-Jinja template combinator** for composing value sources ([6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9)) and optional normalization for magnitude sources ([669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20))
##### Visual graph editor
- The editor is now a **full wiring control surface** ([2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46)), and you can **duplicate a selected subgraph** server-side ([15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82))
##### Android on-device capture
- **System audio playback capture** ([fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1)), **OS notification capture** via NotificationListenerService ([0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83)), **webcam capture** via Camera2 ([4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6)), and a **foreground-app automation condition** ([1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2))
#### Bug Fixes
- **Graceful shutdown no longer hangs:** uvicorn's graceful-shutdown wait is now bounded (`GRACEFUL_SHUTDOWN_TIMEOUT`, shared by the desktop, Android, and demo launchers). A lingering events WebSocket (which the browser auto-reconnects) used to keep connections from draining, so the lifespan shutdown never ran — leaving LED targets lit and blocking process exit. Ctrl+C / OS shutdown with the UI open now reliably stops targets and checkpoints the DB ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **Device update error codes:** `update_device` no longer masks an intentional 4xx (e.g. an unknown `mqtt_source_id` or failed group validation) as a generic 500 ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **Security:** removed an active **weak default API key** from the shipped config — fresh installs no longer ship with a guessable key. Set your own key on first run ([5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5))
- Removed a broken legacy `/system/mqtt/settings` route ([fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201))
- Scene brightness value-source changes now sync to the live processor immediately ([02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3))
- Wizard hardening: scaffolded targets are registered with the ProcessorManager and the final review step is more robust ([6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05))
- Installer opens the WebUI only once after "Launch LedGrab" ([05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121))
---
### Development / Internal
#### Backend
- **Wiring-graph schema engine** (`api/graph_schema.py`): a pure, unit-tested module that is the single source of truth for which reference fields connect which entity kinds; builds the topology and performs dependency lookup plus cycle / dangling-reference detection without booting the app or any store. The route layer only gathers serialized entities and delegates ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **Structured access log:** a new middleware emits one structured line per request, attributing it to the authenticated token's friendly label (the key name, **never** the secret) so traffic can be traced to a client (e.g. `homeassistant` vs `android`). uvicorn's own access log is disabled to avoid duplicate lines ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- Shared `validate_mqtt_source_exists` (`_mqtt_validation.py`) deduplicates the MQTT-source existence check between the device and output-target routes ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
#### Backend / Storage
- `clone()` is now gated behind an **opt-in allowlist**, with expanded duplicate-handling tests ([498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f))
#### Frontend
- In-progress dashboard customization groundwork ([6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569))
- Service-worker refresh for the new bundle ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
#### Docs
- Actualized README + API reference with embedded screenshots ([12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6)), graph-editor wiring-control roadmap ([d505388](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d505388)), Android audio-capture design notes ([4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc)); removed stale ANDROID-REVIEW planning docs ([9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15))
#### Tests
- New suites: graph routes + schema engine, snapshot routes, access-log middleware, `mqtt_source_id` device regressions, and the bounded-shutdown entrypoint. Full suite: **1614 passing** ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- Large new suites for calibration solver/session (incl. adversarial), setup & scene-playlist routes, playlist engine, and ROI capture. Full suite: **2149 passing, 2 skipped**
---
<details>
<summary>All Commits (1)</summary>
<summary>All Commits (31)</summary>
| Hash | Message | Author |
| ---- | ------- | ------ |
| [a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba) | feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers | alexei.dolgolyov |
| [dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38) | fix(calibration-wizard): all-provider discovery + spatial corner picker | alexei.dolgolyov |
| [6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05) | fix(setup): register scaffolded target with ProcessorManager + final-review hardening | alexei.dolgolyov |
| [81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808) | feat(onboarding): guided first-run setup wizard (phase 4, final) | alexei.dolgolyov |
| [abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c) | feat(snapshot): include scene playlists + cycling state in snapshot | alexei.dolgolyov |
| [9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688) | feat(calibration): browser-driven auto edge-calibration UI (phase 3) | alexei.dolgolyov |
| [9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d) | feat(setup): one-call setup scaffold + onboarding flag (phase 2) | alexei.dolgolyov |
| [0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8) | feat(calibration): auto edge-calibration backend core (phase 1) | alexei.dolgolyov |
| [6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569) | wip(dashboard): in-progress dashboard customization changes | alexei.dolgolyov |
| [f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e) | feat(scenes): scene playlists with timed auto-cycling | alexei.dolgolyov |
| [ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546) | feat(capture): region-of-interest (ROI) crop for screen sampling | alexei.dolgolyov |
| [1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac) | feat(automations): weekday + timezone scheduling for time-of-day rule | alexei.dolgolyov |
| [e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c) | feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool) | alexei.dolgolyov |
| [7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec) | feat(wled): native realtime UDP output (DRGB/DRGBW/DNRGB) with auto-revert | alexei.dolgolyov |
| [ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156) | feat(targets): automatic brightness limiting (ABL) / per-LED power budget | alexei.dolgolyov |
| [02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3) | fix(scenes): sync brightness value-source change to live processor | alexei.dolgolyov |
| [fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201) | fix(api): remove broken legacy /system/mqtt/settings route | alexei.dolgolyov |
| [5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5) | fix(security): remove active weak default API key from shipped config | alexei.dolgolyov |
| [9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15) | docs(android): remove ANDROID-REVIEW planning/review docs | alexei.dolgolyov |
| [1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2) | feat(android): foreground-app automation condition | alexei.dolgolyov |
| [4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6) | feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine) | alexei.dolgolyov |
| [0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83) | feat(android): on-device OS notification capture (NotificationListenerService) | alexei.dolgolyov |
| [4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc) | docs(android): add audio-capture design + missing-functionality review | alexei.dolgolyov |
| [fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1) | feat(audio): Android on-device system playback capture | alexei.dolgolyov |
| [669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20) | feat(value-sources): optional normalization for magnitude sources | alexei.dolgolyov |
| [6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9) | feat(value-sources): add sandboxed-Jinja template combinator | alexei.dolgolyov |
| [12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6) | docs: actualize README and API reference, embed screenshots | alexei.dolgolyov |
| [498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f) | refactor(storage): gate clone() behind an opt-in allowlist; expand duplicate tests | alexei.dolgolyov |
| [15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82) | feat(graph): duplicate a selected subgraph server-side | alexei.dolgolyov |
| [2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46) | feat(graph): make the visual editor a full wiring control surface | alexei.dolgolyov |
| [05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121) | fix(installer): open WebUI once after "Launch LedGrab" | alexei.dolgolyov |
</details>
+5 -1
View File
@@ -41,7 +41,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.8.1"
versionName = "0.8.2"
// ABI selection. Detect armeabi-v7a wheel presence and opt the
// ABI in only when the matching pydantic-core wheel is on disk —
@@ -210,6 +210,10 @@ dependencies {
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")
+2 -1
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
@@ -1,7 +1,10 @@
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
/**
@@ -23,8 +26,23 @@ import java.security.SecureRandom
*/
class ApiKeyManager(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
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). [encrypted] records which
// path we took so we don't repeatedly attempt migration.
private val encrypted: Boolean
private val prefs: SharedPreferences
init {
val (store, isEncrypted) = buildPrefs(appContext)
prefs = store
encrypted = isEncrypted
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
@@ -60,6 +78,20 @@ class ApiKeyManager(context: Context) {
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
@@ -74,6 +106,88 @@ class ApiKeyManager(context: Context) {
}
}
/**
* 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 {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val store = EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
store to true
} catch (e: Exception) {
// Keystore unavailable/corrupt — 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 unavailable, using plain prefs: ${e.message}")
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
}
}
/**
* 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
* when on the plain-prefs path (no legacy/encrypted split) or no valid key
* survives. Guarantees [getOrCreateKey] never rotates an existing key as long
* as the legacy file survives.
*/
private fun recoverLegacyKey(): String? {
if (!encrypted) return null
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)
@@ -88,7 +202,11 @@ class ApiKeyManager(context: Context) {
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
@@ -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)")
@@ -105,12 +105,48 @@ class CaptureService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
// CRITICAL: startForeground must be called IMMEDIATELY — before
// any other work, especially before getMediaProjection(). The
// service type must match the work; pass it explicitly via
// ServiceCompat so we stay compatible back to API 24.
// 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 {
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
var t = if (useRoot) {
@@ -152,20 +188,13 @@ class CaptureService : Service() {
// otherwise `isRunning=true` sticks forever when startForeground throws.
isRunning = true
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)
@@ -294,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
@@ -6,7 +6,9 @@ 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
@@ -25,7 +27,20 @@ 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.
private val pushExecutor = Executors.newSingleThreadExecutor()
//
// 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()
// packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working.
@@ -51,17 +66,34 @@ class LedGrabNotificationListener : NotificationListenerService() {
val label = resolveAppLabel(notification.packageName)
pushExecutor.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}")
// 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()
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
}
}
}
@@ -69,24 +101,59 @@ class LedGrabNotificationListener : NotificationListenerService() {
/** 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()
}.getOrDefault(pkg)
labelCache[pkg] = resolved
return resolved
}.getOrNull()
if (resolved != null) {
labelCache[pkg] = resolved
return resolved
}
return pkg
}
/**
* Return the push executor, creating it under [executorLock] if absent.
* 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) {
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.
ensureExecutor()
}
override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected")
// Tear the executor down on disconnect; a fresh one is created on the
// next onListenerConnected. Null out first so any in-flight
// onNotificationPosted snapshots see null (skips submit) rather than
// racing a shutdown executor.
pushExecutor?.let { exec ->
pushExecutor = null
exec.shutdown()
}
}
override fun onDestroy() {
pushExecutor.shutdown()
// Defensive: onListenerDisconnected normally clears this first, but
// shut down here too in case onDestroy fires without a prior disconnect.
pushExecutor?.shutdown()
pushExecutor = null
super.onDestroy()
}
@@ -34,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
/**
@@ -56,6 +57,7 @@ class MainActivity : Activity() {
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"
@@ -189,7 +191,13 @@ class MainActivity : Activity() {
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
@@ -214,6 +222,7 @@ class MainActivity : Activity() {
ensureNotificationPermission()
ensureNotificationListenerAccess()
ensureCameraPermission()
ensureBluetoothPermissions()
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
updateUI()
}
@@ -236,6 +245,7 @@ class MainActivity : Activity() {
ensureNotificationListenerAccess()
ensureAudioPermission()
ensureCameraPermission()
ensureBluetoothPermissions()
val intent = CaptureService.createIntent(this, resultCode, resultData)
ContextCompat.startForegroundService(this, intent)
updateUI()
@@ -536,6 +546,30 @@ class MainActivity : Activity() {
}
}
/**
* 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)
@@ -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
}
@@ -89,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) {
@@ -178,6 +179,14 @@ class RootScreenrecord(
buffer.get(frameBuffer, row * rowBytes, rowBytes)
}
}
// 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) {
@@ -147,6 +147,14 @@ class ScreenCapture(
}
}
// 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
+156 -2
View File
@@ -42,6 +42,7 @@ Complete REST + WebSocket API reference for the LedGrab server.
- [Weather sources](#weather-sources)
- [Automations](#automations)
- [Scene presets](#scene-presets)
- [Scene playlists](#scene-playlists)
- [Sync clocks](#sync-clocks)
- [Webhooks](#webhooks)
- [HTTP endpoints](#http-endpoints)
@@ -184,7 +185,7 @@ Server configuration: MQTT broker, external URL, shutdown action, log level, ADB
## User preferences
Dashboard layout, notification settings, card display modes, and the global daylight timezone.
Dashboard layout, notification settings, card display modes, the global daylight timezone, and the first-run onboarding flag.
| Method | Path | Description |
| ------ | ---- | ----------- |
@@ -198,6 +199,19 @@ Dashboard layout, notification settings, card display modes, and the global dayl
| DELETE | `/api/v1/preferences/card-modes` | Delete card-mode preferences; revert to defaults. |
| GET | `/api/v1/preferences/daylight-timezone` | Read the global IANA timezone for daylight cycles. |
| PUT | `/api/v1/preferences/daylight-timezone` | Persist the daylight-cycle timezone (empty = server local). |
| GET | `/api/v1/preferences/onboarding` | Read the first-run onboarding flag (`onboarded: bool`, `completed_at: str\|null`). Defaults to `false`. |
| PUT | `/api/v1/preferences/onboarding` | Persist the onboarding flag. Server auto-stamps `completed_at` when `onboarded` is set to `true` without a timestamp. |
**Onboarding flag response shape:**
```json
{
"onboarded": true,
"completed_at": "2026-06-08T12:00:00.000000+00:00"
}
```
Defaults to `{"onboarded": false, "completed_at": null}` when never set.
## Backup, restore & server control
@@ -237,7 +251,7 @@ A single aggregated poll endpoint for low-overhead clients.
| Method | Path | Description |
| ------ | ---- | ----------- |
| GET | `/api/v1/snapshot` | Full poll payload (targets, states, metrics, devices, brightness, color/value sources, scene presets, sync clocks, system) in one response. Use `?include=` to request a subset; per-section fault isolation. |
| GET | `/api/v1/snapshot` | Full poll payload (targets, states, metrics, devices, brightness, color/value sources, scene presets, scene playlists + cycling state, sync clocks, system) in one response. Use `?include=` to request a subset; per-section fault isolation. |
## Devices
@@ -518,6 +532,25 @@ Captured snapshots of target state that can be restored.
| POST | `/api/v1/scene-presets/{preset_id}/recapture` | Re-capture current state into the preset. |
| POST | `/api/v1/scene-presets/{preset_id}/activate` | Activate the preset (restore captured state). |
## Scene playlists
Ordered, timed sequences of scene presets that auto-cycle. The engine drives
**one** playlist at a time — starting a playlist stops any other. Each item
references a scene preset and holds it for its `duration_seconds` (min 1s)
before advancing; `loop` repeats from the start and `shuffle` randomises the
order each cycle.
| Method | Path | Description |
| ------ | ---- | ----------- |
| POST | `/api/v1/scene-playlists` | Create a playlist (items reference scene presets). |
| GET | `/api/v1/scene-playlists` | List all playlists plus the current cycling `state`. |
| GET | `/api/v1/scene-playlists/state` | Get the current cycling state (idle if nothing runs). |
| GET | `/api/v1/scene-playlists/{playlist_id}` | Get a playlist by ID. |
| PUT | `/api/v1/scene-playlists/{playlist_id}` | Update metadata, items, and `loop`/`shuffle`. |
| DELETE | `/api/v1/scene-playlists/{playlist_id}` | Delete a playlist (stops it first if running). |
| POST | `/api/v1/scene-playlists/{playlist_id}/start` | Start cycling (stops any other playlist first). |
| POST | `/api/v1/scene-playlists/stop` | Stop the active playlist (leaves the last scene applied). |
## Sync clocks
Shared clocks that drive linked animations with configurable speed.
@@ -629,6 +662,127 @@ The wiring-graph: schema registry, topology, dependents, validation, and subgrap
| POST | `/api/v1/graph/validate-connection` | Validate a proposed wiring edit (existence, kind, no cycle). |
| POST | `/api/v1/graph/duplicate` | Deep-clone selected value/color-strip sources with remapped wiring. |
## Calibration
Guided LED chase and auto-solver for the `CalibrationConfig` stored on a
color-strip source. The flow is:
1. **Start** a session (`POST /session`) — stops any running target on the
device and remembers it for restore on stop.
2. **Position** the chase pixel (`POST /session/position`) to walk through
each physical corner and record the LED index.
3. **Solve** (`POST /solve`) — the server computes per-edge LED counts.
4. **Persist** — call `PUT /api/v1/color-strip-sources/{id}` with the solved
`calibration` object to save and hot-reload.
5. **Stop** (`POST /session/stop`) — clears the device and restores the prior
target.
| Method | Path | Description |
| ------ | ---- | ----------- |
| POST | `/api/v1/calibration/session` | Start a calibration session on a device (stops the running target, clears to black). |
| POST | `/api/v1/calibration/session/position` | Advance the chase pixel to LED `index``window` dim neighbours). |
| POST | `/api/v1/calibration/session/stop` | End the session: clear to black and restore the prior target. |
| POST | `/api/v1/calibration/session/cancel` | Alias for stop — no calibration is applied. |
| GET | `/api/v1/calibration/session/state` | Current session state (active, device_id, led_count, last_activity). |
| POST | `/api/v1/calibration/solve` | Solve per-edge LED counts from 4 corner tap indices. Returns solved config dict (does NOT persist). |
**Session state** response shape:
```json
{
"active": true,
"device_id": "dev_abc123",
"led_count": 100,
"prior_target_id": "ot_xyz456",
"last_activity": "2026-06-08T12:34:56.789Z"
}
```
**Solve request** (body):
```json
{
"device_id": "dev_abc123",
"start_position": "bottom_left",
"layout": "clockwise",
"corner_indices": [0, 30, 60, 80],
"offset": 0
}
```
`corner_indices` must be exactly 4 integers, one per screen corner, in the
strip-walk order defined by `(start_position, layout)`. Provide either
`device_id` (preferred — server derives `led_count`) or `led_count` directly.
**Important session behavior:**
- **Stops the running output target** — starting a calibration session immediately
stops any output target currently running on that device. Other clients driving
that device will lose their output for the duration of the session.
- **Single session only** — only one calibration session runs at a time across the
whole server. Starting a new session automatically ends the previous one (clearing
and restoring its device first), regardless of which device each session is on.
- **Idle auto-end** — a session that receives no `position` calls for ~60 seconds is
automatically stopped and the prior target restored, so devices are never left dark
indefinitely.
**Idle timeout:** a session that receives no `position` calls for 60 seconds
is automatically stopped and the prior target restored.
## Setup scaffold
One-call first-run helper that creates the full capture-to-output chain and
returns all entity ids. The wizard calls this, then starts the output target
after optional calibration.
| Method | Path | Description |
| ------ | ---- | ----------- |
| POST | `/api/v1/setup/scaffold` | Create capture template + picture source + color-strip source + LED output target in one atomic call with rollback on partial failure. Does NOT auto-start the target. |
**Wizard sequence (Phase 4):**
1. Discover or create the device via `POST /api/v1/devices` (full URL
normalisation + provider validation runs there).
2. Call `POST /api/v1/setup/scaffold` with the resulting `device_id`.
3. Calibrate (Phase 1 endpoints).
4. Start the output target via `POST /api/v1/output-targets/{id}/start`.
**Request body:**
```json
{
"device_id": "device_abc123",
"display_index": 0,
"calibration": null
}
```
`device_id` is **required** and must reference an existing device (created via
`POST /api/v1/devices`). `display_index` selects the monitor to capture
(0 = primary; range 063). `calibration` is an optional `CalibrationConfig`
dict; when omitted, `create_default_calibration(led_count)` is used.
**Response (201 Created):**
```json
{
"device_id": "device_abc123",
"capture_template_id": "tpl_11223344",
"picture_source_id": "ps_aabbccdd",
"color_strip_source_id": "css_11223344",
"output_target_id": "pt_aabbccdd",
"capture_template_reused": true
}
```
`capture_template_reused` is `true` when an existing template matched the
platform engine (no new template was created).
**Rollback:** if any step fails, all entities created within the same call are
deleted in reverse order so no orphans remain. The pre-existing device and any
reused template are never deleted. Entity "created" events are emitted only
after the full chain succeeds, so a rollback never produces ghost UI cards.
## Web UI & PWA
App-level routes served by FastAPI (not under `/api/v1`).
+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
+12
View File
@@ -28,6 +28,18 @@ API key authentication via Bearer token in the `Authorization` header (`Authoriz
- 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
+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.8.1"
version = "0.8.2"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+8
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
@@ -35,6 +36,9 @@ 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)
@@ -53,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)
@@ -70,5 +75,8 @@ 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"]
+149 -2
View File
@@ -3,18 +3,111 @@
import asyncio
import json
import secrets
import time
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-seen IP (lowest timestamp) is
# evicted so the dict stays bounded regardless of the number of distinct source
# IPs an attacker can forge.
_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
_auth_record_last: dict[str, float] = {}
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.
"""
now = time.monotonic()
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 single oldest entry.
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
oldest_ip = min(_auth_record_last, key=lambda ip: _auth_record_last[ip])
del _auth_record_last[oldest_ip]
_auth_record_last[client_ip] = now
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.
"""
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"},
)
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)
@@ -81,10 +174,12 @@ def verify_api_key(
# 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=(
@@ -97,13 +192,14 @@ 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
@@ -115,6 +211,7 @@ def verify_api_key(
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",
@@ -127,6 +224,9 @@ def verify_api_key(
# 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
@@ -135,6 +235,31 @@ def verify_api_key(
AuthRequired = Annotated[str, Depends(verify_api_key)]
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 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.
@@ -190,12 +315,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:
@@ -210,6 +353,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
@@ -275,6 +419,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:
@@ -332,6 +477,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(
{
@@ -343,10 +489,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:
+126 -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
@@ -40,6 +42,11 @@ 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 +117,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")
@@ -186,16 +201,83 @@ 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.
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).
"""
# Map entity_type → (_deps key, method name on the store)
_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"),
}
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 +290,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 +340,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,
@@ -245,6 +361,9 @@ def init_dependencies(
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 +381,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,
@@ -281,5 +402,8 @@ def init_dependencies(
"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,
}
)
@@ -0,0 +1,436 @@
"""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.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
# 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")
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 _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, # noqa: ARG001
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(description="Filter by actor label (exact match)"),
] = None,
entity_type: Annotated[
str | None,
Query(description="Filter by entity type (exact match)"),
] = None,
entity_id: Annotated[
str | None,
Query(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(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)
entries_plus = repo.query(filters, before_seq=before_seq, limit=effective_limit + 1)
has_more = len(entries_plus) > effective_limit
if has_more:
# Drop the oldest probe row; keep the newest `limit` entries.
entries = entries_plus[1:]
else:
entries = entries_plus
total = repo.count(filters)
# Compute next_before_seq: the seq of the oldest entry on this page.
# query() returns entries ascending (entries[0] is oldest); its seq is the
# cursor for the next page. The next request passes before_seq=X to get
# entries with seq < X, i.e. entries older than the oldest entry on this page.
# get_seq_for_id() does a cheap indexed point-lookup.
next_before_seq: int | None = None
if has_more and entries:
next_before_seq = repo.get_seq_for_id(entries[0].id)
return ActivityLogPageResponse(
entries=[entry_to_dict(e) for e in entries], # 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":
cell = json.dumps(d.get(col) or {})
else:
cell = str(d.get(col, "") or "")
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()] = None,
entity_type: Annotated[str | None, Query()] = None,
entity_id: Annotated[str | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None,
until: Annotated[datetime | None, Query()] = None,
q: Annotated[str | None, Query()] = 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))
+9 -1
View File
@@ -52,6 +52,8 @@ 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 "",
),
"system_idle": lambda: SystemIdleRule(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
@@ -327,6 +329,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 +343,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 =====
+30 -1
View File
@@ -27,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
@@ -35,6 +36,22 @@ 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]
@@ -143,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",
@@ -243,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(
@@ -257,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"}
@@ -267,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"}
@@ -300,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"])
@@ -365,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:
+8 -1
View File
@@ -701,6 +701,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:
@@ -728,7 +735,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:
@@ -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
@@ -57,6 +58,73 @@ _prev_states: dict[str, dict[str, Any]] = {}
_integration_stats: 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()
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]]:
"""Convert a JSON Schema object into a flat list of field descriptors.
@@ -387,7 +455,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:
@@ -405,6 +482,7 @@ async def ingest_event(
# Adapter-level auth check
headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
_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))
@@ -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,
@@ -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,
)
+19 -1
View File
@@ -30,7 +30,9 @@ 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,
@@ -43,6 +45,7 @@ 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
@@ -53,7 +56,9 @@ logger = get_logger(__name__)
router = APIRouter()
# Selectable snapshot sections — these are exactly the response top-level keys.
# 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",
@@ -63,6 +68,7 @@ SNAPSHOT_SECTIONS = (
"css_sources",
"value_sources",
"scene_presets",
"scene_playlists",
"sync_clocks",
"system",
)
@@ -135,6 +141,8 @@ async def get_snapshot(
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),
@@ -152,6 +160,8 @@ async def get_snapshot(
"css_sources": [...],
"value_sources": [...],
"scene_presets": [...],
"scene_playlists": [...],
"playlist_state": {...}, # companion to scene_playlists
"sync_clocks": [...],
"system": {"performance": {...}, "health": {...}, "update": {...}}
}
@@ -184,6 +194,14 @@ async def get_snapshot(
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
+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))
@@ -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
@@ -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)",
)
@@ -30,6 +30,14 @@ class RuleSchema(BaseModel):
# 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 rule (e.g. 'Europe/Berlin'). Empty = server local.",
)
# System idle rule fields
idle_minutes: int | None = Field(
None, description="Idle timeout in minutes (for system_idle rule)"
@@ -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")
+12
View File
@@ -344,6 +344,18 @@ 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)"
)
class CalibrationTestModeRequest(BaseModel):
@@ -91,7 +91,7 @@ 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)"
)
@@ -237,8 +237,8 @@ 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,
@@ -386,7 +386,9 @@ 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)"
@@ -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.",
)
-29
View File
@@ -194,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 ───────────────────────────────────────
+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,21 @@
"""Actor context variable for the activity log.
``current_actor`` is set by ``api/auth.py:verify_api_key`` on every request 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 guaranteed by ASGI's coroutine context: each request
runs in its own coroutine with its own copy of the context inherited from the
server's main task. The auth layer resets it on every request before the route
handler runs, so stale labels from a previous request cannot bleed into a new
one.
"""
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,267 @@
"""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 a compact activity-log entry id: ``al_<8-hex-chars>``."""
return "al_" + uuid.uuid4().hex[:8]
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
@@ -0,0 +1,216 @@
"""Activity log retention engine.
Mirrors ``core/backup/auto_backup.py``:
- Settings persisted via ``db.get_setting("activity_log")`` /
``db.set_setting("activity_log", {...})``.
- ``start()`` / ``stop()`` lifecycle following the engine convention used
throughout the codebase.
- Hourly background loop calling ``repo.prune(before_ts=..., max_entries=...)``.
- ``get_settings()`` / ``async update_settings(...)`` for the Settings API
(Phase 4).
Changing ``enabled`` to ``False`` records an ``"audit_log.disabled"`` event via
the recorder BEFORE the flag takes effect so the last action in the log is a
record of the intentional disable.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.storage.database import Database
logger = get_logger(__name__)
DEFAULT_SETTINGS: dict = {
"enabled": True,
"max_days": 90,
"max_entries": 20000,
}
# Prune loop interval — run roughly once an hour.
_PRUNE_INTERVAL_SECS = 3600
class ActivityLogRetentionEngine:
"""Background engine that prunes old activity log entries.
Parameters
----------
repo:
The ``ActivityLogRepository`` used to prune entries.
db:
The shared ``Database`` singleton for settings persistence.
recorder:
The ``ActivityRecorder`` used to log the "audit log disabled" event
before disabling takes effect.
"""
def __init__(
self,
repo: "ActivityLogRepository",
db: "Database",
recorder: "ActivityRecorder",
) -> None:
self._repo = repo
self._db = db
self._recorder = recorder
self._task: asyncio.Task | None = None
self._settings = self._load_settings()
# Rehydrate the recorder's enabled flag from persisted settings so a
# previously-disabled log stays disabled across restarts.
self._recorder.enabled = self._settings["enabled"]
# ── Settings persistence ───────────────────────────────────────────────
def _load_settings(self) -> dict:
data = self._db.get_setting("activity_log")
if data:
return {**DEFAULT_SETTINGS, **data}
return dict(DEFAULT_SETTINGS)
def _save_settings(self) -> None:
self._db.set_setting(
"activity_log",
{
"enabled": self._settings["enabled"],
"max_days": self._settings["max_days"],
"max_entries": self._settings["max_entries"],
},
)
# ── Lifecycle ──────────────────────────────────────────────────────────
async def start(self) -> None:
"""Start the retention loop if enabled."""
if self._settings["enabled"]:
self._start_loop()
logger.info(
"Activity log retention engine started " "(max_days=%d, max_entries=%d)",
self._settings["max_days"],
self._settings["max_entries"],
)
else:
logger.info("Activity log retention engine initialized (disabled)")
async def stop(self) -> None:
"""Cancel the retention loop."""
self._cancel_loop()
logger.info("Activity log retention engine stopped")
def _start_loop(self) -> None:
self._cancel_loop()
self._task = asyncio.create_task(self._retention_loop())
def _cancel_loop(self) -> None:
if self._task is not None:
self._task.cancel()
self._task = None
# ── Prune loop ─────────────────────────────────────────────────────────
async def _retention_loop(self) -> None:
try:
while True:
await asyncio.sleep(_PRUNE_INTERVAL_SECS)
try:
self._prune()
except Exception as exc:
logger.error("Activity log retention prune failed: %s", exc, exc_info=True)
except asyncio.CancelledError:
logger.debug("Activity log retention loop cancelled")
def _prune(self) -> None:
"""Execute one prune pass based on current settings."""
settings = self._settings
if not settings["enabled"]:
return
max_days: int = settings["max_days"]
max_entries: int = settings["max_entries"]
before_ts: datetime | None = None
if max_days and max_days > 0:
before_ts = datetime.now(timezone.utc) - timedelta(days=max_days)
max_entries_val: int | None = max_entries if max_entries and max_entries > 0 else None
deleted = self._repo.prune(before_ts=before_ts, max_entries=max_entries_val)
if deleted:
logger.info(
"Activity log pruned %d rows (max_days=%d, max_entries=%d)",
deleted,
max_days,
max_entries,
)
# ── Public API ─────────────────────────────────────────────────────────
def get_settings(self) -> dict:
"""Return the current retention settings dict."""
return {
"enabled": self._settings["enabled"],
"max_days": self._settings["max_days"],
"max_entries": self._settings["max_entries"],
}
async def update_settings(
self,
*,
enabled: bool,
max_days: int,
max_entries: int,
) -> dict:
"""Persist new settings and apply them immediately.
If ``enabled`` is changing to ``False``, the disable event is recorded
BEFORE the flag takes effect so there is a final log entry.
Returns the new settings dict (same as ``get_settings()``).
"""
was_enabled = self._settings["enabled"]
# Record the disable event before the recorder stops accepting entries.
if was_enabled and not enabled:
self._recorder.record(
category=ActivityCategory.SYSTEM,
action="audit_log.disabled",
severity=ActivitySeverity.WARNING,
actor="system",
message="Activity log recording disabled via settings",
_bypass_enabled=True,
)
self._settings["enabled"] = enabled
self._settings["max_days"] = max_days
self._settings["max_entries"] = max_entries
self._save_settings()
# Propagate enabled flag to the recorder.
self._recorder.enabled = enabled
if enabled:
self._start_loop()
logger.info(
"Activity log retention enabled (max_days=%d, max_entries=%d)",
max_days,
max_entries,
)
# Run an immediate prune pass when re-enabling.
try:
self._prune()
except Exception as exc:
logger.error("Activity log immediate prune failed: %s", exc)
else:
self._cancel_loop()
logger.info("Activity log retention disabled")
return self.get_settings()
@@ -0,0 +1,82 @@
"""Log-injection sanitizer for audit-log message and display strings.
Provides :func:`sanitize_display` a dependency-free helper that strips
characters that should not appear in a recorded ``message`` or display
string before it is persisted to SQLite, broadcast over WebSocket, or
exported to CSV.
Design constraints
------------------
- **Dependency-free**: uses only the Python standard library so it can be
imported from any module without adding transitive weight.
- **Conservative**: keeps printable ASCII/Unicode and normal spaces; drops
everything else including control chars (NUL, BEL, BS, VT, FF, ESC,
DEL), ANSI/CSI escape sequences (``\\x1b[...``), and carriage returns /
newlines / tabs which are the classic log-injection primitives.
- **Length-capped**: truncates to *maxlen* characters and appends ``""``
so callers can rely on a bounded string without adding their own guards.
"""
from __future__ import annotations
import re
# Matches ANSI/VT100 escape sequences: ESC [ ... m (CSI) and shorter forms.
# We strip these before the printable-char filter so the bracket/letters that
# follow the ESC don't survive stripping the ESC alone.
_ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
# Characters we explicitly want to remove even if str.isprintable() would
# let them through in some edge-case: NUL is the canonical SQL/log null-byte
# injection; the others are kept out by the printable check but listed here
# for documentation clarity.
_EXPLICIT_DROP = frozenset("\x00\r\n\t")
def sanitize_display(value: str | None, *, maxlen: int = 120) -> str:
"""Return a sanitized, length-capped version of *value* safe for log messages.
Parameters
----------
value:
The raw, potentially attacker-controlled string. ``None`` or empty
returns ``""``.
maxlen:
Maximum length of the returned string (default: 120). If the input
exceeds this length after sanitization, the string is truncated and
``""`` is appended (the ellipsis counts toward *maxlen*).
Returns
-------
str
A string that:
- contains no NUL bytes (``\\x00``),
- contains no ANSI/CSI escape sequences,
- contains no carriage returns, newlines, or tab characters,
- contains only characters for which ``str.isprintable()`` is ``True``
plus the regular ASCII space (``\\x20``),
- is at most *maxlen* characters long.
"""
if not value:
return ""
# 1. Strip ANSI escape sequences first so their bracket/letter tails don't
# survive as stray printable characters.
cleaned = _ANSI_RE.sub("", value)
# 2. Drop each character that is neither printable nor a plain space.
# str.isprintable() returns False for all control chars (including NUL,
# BEL, BS, TAB, LF, VT, FF, CR, ESC, DEL) and True for normal letters,
# digits, punctuation, and the space character.
cleaned = "".join(ch for ch in cleaned if ch.isprintable() or ch == " ")
# 3. Final belt-and-suspenders pass for the explicit drop set (catches NUL
# that may survive if isprintable ever changes in a future Python version).
cleaned = "".join(ch for ch in cleaned if ch not in _EXPLICIT_DROP)
# 4. Cap length.
if len(cleaned) > maxlen:
# Reserve one character for the ellipsis so total length == maxlen.
cleaned = cleaned[: maxlen - 1] + ""
return cleaned
@@ -26,6 +26,33 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__)
# Cache resolved IANA timezones (and remember invalid names) so the ~1 Hz
# automation tick neither re-parses tzdata nor log-spams on a bad name.
_TZ_CACHE: Dict[str, object] = {}
_TZ_WARNED: set = set()
def _now_in_tz(tz_name: str) -> datetime:
"""Current local time, in ``tz_name`` (IANA) if given, else the server's."""
if not tz_name:
return datetime.now()
tz = _TZ_CACHE.get(tz_name)
if tz is None:
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo(tz_name)
_TZ_CACHE[tz_name] = tz
except Exception:
if tz_name not in _TZ_WARNED:
_TZ_WARNED.add(tz_name)
logger.warning(
"Invalid timezone %r for time-of-day rule; using server local time",
tz_name,
)
return datetime.now()
return datetime.now(tz)
@dataclass(frozen=True)
class _RuleEvalContext:
@@ -519,16 +546,26 @@ class AutomationEngine:
@staticmethod
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
now = datetime.now()
now = _now_in_tz(rule.timezone)
current = now.hour * 60 + now.minute
parts_s = rule.start_time.split(":")
parts_e = rule.end_time.split(":")
start = int(parts_s[0]) * 60 + int(parts_s[1])
end = int(parts_e[0]) * 60 + int(parts_e[1])
days = rule.days_of_week
if start <= end:
return start <= current <= end
# Overnight range (e.g. 22:00 → 06:00)
return current >= start or current <= end
if not (start <= current <= end):
return False
return not days or now.weekday() in days
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its
# START day, so the after-midnight tail is matched against yesterday.
if current >= start: # evening portion — today's window
return not days or now.weekday() in days
if current <= end: # early-morning portion — yesterday's window
return not days or ((now.weekday() - 1) % 7) in days
return False
@staticmethod
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
@@ -689,6 +726,28 @@ class AutomationEngine:
else:
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
# Audit record — best-effort.
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_name = sanitize_display(automation.name) if automation.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.activated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' activated",
)
except Exception:
pass
async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_automations.pop(automation_id, False)
if not was_active:
@@ -714,6 +773,33 @@ class AutomationEngine:
# Clean up any leftover snapshot
self._pre_activation_snapshots.pop(automation_id, None)
# Audit record — best-effort.
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_auto_name: str | None = None
try:
_auto_name = self._store.get_automation(automation_id).name
except Exception:
pass
_safe_deact_name = sanitize_display(_auto_name) if _auto_name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.deactivated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation_id,
entity_name=_safe_deact_name,
message=f"Automation '{_safe_deact_name or automation_id}' deactivated",
)
except Exception:
pass
async def _deactivate_revert(self, automation_id: str) -> None:
"""Revert to pre-activation snapshot."""
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
@@ -113,6 +113,18 @@ class CalibrationConfig:
skip_leds_end: int = 0
# Border width: how many pixels from the screen edge to sample
border_width: int = 10
# Region of interest (simple mode): sample only this sub-rectangle of the
# frame (fractions 0..1). Defaults to the full frame. Lets a user exclude
# HUDs/taskbars/letterboxing from the sampled border colours.
roi_x: float = 0.0
roi_y: float = 0.0
roi_width: float = 1.0
roi_height: float = 1.0
@property
def has_roi(self) -> bool:
"""True when the ROI is narrower than the full frame."""
return self.roi_x > 0.0 or self.roi_y > 0.0 or self.roi_width < 1.0 or self.roi_height < 1.0
def build_segments(self) -> List[CalibrationSegment]:
"""Derive segment list from core parameters."""
@@ -656,6 +668,98 @@ def create_pixel_mapper(
return PixelMapper(calibration, interpolation_mode)
def solve_calibration(
led_count: int,
start_position: str,
layout: str,
corner_indices: List[int],
offset: int = 0,
) -> "CalibrationConfig":
"""Derive a CalibrationConfig from 4 corner tap indices.
Given the LED-strip indices where the user tapped each physical corner of
the screen (in strip-walk order matching *start_position* and *layout*),
compute per-edge LED counts that are consistent with
``EDGE_ORDER``/``EDGE_REVERSE`` and round-trip through
``build_segments()``.
Args:
led_count: Total number of LEDs on the strip.
start_position: Starting corner of the strip
(``"top_left"``, ``"top_right"``, ``"bottom_left"``,
``"bottom_right"``).
layout: Winding direction (``"clockwise"`` or
``"counterclockwise"``).
corner_indices: Four strip indices, one per screen corner, in the
same order as the strip walk defined by ``EDGE_ORDER`` for the
given *(start_position, layout)* pair. Index 0 is the start
corner, index 1 is the second corner reached while walking,
etc. Indices may wrap around (i.e. the last segment may
straddle the physical end of the strip).
offset: Physical LED offset stored directly on the config (0 = none).
Returns:
``CalibrationConfig`` in simple mode with per-edge counts filled in.
Raises:
ValueError: If *start_position*, *layout*, or the number of
corner indices is invalid.
"""
key = (start_position, layout)
if key not in EDGE_ORDER:
raise ValueError(
f"Invalid start_position/layout combination: {start_position!r}/{layout!r}"
)
if len(corner_indices) != 4:
raise ValueError(f"corner_indices must have exactly 4 entries, got {len(corner_indices)}")
if led_count <= 0:
raise ValueError(f"led_count must be positive, got {led_count}")
edge_order = EDGE_ORDER[key] # 4 edges in strip-walk order
# Compute per-edge LED counts from consecutive corner indices.
# The i-th edge spans from corner_indices[i] to corner_indices[(i+1) % 4],
# wrapping around led_count if necessary.
edge_counts: dict[str, int] = {}
for i, edge in enumerate(edge_order):
start_idx = corner_indices[i] % led_count
end_idx = corner_indices[(i + 1) % 4] % led_count
if end_idx > start_idx:
count = end_idx - start_idx
elif end_idx == start_idx:
# Adjacent taps on the same index → 0-LED edge
count = 0
else:
# Wrap-around: strip crosses the physical end
count = (led_count - start_idx) + end_idx
edge_counts[edge] = count
cfg = CalibrationConfig(
mode="simple",
layout=layout,
start_position=start_position,
leds_top=edge_counts.get("top", 0),
leds_right=edge_counts.get("right", 0),
leds_bottom=edge_counts.get("bottom", 0),
leds_left=edge_counts.get("left", 0),
offset=offset,
)
logger.info(
"solve_calibration: start=%s layout=%s corner_indices=%s "
"-> top=%d right=%d bottom=%d left=%d offset=%d",
start_position,
layout,
corner_indices,
cfg.leds_top,
cfg.leds_right,
cfg.leds_bottom,
cfg.leds_left,
offset,
)
return cfg
def create_default_calibration(
led_count: int,
aspect_width: int = 16,
@@ -720,6 +824,30 @@ def create_default_calibration(
right_count = max(1, right_count)
left_count = max(1, left_count)
# The max(1, ...) floors above can push the total above led_count for
# small counts (e.g. led_count=5 -> top=2,right=1,bottom=2,left=1 = 6).
# Trim the largest edge that stays >= 1 until the total matches exactly.
edge_order = ["bottom", "top", "right", "left"]
counts = {
"bottom": bottom_count,
"top": top_count,
"right": right_count,
"left": left_count,
}
overshoot = sum(counts.values()) - led_count
while overshoot > 0:
# Pick the largest edge that can still be reduced (stays >= 1).
trimmable = [e for e in edge_order if counts[e] > 1]
if not trimmable:
break
target_edge = max(trimmable, key=lambda e: counts[e])
counts[target_edge] -= 1
overshoot -= 1
bottom_count = counts["bottom"]
top_count = counts["top"]
right_count = counts["right"]
left_count = counts["left"]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
@@ -799,6 +927,10 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0),
border_width=data.get("border_width", 10),
roi_x=data.get("roi_x", 0.0),
roi_y=data.get("roi_y", 0.0),
roi_width=data.get("roi_width", 1.0),
roi_height=data.get("roi_height", 1.0),
)
config.validate()
@@ -870,4 +1002,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["skip_leds_end"] = config.skip_leds_end
if config.border_width != 10:
result["border_width"] = config.border_width
# Include ROI only when it is not the full frame
if config.has_roi:
result["roi_x"] = config.roi_x
result["roi_y"] = config.roi_y
result["roi_width"] = config.roi_width
result["roi_height"] = config.roi_height
return result
@@ -0,0 +1,410 @@
"""Calibration session lifecycle and per-LED chase driver.
Provides two things:
1. ``set_calibration_pixel`` direct per-index LED write for the chase
(added beside ``set_test_mode`` on ``ProcessorManager`` via the mixin, but
kept here to avoid growing device_test_mode.py further).
2. ``CalibrationSession`` single-active-session guard with idle timeout and
guaranteed stop/restore contract.
Stop / restore contract (required by Phase 3 UI)
-------------------------------------------------
- ``start(device_id)``:
* If a target is currently processing on *device_id*, stop it and record
its ``target_id`` as ``_prior_target_id``.
* Send the device to black (chase start state).
* Record session as active with a fresh ``last_activity`` timestamp.
* Only one active session is allowed at a time; starting a new one on any
device while another is active calls ``stop()`` on the old one first.
- ``position(index, window)``:
* Validates ``index < led_count``; raises ``ValueError`` on out-of-range.
* Sends a chase pixel (bright white centre ±window dim neighbours).
* Updates ``last_activity``.
- ``stop()`` / ``cancel()``:
* Sends all-black to clear the device.
* If ``_prior_target_id`` was recorded, calls ``start_processing`` to
restart it.
* Clears the session state.
* NEVER leaves the device dark or stuck in chase.
- Idle timeout (``IDLE_TIMEOUT_SECONDS``, default 60 s):
* A background asyncio task checks ``last_activity``; if the session has
been idle longer than the timeout, ``stop()`` is called automatically.
* The timeout task is cancelled when ``stop()`` is called explicitly.
Notes
-----
- ``set_calibration_pixel`` reuses ``_get_idle_client`` /
``_send_pixels_to_device`` from ``DeviceTestModeMixin``; no new connection
management is needed.
- The session holds a reference to the ``ProcessorManager`` so it can call
``stop_processing`` / ``start_processing``.
- Thread-safety: all public methods are ``async``; the idle-timeout callback
schedules itself on the running event loop via ``asyncio.ensure_future``.
"""
import asyncio
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.processing.processor_manager import ProcessorManager
logger = get_logger(__name__)
# ── Constants ────────────────────────────────────────────────────────────────
IDLE_TIMEOUT_SECONDS: int = 60
"""Auto-stop a calibration session after this many seconds of inactivity."""
_CHASE_CENTER_COLOR: tuple[int, int, int] = (255, 255, 255)
"""Bright white for the chase centre pixel."""
_CHASE_WING_COLOR: tuple[int, int, int] = (60, 60, 60)
"""Dim grey for ±window neighbour pixels."""
# ── Mixin: per-index chase driver ────────────────────────────────────────────
class CalibrationChaseMixin:
"""Adds ``set_calibration_pixel`` to ``ProcessorManager``.
Requires the same host-class attributes as ``DeviceTestModeMixin``:
``_devices``, ``_processors``, ``_idle_clients``.
Inherits ``_send_pixels_to_device`` and ``_get_idle_client`` from
``DeviceTestModeMixin`` (both already on ``ProcessorManager``).
"""
async def set_calibration_pixel(
self,
device_id: str,
index: int,
color: tuple[int, int, int] = _CHASE_CENTER_COLOR,
window: int = 1,
) -> None:
"""Light a single LED index (plus optional ±window neighbours) on a device.
Sends a full pixel array to avoid partial-frame artefacts. The centre
LED is set to *color*; the ``window`` neighbours on each side are set to
``_CHASE_WING_COLOR`` (dim grey) so the user can see which direction the
strip is wound.
Args:
device_id: Target device ID (must be registered).
index: LED index to light (0-based). Must be < ``led_count``.
color: RGB tuple for the centre LED (default bright white).
window: Number of neighbouring LEDs to dim on each side (default 1).
Raises:
ValueError: If *device_id* is not registered or *index* is out of
range.
"""
if device_id not in self._devices:
raise ValueError(f"Device {device_id!r} not found")
ds = self._devices[device_id]
led_count = ds.led_count
if led_count <= 0:
raise ValueError(f"Device {device_id!r} has led_count={led_count}")
if not (0 <= index < led_count):
raise ValueError(
f"index {index} out of range for device {device_id!r} " f"(led_count={led_count})"
)
pixels: list[tuple[int, int, int]] = [(0, 0, 0)] * led_count
pixels[index] = color
for offset in range(1, window + 1):
left = (index - offset) % led_count
right = (index + offset) % led_count
pixels[left] = _CHASE_WING_COLOR
pixels[right] = _CHASE_WING_COLOR
# Re-assign center last so on tiny strips (window >= led_count) the
# center LED always shows the full color rather than a wrapped wing.
pixels[index] = color
await self._send_pixels_to_device(device_id, pixels)
logger.debug(
"set_calibration_pixel: device=%s index=%d window=%d",
device_id,
index,
window,
)
# ── Session lifecycle ─────────────────────────────────────────────────────────
class CalibrationSession:
"""Single-active calibration session with idle-timeout and stop/restore.
One instance is shared per application (singleton held by the API layer).
Only one session can be active at a time; starting a new session
automatically terminates the previous one.
All public methods that mutate session state acquire ``_lock`` so that
concurrent ``POST /session`` calls (or a ``stop`` racing with the idle
watchdog) cannot interleave and leave ``_prior_target_id`` stale. The
watchdog calls the internal ``_teardown_locked`` helper which must only be
invoked when the lock is already held; if the lock is already taken the
watchdog simply exits, letting the holder finish teardown.
"""
def __init__(self) -> None:
self._manager: "ProcessorManager | None" = None
self._device_id: str | None = None
self._led_count: int = 0
self._prior_target_id: str | None = None
self._last_activity: datetime | None = None
self._timeout_task: asyncio.Task | None = None
self._active: bool = False
self._lock: asyncio.Lock = asyncio.Lock()
# ── Public API ───────────────────────────────────────────────────────────
@property
def is_active(self) -> bool:
return self._active
@property
def device_id(self) -> str | None:
return self._device_id
@property
def led_count(self) -> int:
return self._led_count
@property
def last_activity(self) -> datetime | None:
return self._last_activity
async def start(self, device_id: str, manager: "ProcessorManager") -> None:
"""Begin a calibration session on *device_id*.
If a session is already active (even on a different device), it is
stopped first. If a target is currently processing on *device_id*, it
is stopped and remembered so it can be restored when this session ends.
Args:
device_id: The device to drive during calibration.
manager: Live ``ProcessorManager`` instance.
Raises:
ValueError: If *device_id* is not registered.
"""
async with self._lock:
# Validate device before touching any state or awaiting
if device_id not in manager._devices:
raise ValueError(f"Device {device_id!r} not found")
ds = manager._devices[device_id]
led_count = ds.led_count
# Capture the prior running target NOW — before any await — so the
# value cannot be mutated by a concurrent call that sneaks in after
# the lock is released between awaits.
prior_target_id = manager.get_processing_target_for_device(device_id)
# Terminate any existing session while we still hold the lock.
# Call _teardown_locked directly (we already hold the lock).
if self._active:
logger.info(
"CalibrationSession.start: stopping existing session on device=%s "
"to start new one on device=%s",
self._device_id,
device_id,
)
await self._teardown_locked(cancelled=False)
# Stop any running target on this device and remember it for restore
if prior_target_id is not None:
logger.info(
"CalibrationSession.start: stopping target %s on device %s for calibration",
prior_target_id,
device_id,
)
await manager.stop_processing(prior_target_id)
self._manager = manager
self._device_id = device_id
self._led_count = led_count
self._prior_target_id = prior_target_id
self._last_activity = datetime.now(timezone.utc)
self._active = True
# Clear the device to black so the chase starts from a clean state
await manager.send_clear_pixels(device_id)
# Start idle-timeout watchdog
self._timeout_task = asyncio.ensure_future(self._idle_watchdog())
logger.info(
"CalibrationSession.start: session started on device=%s led_count=%d "
"prior_target=%s",
device_id,
led_count,
prior_target_id,
)
async def position(self, index: int, window: int = 1) -> None:
"""Drive the chase pixel to *index* on the active device.
Args:
index: LED index to illuminate (0-based, must be < led_count).
window: Number of dim neighbours on each side (default 1).
Raises:
RuntimeError: If no session is active.
ValueError: If *index* is out of range.
"""
async with self._lock:
if not self._active or self._manager is None or self._device_id is None:
raise RuntimeError("No active calibration session")
if not (0 <= index < self._led_count):
raise ValueError(f"index {index} out of range (led_count={self._led_count})")
self._last_activity = datetime.now(timezone.utc)
await self._manager.set_calibration_pixel(self._device_id, index, window=window)
logger.debug(
"CalibrationSession.position: device=%s index=%d window=%d",
self._device_id,
index,
window,
)
async def stop(self) -> None:
"""End the session: clear the device and restore the prior target.
Safe to call even if no session is active (no-op).
"""
async with self._lock:
await self._teardown_locked(cancelled=False)
async def cancel(self) -> None:
"""Alias for ``stop()`` — ends the session without applying calibration."""
async with self._lock:
await self._teardown_locked(cancelled=True)
def get_state(self) -> dict:
"""Return a snapshot of the current session state for API responses."""
return {
"active": self._active,
"device_id": self._device_id,
"led_count": self._led_count,
"prior_target_id": self._prior_target_id,
"last_activity": (self._last_activity.isoformat() if self._last_activity else None),
}
# ── Internal ─────────────────────────────────────────────────────────────
async def _teardown_locked(self, cancelled: bool) -> None:
"""Clear the device, restore the prior target, and reset state.
MUST be called with ``self._lock`` already held by the caller.
Safe to call when already inactive (no-op).
"""
if not self._active:
return
device_id = self._device_id
manager = self._manager
prior_target_id = self._prior_target_id
# Cancel the idle watchdog — but only if we are NOT running inside it.
# Awaiting the current task would deadlock.
if (
self._timeout_task is not None
and self._timeout_task is not asyncio.current_task()
and not self._timeout_task.done()
):
self._timeout_task.cancel()
try:
await self._timeout_task
except asyncio.CancelledError:
pass
self._timeout_task = None
# Reset state before side-effects so re-entrant calls are no-ops
self._active = False
self._device_id = None
self._led_count = 0
self._prior_target_id = None
self._last_activity = None
self._manager = None
if manager is None or device_id is None:
return
# 1. Clear the device to black
try:
await manager.send_clear_pixels(device_id)
except Exception as exc:
logger.warning(
"CalibrationSession._teardown: failed to clear pixels on %s: %s",
device_id,
exc,
)
# 2. Restore the prior target (if any)
if prior_target_id is not None:
try:
await manager.start_processing(prior_target_id)
logger.info(
"CalibrationSession._teardown: restored target %s on device %s",
prior_target_id,
device_id,
)
except Exception as exc:
logger.error(
"CalibrationSession._teardown: failed to restore target %s on " "device %s: %s",
prior_target_id,
device_id,
exc,
)
action = "cancel" if cancelled else "stop"
logger.info(
"CalibrationSession.%s: session ended on device=%s prior_target=%s",
action,
device_id,
prior_target_id,
)
async def _idle_watchdog(self) -> None:
"""Background task: auto-stop the session after IDLE_TIMEOUT_SECONDS.
Tries to acquire ``_lock`` when the timeout fires. If the lock is
already held (e.g. a concurrent ``stop()`` is in progress) the
``acquire`` will wait; once it gets the lock, ``_teardown_locked``
is a no-op if the session was already ended by the other caller.
"""
try:
while True:
await asyncio.sleep(5)
if not self._active or self._last_activity is None:
break
elapsed = (datetime.now(timezone.utc) - self._last_activity).total_seconds()
if elapsed >= IDLE_TIMEOUT_SECONDS:
logger.warning(
"CalibrationSession._idle_watchdog: session on device=%s "
"idle for %.0fs — auto-stopping",
self._device_id,
elapsed,
)
async with self._lock:
await self._teardown_locked(cancelled=False)
break
except asyncio.CancelledError:
pass
# ── Module-level singleton ────────────────────────────────────────────────────
_session: CalibrationSession = CalibrationSession()
def get_calibration_session() -> CalibrationSession:
"""Return the module-level singleton ``CalibrationSession``."""
return _session
@@ -159,6 +159,37 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
raise RuntimeError(f"Screen capture failed: {e}")
def crop_screen_capture(
sc: ScreenCapture,
roi_x: float,
roi_y: float,
roi_width: float,
roi_height: float,
) -> ScreenCapture:
"""Crop a capture to a relative region-of-interest rectangle (fractions 0..1).
Sampling only a sub-rectangle of the frame lets a user exclude HUDs, task
bars, or letterboxing so they don't pollute the border colours. Returns the
original capture unchanged for a full-frame ROI (fast path). The cropped
image is a numpy view (no copy); out-of-range/degenerate ROIs are clamped so
at least a 1x1 region remains.
"""
if roi_x <= 0.0 and roi_y <= 0.0 and roi_width >= 1.0 and roi_height >= 1.0:
return sc
h, w = sc.image.shape[:2]
x0 = max(0, min(w - 1, int(round(roi_x * w))))
y0 = max(0, min(h - 1, int(round(roi_y * h))))
x1 = max(x0 + 1, min(w, int(round((roi_x + roi_width) * w))))
y1 = max(y0 + 1, min(h, int(round((roi_y + roi_height) * h))))
cropped = sc.image[y0:y1, x0:x1]
return ScreenCapture(
image=cropped,
width=x1 - x0,
height=y1 - y0,
display_index=sc.display_index,
)
def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10) -> BorderPixels:
"""Extract border pixels from screen capture.
@@ -154,6 +154,15 @@ class DDPClient:
all buses (observed in multi-bus setups). We reorder pixel channels
here so the hardware receives the correct byte order directly.
TODO(ddp-multibus): currently UNUSED ``send_pixels_numpy`` (the hot
send path) does NOT call this, so the per-bus color-order config
captured by ``set_buses`` is never applied to outgoing pixels. This
is intentionally left in place (not deleted) because it encodes real
multi-bus handling: if a multi-bus WLED setup needs per-bus byte
reordering, wire this into ``send_pixels_numpy`` before the payload
view is built (note it allocates a copy, so only call it when
``self._buses`` actually requires reordering).
Args:
pixel_array: (N, 3) uint8 numpy array in RGB order
@@ -23,6 +23,11 @@ class BaseDeviceConfig:
class WLEDConfig(BaseDeviceConfig):
device_type: Literal["wled"] = "wled"
use_ddp: bool = False
# WLED native realtime UDP (port 21324) — mutually exclusive with use_ddp.
# realtime_timeout = seconds WLED stays in realtime after the last packet
# before reverting to its normal effect/preset (graceful auto-revert).
use_realtime: bool = False
realtime_timeout: int = 2
@dataclass(frozen=True)
@@ -36,6 +36,7 @@ from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZerocon
from ledgrab.core.devices.serial_transport import list_serial_ports
from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
@@ -286,3 +287,34 @@ class DiscoveryWatcher:
)
except Exception as e:
logger.debug("Discovery watcher: fire_event failed: %s", e)
# Audit record — best-effort, thread-safe (recorder marshals via
# call_soon_threadsafe when called from the zeroconf thread).
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
rec = get_module_recorder()
if rec is not None:
is_discovered = event_type == "device_discovered"
action = "device.discovered" if is_discovered else "device.lost"
severity = ActivitySeverity.INFO if is_discovered else ActivitySeverity.WARNING
verb = "discovered" if is_discovered else "lost"
# Sanitize mDNS-advertised strings before they enter the log.
# entry.name and entry.url are unauthenticated, attacker-controlled
# values; strip control chars, ANSI escapes, and NUL before use.
safe_name = sanitize_display(entry.name)
safe_url = sanitize_display(entry.url)
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=entry.url,
entity_name=safe_name,
message=f"Device '{safe_name}' {verb} at {safe_url}",
metadata={"url": safe_url, "device_type": entry.device_type},
)
except Exception as e:
logger.debug("Discovery watcher: audit record failed: %s", e)
+53 -9
View File
@@ -86,6 +86,8 @@ class WLEDClient(LEDClient):
retry_attempts: int = 3,
retry_delay: int = 1,
use_ddp: bool = False,
use_realtime: bool = False,
realtime_timeout: int = 2,
):
"""Initialize WLED client.
@@ -95,12 +97,17 @@ class WLEDClient(LEDClient):
retry_attempts: Number of retry attempts on failure
retry_delay: Delay between retries in seconds
use_ddp: Force DDP protocol (auto-enabled for >500 LEDs)
use_realtime: Use WLED native realtime UDP (port 21324) instead of DDP
realtime_timeout: Seconds WLED stays in realtime after the last packet
before reverting to its normal effect/preset (1-255)
"""
self.url = url.rstrip("/")
self.timeout = timeout
self.retry_attempts = retry_attempts
self.retry_delay = retry_delay
self.use_ddp = use_ddp
self.use_realtime = use_realtime
self.realtime_timeout = realtime_timeout
# Extract hostname/IP from URL for DDP
parsed = urlparse(self.url)
@@ -108,6 +115,7 @@ class WLEDClient(LEDClient):
self._client: httpx.AsyncClient | None = None
self._ddp_client: DDPClient | None = None
self._realtime_client = None # WledRealtimeClient when use_realtime
self._connected = False
self._pre_connect_state: dict | None = None
@@ -127,8 +135,9 @@ class WLEDClient(LEDClient):
# Test connection by getting device info
info = await self.get_info()
# Auto-enable DDP for large LED counts
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp:
# Auto-enable DDP for large LED counts (unless the user explicitly
# chose native realtime UDP, which handles any size via DNRGB).
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp and not self.use_realtime:
logger.info(
f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), "
"auto-enabling DDP protocol"
@@ -138,8 +147,30 @@ class WLEDClient(LEDClient):
# Snapshot device state BEFORE any mutations (for auto-restore)
self._pre_connect_state = await self.snapshot_device_state()
# Create WLED native realtime UDP client if selected
if self.use_realtime:
from ledgrab.core.devices.wled_realtime_client import WledRealtimeClient
self._realtime_client = WledRealtimeClient(
self.host, rgbw=info.rgbw, timeout_secs=self.realtime_timeout
)
await self._realtime_client.connect()
try:
await self._request(
"POST",
"/json/state",
json_data={"on": True, "lor": 0, "AudioReactive": {"on": False}},
)
except Exception as e:
logger.warning(f"Could not configure device for realtime UDP: {e}")
logger.info(
"WLED native realtime UDP enabled (port 21324, %ds timeout, %s)",
self.realtime_timeout,
"RGBW" if info.rgbw else "RGB",
)
# Create DDP client if needed
if self.use_ddp:
elif self.use_ddp:
self._ddp_client = DDPClient(self.host, rgbw=False)
# Pass per-bus config so DDP client can apply per-bus color reordering
if info.buses:
@@ -191,6 +222,9 @@ class WLEDClient(LEDClient):
if self._ddp_client:
await self._ddp_client.close()
self._ddp_client = None
if self._realtime_client:
await self._realtime_client.close()
self._realtime_client = None
self._connected = False
logger.debug(f"Closed connection to {self.url}")
@@ -201,8 +235,10 @@ class WLEDClient(LEDClient):
@property
def supports_fast_send(self) -> bool:
"""True when DDP is active and ready for fire-and-forget sends."""
return self.use_ddp and self._ddp_client is not None
"""True when DDP or native realtime UDP is active (fire-and-forget)."""
return (self.use_ddp and self._ddp_client is not None) or (
self.use_realtime and self._realtime_client is not None
)
async def _request(
self,
@@ -384,7 +420,10 @@ class WLEDClient(LEDClient):
raise ValueError(f"Invalid RGB values at index {idx}: {tuple(pixel_arr[idx])}")
validated_pixels = pixel_arr.astype(np.uint8) if pixel_arr.dtype != np.uint8 else pixel_arr
# Use DDP protocol if enabled
# Native realtime UDP takes precedence, then DDP, then HTTP
if self.use_realtime and self._realtime_client:
self._realtime_client.send_pixels_numpy(validated_pixels)
return True
if self.use_ddp and self._ddp_client:
return await self._send_pixels_ddp(validated_pixels, brightness)
else:
@@ -485,8 +524,10 @@ class WLEDClient(LEDClient):
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
brightness: Global brightness (0-255)
"""
if not self.use_ddp or not self._ddp_client:
raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP")
if not (self.use_ddp and self._ddp_client) and not (
self.use_realtime and self._realtime_client
):
raise RuntimeError("send_pixels_fast requires DDP or realtime UDP; use send_pixels")
if isinstance(pixels, np.ndarray):
pixel_array = pixels
@@ -494,7 +535,10 @@ class WLEDClient(LEDClient):
pixel_array = np.array(pixels, dtype=np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
self._ddp_client.send_pixels_numpy(pixel_array)
if self.use_realtime and self._realtime_client:
self._realtime_client.send_pixels_numpy(pixel_array)
else:
self._ddp_client.send_pixels_numpy(pixel_array)
# ===== LEDClient abstraction methods =====
@@ -86,6 +86,8 @@ class WLEDDeviceProvider(LEDDeviceProvider):
return WLEDClient(
config.device_url,
use_ddp=config.use_ddp,
use_realtime=config.use_realtime,
realtime_timeout=config.realtime_timeout,
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
@@ -0,0 +1,153 @@
"""WLED native realtime UDP client (port 21324).
WLED exposes a family of "realtime" UDP protocols separate from DDP. Compared to
the DDP path this gives three user-visible wins for the device LedGrab drives
most:
* **Auto-revert** every packet carries a *timeout* byte. If LedGrab stops
streaming (host hiccup, sleep, crash), WLED returns to its normal effect /
preset after that many seconds instead of freezing on the last frame.
* **Correct RGBW whites** the DRGBW variant carries an explicit white channel,
so RGBW strips are driven correctly instead of leaving W uncontrolled.
* **Lighter on weak Wi-Fi** raw RGB with a 2-byte header, no DDP framing.
Unlike the DDP path, WLED applies the configured per-bus color order itself in
realtime mode, so this sender transmits plain RGB (no manual reordering) the
user's WLED colour-order setting just works.
Packet layout (first byte selects the protocol)::
DRGB (2): [2][timeout] + R G B per LED (<= 490 LEDs)
DRGBW (3): [3][timeout] + R G B W per LED (<= 367 LEDs)
DNRGB (4): [4][timeout][start_hi][start_lo] + R G B per LED (chunked, 489/pkt)
The ``timeout`` byte is in **seconds** (1-255). DNRGB carries a 16-bit start
index so strips larger than one packet are sent as several chunks.
Ref: https://kno.wled.ge/interfaces/udp-realtime/
"""
from __future__ import annotations
import asyncio
import numpy as np
from ledgrab.utils import get_logger
logger = get_logger(__name__)
REALTIME_PORT = 21324
# Protocol selector (first byte).
_DRGB = 2
_DRGBW = 3
_DNRGB = 4
# Per-protocol LED capacity (bounded by the ~1500-byte UDP payload).
_MAX_DRGB = 490 # 2 + 490*3 = 1472
_MAX_DRGBW = 367 # 2 + 367*4 = 1470
_MAX_DNRGB_CHUNK = 489 # 4 + 489*3 = 1471
# Default seconds WLED stays in realtime after the last packet before reverting.
DEFAULT_REALTIME_TIMEOUT = 2
def _clamp_timeout(seconds: int) -> int:
"""Clamp the realtime timeout to the on-wire 1-255 range."""
return max(1, min(255, int(seconds)))
class WledRealtimeClient:
"""Fire-and-forget UDP sender for WLED native realtime protocols."""
def __init__(
self,
host: str,
port: int = REALTIME_PORT,
rgbw: bool = False,
timeout_secs: int = DEFAULT_REALTIME_TIMEOUT,
) -> None:
self.host = host
self.port = port
self.rgbw = rgbw
self.timeout_secs = _clamp_timeout(timeout_secs)
self._transport: asyncio.DatagramTransport | None = None
self._protocol: asyncio.DatagramProtocol | None = None
# Reusable RGBW scratch (resized on demand) so the hot path doesn't
# allocate a fresh (N, 4) array per frame.
self._rgbw_buf: np.ndarray | None = None
self._rgbw_buf_n: int = 0
async def connect(self) -> bool:
"""Open the UDP datagram endpoint to the device."""
loop = asyncio.get_running_loop()
self._transport, self._protocol = await loop.create_datagram_endpoint(
asyncio.DatagramProtocol, remote_addr=(self.host, self.port)
)
logger.info(
"WLED realtime client connected to %s:%d (timeout %ds, %s)",
self.host,
self.port,
self.timeout_secs,
"RGBW" if self.rgbw else "RGB",
)
return True
async def close(self) -> None:
"""Close the datagram endpoint."""
if self._transport is not None:
self._transport.close()
self._transport = None
self._protocol = None
logger.debug("Closed WLED realtime connection to %s:%d", self.host, self.port)
@property
def is_connected(self) -> bool:
return self._transport is not None
def _ensure_rgbw_buf(self, n: int) -> np.ndarray:
"""Return an ``(n, 4)`` uint8 RGBW buffer with the white channel zeroed."""
if self._rgbw_buf is None or self._rgbw_buf_n != n:
self._rgbw_buf = np.zeros((n, 4), dtype=np.uint8)
self._rgbw_buf_n = n
return self._rgbw_buf
def build_packets(self, pixels: np.ndarray) -> list[bytes]:
"""Build the realtime UDP packet(s) for one ``(N, 3)`` uint8 RGB frame.
Exposed (and pure) for unit testing the wire format. Picks DRGBW for
RGBW strips within range, DRGB for small RGB strips, otherwise DNRGB
chunks. The white channel is sent as 0 (colour comes from the RGB LEDs).
"""
pixels = np.ascontiguousarray(pixels, dtype=np.uint8)
n = len(pixels)
t = self.timeout_secs
if n == 0:
return []
if self.rgbw and n <= _MAX_DRGBW:
buf = self._ensure_rgbw_buf(n)
buf[:, 0:3] = pixels
# white channel already zeroed and left at 0
return [bytes([_DRGBW, t]) + buf.tobytes()]
if n <= _MAX_DRGB and not self.rgbw:
return [bytes([_DRGB, t]) + pixels.tobytes()]
# DNRGB: 16-bit start index, chunked. Covers >490 RGB and >367 RGBW
# (the white channel is dropped for oversized RGBW strips).
packets: list[bytes] = []
for start in range(0, n, _MAX_DNRGB_CHUNK):
end = min(start + _MAX_DNRGB_CHUNK, n)
header = bytes([_DNRGB, t, (start >> 8) & 0xFF, start & 0xFF])
packets.append(header + pixels[start:end].tobytes())
return packets
def send_pixels_numpy(self, pixels: np.ndarray) -> bool:
"""Send one frame of ``(N, 3)`` uint8 RGB pixels (fire-and-forget)."""
if self._transport is None:
return False
for packet in self.build_packets(pixels):
self._transport.sendto(packet)
return True
@@ -260,7 +260,10 @@ class CS2Adapter(GameAdapter):
auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "")
return bool(actual_token and actual_token == expected_token)
if not actual_token:
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_token, expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -177,7 +177,10 @@ class Dota2Adapter(GameAdapter):
auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "")
return bool(actual_token and actual_token == expected_token)
if not actual_token:
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_token, expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -4,6 +4,7 @@ Allows users to define custom JSON path mappings via the adapter_config
rather than a YAML file. Delegates all parsing logic to MappingAdapter.
"""
import secrets
from typing import Any, ClassVar
from ledgrab.core.game_integration.base_adapter import GameAdapter
@@ -54,11 +55,18 @@ class GenericWebhookAdapter(GameAdapter):
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate auth using a configurable header token."""
"""Validate auth using a configurable header token.
Secure-by-default: this adapter is explicitly network-facing
(it accepts unauthenticated HTTP POSTs from anywhere on the LAN),
so a missing/empty ``auth_token`` REJECTS the request rather than
accepting it. A token must be configured for ingestion to work.
"""
expected_token = adapter_config.get("auth_token")
if not expected_token:
# No auth configured
return True
# No token configured — reject (secure-by-default for a
# network-facing adapter; an open webhook is a LAN attack surface).
return False
auth_header = adapter_config.get("auth_header", "Authorization")
actual_value = headers.get(auth_header, "")
@@ -67,18 +75,27 @@ class GenericWebhookAdapter(GameAdapter):
if actual_value.startswith("Bearer "):
actual_value = actual_value[7:]
return bool(actual_value and actual_value == expected_token)
if not actual_value:
return False
# Constant-time comparison to avoid a token-length/timing oracle.
return secrets.compare_digest(actual_value, expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return generic webhook config schema."""
return {
"type": "object",
"required": ["auth_token"],
"properties": {
"auth_token": {
"type": "string",
"title": "Auth Token",
"description": "Optional token for authenticating incoming webhooks.",
"description": (
"Required token for authenticating incoming webhooks. "
"Without it, ingestion is rejected (this adapter is "
"network-facing and secure-by-default)."
),
},
"auth_header": {
"type": "string",
@@ -136,15 +153,17 @@ class GenericWebhookAdapter(GameAdapter):
"HTTP POST requests with JSON payloads.\n\n"
"**Steps:**\n"
"1. Configure your event mappings above — map JSON paths to standard events\n"
"2. Set an auth token (optional but recommended)\n"
"2. Set an auth token (REQUIRED — without it incoming webhooks are rejected)\n"
"3. Point your game/application to:\n"
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
"**Mapping example:**\n"
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n"
"- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n"
"**Auth:**\n"
"**Auth (required):**\n"
"- Set `Authorization: Bearer <token>` header in your webhook sender\n"
"- Or configure a custom auth header name in the adapter config\n"
"- A token is mandatory: this endpoint is reachable from the LAN, so\n"
" an unconfigured token rejects all incoming events.\n"
)
@@ -10,6 +10,7 @@ The MappingAdapter class is a concrete GameAdapter whose behavior is
entirely driven by the parsed YAML definition.
"""
import secrets
import time
from pathlib import Path
from typing import Any
@@ -241,7 +242,10 @@ class MappingAdapter(GameAdapter):
expected_key = "auth_token"
expected_value = adapter_config.get(expected_key, "")
actual_value = headers.get(header_name, "")
return bool(expected_value and actual_value == expected_value)
if not (expected_value and actual_value):
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_value, expected_value)
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
return False
@@ -18,7 +18,7 @@ from ledgrab.core.capture.calibration import (
CalibrationConfig,
create_pixel_mapper,
)
from ledgrab.core.capture.screen_capture import extract_border_pixels
from ledgrab.core.capture.screen_capture import crop_screen_capture, extract_border_pixels
from ledgrab.storage.bindable import bfloat
from ledgrab.utils import get_logger
from ledgrab.utils.frame_limiter import FrameLimiter
@@ -296,7 +296,19 @@ class PictureColorStripStream(ColorStripStream):
t1 = time.perf_counter()
led_colors = mapper.map_lines_to_leds(frames_dict)
else:
border_pixels = extract_border_pixels(frame, calibration.border_width)
src = frame
bw = calibration.border_width
if calibration.has_roi:
src = crop_screen_capture(
frame,
calibration.roi_x,
calibration.roi_y,
calibration.roi_width,
calibration.roi_height,
)
# Border width must stay within the cropped size.
bw = max(1, min(bw, min(src.width, src.height) // 4))
border_pixels = extract_border_pixels(src, bw)
t1 = time.perf_counter()
led_colors = mapper.map_border_to_leds(border_pixels)
t2 = time.perf_counter()
@@ -69,6 +69,9 @@ class CompositeColorStripStream(ColorStripStream):
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
self._resize_cache: Dict[tuple, tuple] = {}
# (src_len, target_n) -> (src_x, dst_x) cache for full-strip resizing
# (output reuses the preallocated self._resize_buf from _ensure_pool)
self._resize_linspace_cache: Dict[tuple, tuple] = {}
# layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
# layer_index -> (vs_id, value_stream)
@@ -314,8 +317,14 @@ class CompositeColorStripStream(ColorStripStream):
n_src = len(colors)
if n_src == target_n:
return colors
src_x = np.linspace(0, 1, n_src)
dst_x = np.linspace(0, 1, target_n)
# Cache the (src_x, dst_x) linspace arrays keyed by (n_src, target_n)
# exactly like the zone path, so they are not reallocated every frame.
lkey = (n_src, target_n)
linspaces = self._resize_linspace_cache.get(lkey)
if linspaces is None:
linspaces = (np.linspace(0, 1, n_src), np.linspace(0, 1, target_n))
self._resize_linspace_cache[lkey] = linspaces
src_x, dst_x = linspaces
buf = self._resize_buf
for ch in range(3):
np.copyto(
@@ -11,6 +11,8 @@ from ledgrab.core.devices.led_client import (
check_device_health,
get_device_capabilities,
)
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -128,6 +130,35 @@ class DeviceHealthMixin:
"latency_ms": state.health.latency_ms,
}
)
# Audit record for device online/offline transition.
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
is_online = state.health.online
# Best-effort name lookup from the device store.
device_name: str | None = None
try:
if self._device_store is not None:
device_name = self._device_store.get_device(device_id).name
except Exception:
pass
safe_name = sanitize_display(device_name) if device_name else None
display = safe_name or device_id
action = "device.online" if is_online else "device.offline"
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
status_word = "came online" if is_online else "went offline"
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=device_id,
entity_name=safe_name,
message=f"Device '{display}' {status_word}",
metadata={"latency_ms": state.health.latency_ms},
)
# Auto-sync LED count
reported = state.health.device_led_count
@@ -137,16 +137,34 @@ class DeviceTestModeMixin:
await self._send_pixels_to_device(device_id, pixels)
async def _send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to clear LED output."""
"""Send all-black pixels to clear LED output.
This is the explicit teardown path unlike the per-frame
``_send_pixels_to_device`` swallow, a clear that must actually take
effect retries once before giving up, so a single transient send
error doesn't leave the device lit after a session ends.
"""
ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count
await self._send_pixels_to_device(device_id, pixels)
try:
client = await self._get_idle_client(device_id)
await client.send_pixels(pixels)
except Exception as e:
logger.warning(f"Clear send to {device_id} failed, retrying once: {e}")
client = await self._get_idle_client(device_id)
await client.send_pixels(pixels)
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
"""Send pixels to a device via cached idle client.
Reuses a cached connection to avoid repeated serial reconnections
(which trigger Arduino bootloader reset on Adalight devices).
Send failures are logged and swallowed (best-effort per-frame send).
Callers that need a *guaranteed* clear e.g. session teardown that
must "never leave the device dark" must NOT rely on this returning
cleanly; use ``_send_clear_pixels`` (which retries once) and treat a
propagated exception as a failed clear.
"""
try:
client = await self._get_idle_client(device_id)
@@ -973,13 +973,18 @@ class EffectColorStripStream(ColorStripStream):
# Use noise at very low frequency for blob movement
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)
# Two blob layers at different speeds for organic movement
# Two blob layers at different speeds for organic movement.
# fbm() returns a shared internal buffer that the next fbm() call
# overwrites, so each layer must be copied out — write into the
# preallocated scratch buffers instead of allocating per frame.
self._s_f32_a += t * speed * 0.1
layer1 = self._noise.fbm(self._s_f32_a, octaves=3).copy()
layer1 = self._s_layer1
np.copyto(layer1, self._noise.fbm(self._s_f32_a, octaves=3))
np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
self._s_f32_a += t * speed * 0.07 + 100.0
layer2 = self._noise.fbm(self._s_f32_a, octaves=2).copy()
layer2 = self._s_layer2
np.copyto(layer2, self._noise.fbm(self._s_f32_a, octaves=2))
# Combine: create blob-like shapes with soft edges
combined = self._s_f32_a
@@ -44,6 +44,7 @@ from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.core.processing.device_health import DeviceHealthMixin
from ledgrab.core.processing.device_test_mode import DeviceTestModeMixin
from ledgrab.core.capture.calibration_session import CalibrationChaseMixin
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -106,7 +107,9 @@ class DeviceState:
zone_mode: str = "combined"
class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin):
class ProcessorManager(
AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin, CalibrationChaseMixin
):
"""Manages devices and delegates target processing to TargetProcessor instances.
Devices are registered for health monitoring.
@@ -156,9 +156,15 @@ class WledTargetProcessor(TargetProcessor):
from ledgrab.core.devices.device_config import WLEDConfig as _WLEDConfig
config = _dev.to_config()
# use_ddp is a target-derived protocol setting — override on WLEDConfig
# The target's protocol selects how we drive a WLED device:
# "ddp" -> DDP UDP (4048) "udp" -> WLED native realtime UDP (21324)
# "http" -> JSON API (use_ddp and use_realtime are exclusive)
if isinstance(config, _WLEDConfig):
config = _replace(config, use_ddp=(self._protocol == "ddp"))
config = _replace(
config,
use_ddp=(self._protocol == "ddp"),
use_realtime=(self._protocol == "udp"),
)
self._device_config = config
# Connect to LED device
@@ -733,10 +739,19 @@ class WledTargetProcessor(TargetProcessor):
self._last_preview_data = data
async def _send_safe(ws):
# Bound each per-client send to roughly one frame interval so a slow or
# backpressured preview WebSocket can never throttle the device send
# cadence. Clients that time out or error are dropped from the set.
eff_fps = self._effective_fps if self._effective_fps > 0 else 30
send_timeout = 1.0 / eff_fps
async def _send_safe(ws) -> bool:
try:
await ws.send_bytes(data)
await asyncio.wait_for(ws.send_bytes(data), timeout=send_timeout)
return True
except asyncio.TimeoutError:
logger.debug("LED preview broadcast WS send timed out (slow client dropped)")
return False
except Exception as e:
logger.debug("LED preview broadcast WS send failed: %s", e)
return False
@@ -0,0 +1,312 @@
"""Playlist engine — background loop that auto-cycles a scene playlist.
A playlist is an ordered, timed sequence of scene presets. The engine drives
**at most one** playlist at a time: starting a new playlist transparently stops
any currently-running one. Each cycle re-reads the playlist from the store, so
edits (and deletion) take effect at the next cycle boundary without a restart.
The actual state application reuses ``scene_activator.apply_scene_state`` the
same code path the scene-presets API and the automation engine use so a
playlist step behaves exactly like manually activating that preset.
"""
import asyncio
import random
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import List
from ledgrab.storage.scene_playlist import ScenePlaylist, clamp_duration
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@dataclass
class PlaylistRuntimeState:
"""Volatile runtime state of the (single) active playlist. Not persisted."""
playlist_id: str
playlist_name: str
current_index: int
item_count: int
current_preset_id: str | None
started_at: datetime
step_started_at: datetime
step_duration: float
def to_dict(self) -> dict:
return {
"is_running": True,
"playlist_id": self.playlist_id,
"playlist_name": self.playlist_name,
"current_index": self.current_index,
"item_count": self.item_count,
"current_preset_id": self.current_preset_id,
"started_at": self.started_at.isoformat(),
"step_started_at": self.step_started_at.isoformat(),
"step_duration": self.step_duration,
}
_IDLE_STATE = {
"is_running": False,
"playlist_id": None,
"playlist_name": None,
"current_index": 0,
"item_count": 0,
"current_preset_id": None,
"started_at": None,
"step_started_at": None,
"step_duration": 0.0,
}
class PlaylistError(Exception):
"""Raised when a playlist cannot be started (empty / not found)."""
class PlaylistEngine:
"""Cycles a scene playlist's presets on a timer, one playlist at a time."""
def __init__(
self,
playlist_store,
scene_preset_store,
target_store,
processor_manager,
):
self._playlist_store = playlist_store
self._scene_preset_store = scene_preset_store
self._target_store = target_store
self._manager = processor_manager
self._task: asyncio.Task | None = None
self._state: PlaylistRuntimeState | None = None
# Serialises start/stop so overlapping API calls can't leave two
# cycling tasks alive at once.
self._lifecycle_lock = asyncio.Lock()
# ===== Public control API =====
async def start_playlist(self, playlist_id: str) -> PlaylistRuntimeState:
"""Start cycling ``playlist_id``, stopping any current playlist first.
Raises ``PlaylistError`` if the playlist is unknown or has no items.
"""
try:
playlist = self._playlist_store.get_playlist(playlist_id)
except Exception as exc: # EntityNotFoundError / ValueError
raise PlaylistError(f"Playlist not found: {playlist_id}") from exc
if not playlist.items:
raise PlaylistError(f"Playlist '{playlist.name}' has no items")
async with self._lifecycle_lock:
await self._cancel_task()
now = datetime.now(timezone.utc)
first_item = playlist.items[0]
self._state = PlaylistRuntimeState(
playlist_id=playlist.id,
playlist_name=playlist.name,
current_index=0,
item_count=len(playlist.items),
current_preset_id=first_item.scene_preset_id,
started_at=now,
step_started_at=now,
step_duration=clamp_duration(first_item.duration_seconds),
)
self._task = asyncio.create_task(self._run(playlist.id))
self._fire_event("started")
logger.info("Playlist '%s' started (%d items)", playlist.name, len(playlist.items))
return self._state
async def stop(self) -> None:
"""Stop the active playlist (if any). Leaves the last scene applied."""
async with self._lifecycle_lock:
was_running = self._task is not None
await self._cancel_task()
stopped_id = self._state.playlist_id if self._state else None
self._state = None
if was_running:
self._fire_event("stopped", playlist_id=stopped_id)
logger.info("Playlist stopped")
async def stop_if_running(self, playlist_id: str) -> None:
"""Stop the playlist only if ``playlist_id`` is the one running.
Used when a playlist is deleted or edited so a stale snapshot can't keep
cycling. The read-compare and the stop happen atomically under the
lifecycle lock so a concurrent natural-end / start can't slip a
different playlist in between the check and the stop.
"""
async with self._lifecycle_lock:
state = self._state
if state is None or state.playlist_id != playlist_id:
return
was_running = self._task is not None
await self._cancel_task()
stopped_id = self._state.playlist_id if self._state else playlist_id
self._state = None
if was_running:
self._fire_event("stopped", playlist_id=stopped_id)
logger.info("Playlist stopped")
# ===== Query API (used by routes) =====
def is_running(self) -> bool:
# Snapshot the task ref so a concurrent clear (set to None at an await
# boundary) can't turn the deref into an attribute error mid-read.
task = self._task
return task is not None and not task.done()
def get_running_playlist_id(self) -> str | None:
state = self._state
return state.playlist_id if state else None
def get_state(self) -> dict:
# Snapshot both refs once: the event loop won't preempt this sync method
# between the reads, but snapshotting also guards against ever returning
# a half-cleared state (running True while _state is already None).
state = self._state
task = self._task
if state is not None and task is not None and not task.done():
return state.to_dict()
return dict(_IDLE_STATE)
# ===== Internal =====
async def _cancel_task(self) -> None:
"""Cancel and await the cycling task. Caller holds the lifecycle lock."""
task = self._task
self._task = None
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception as exc: # pragma: no cover - defensive
logger.error("Playlist task raised on cancel: %s", exc, exc_info=True)
async def _run(self, playlist_id: str) -> None:
"""Cycle the playlist until cancelled, the playlist ends, or it errors."""
try:
while True:
# Re-read each cycle so edits/deletes apply at the boundary.
try:
playlist = self._playlist_store.get_playlist(playlist_id)
except Exception:
logger.info("Playlist %s removed while running; stopping", playlist_id)
break
if not playlist.items:
logger.info("Playlist '%s' has no items; stopping", playlist.name)
break
applied_any = await self._run_cycle(playlist)
if not playlist.loop:
break
if not applied_any:
# Every item referenced a missing preset — a looping
# playlist would otherwise spin with no dwell. Bail out.
logger.warning(
"Playlist '%s' applied no valid presets this cycle; stopping",
playlist.name,
)
break
# Natural end (non-loop or guard). Clear state without recursing
# through stop() (which would try to cancel this very task). Take
# the lifecycle lock so the clear + 'stopped' event are atomic with
# respect to a concurrent start/stop/stop_if_running — otherwise the
# two could interleave and emit duplicate or contradictory terminal
# events. _run never calls _cancel_task and never otherwise holds
# this lock, so acquiring it here cannot deadlock; if a canceller
# holding the lock cancels us while we wait to acquire it, the
# acquire raises CancelledError and we fall through to the handler.
ended_id = None
should_fire = False
async with self._lifecycle_lock:
# Re-check under the lock: a concurrent start_playlist may have
# replaced us while we waited. Only clear if we're still current.
if self._task is asyncio.current_task():
self._task = None
ended_id = self._state.playlist_id if self._state else None
self._state = None
should_fire = True
if should_fire:
self._fire_event("stopped", playlist_id=ended_id)
logger.info("Playlist '%s' finished", playlist_id)
except asyncio.CancelledError:
raise
except Exception as exc: # pragma: no cover - defensive
logger.error("Playlist run loop error: %s", exc, exc_info=True)
async def _run_cycle(self, playlist: ScenePlaylist) -> bool:
"""Run one pass over the playlist's items. Returns True if any applied."""
order = self._resolve_order(playlist)
applied_any = False
for index, item in enumerate(order):
duration = clamp_duration(item.duration_seconds)
if self._state is not None:
self._state.current_index = index
self._state.current_preset_id = item.scene_preset_id
self._state.step_started_at = datetime.now(timezone.utc)
self._state.step_duration = duration
applied = await self._apply_item(item.scene_preset_id)
if applied:
applied_any = True
self._fire_event("advanced", index=index, preset_id=item.scene_preset_id)
# Only dwell on scenes we actually applied; skip missing ones
# immediately so the cycle doesn't stall on a dead reference.
await asyncio.sleep(duration)
return applied_any
def _resolve_order(self, playlist: ScenePlaylist) -> List:
if playlist.shuffle and len(playlist.items) > 1:
shuffled = list(playlist.items)
random.shuffle(shuffled) # noqa: S311 - cosmetic ordering, not security
return shuffled
return list(playlist.items)
async def _apply_item(self, preset_id: str) -> bool:
"""Apply one scene preset. Returns False if it could not be applied."""
if not self._scene_preset_store or not self._target_store or not self._manager:
logger.warning("Playlist engine missing stores; cannot apply %s", preset_id)
return False
try:
preset = self._scene_preset_store.get_preset(preset_id)
except Exception:
logger.warning("Playlist references missing scene preset %s (skipped)", preset_id)
return False
from ledgrab.core.scenes.scene_activator import apply_scene_state
_status, errors = await apply_scene_state(preset, self._target_store, self._manager)
if errors:
logger.warning("Playlist step '%s' applied with errors: %s", preset.name, errors)
return True
def _fire_event(self, action: str, **extra) -> None:
if self._manager is None:
return
try:
self._manager.fire_event(
{
"type": "playlist_state_changed",
"action": action,
"playlist_id": extra.get("playlist_id")
or (self._state.playlist_id if self._state else None),
**{k: v for k, v in extra.items() if k != "playlist_id"},
}
)
except Exception as exc:
logger.error("Playlist event fire failed: %s", exc, exc_info=True)
@@ -93,7 +93,7 @@ async def apply_scene_state(
proc = processor_manager.get_processor(ts.target_id)
if proc and proc.is_running:
css_changed = "color_strip_source_id" in changed
brightness_changed = "brightness" in changed
brightness_changed = "brightness_value_source_id" in changed
settings_changed = "fps" in changed
if css_changed:
target.sync_with_manager(
+83 -7
View File
@@ -35,6 +35,7 @@ import ledgrab.core.audio # noqa: F401 — trigger engine auto-registration
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,
@@ -47,6 +48,7 @@ from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
@@ -58,6 +60,9 @@ from ledgrab.storage.audio_processing_template_store import AudioProcessingTempl
from ledgrab.storage.pattern_template_store import PatternTemplateStore
import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.core.activity_log.recorder import ActivityRecorder, set_module_recorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.core.processing.os_notification_listener import OsNotificationListener
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
from ledgrab.core.update.update_service import UpdateService
@@ -157,6 +162,7 @@ audio_template_store = AudioTemplateStore(db)
value_source_store = ValueSourceStore(db)
automation_store = AutomationStore(db)
scene_preset_store = ScenePresetStore(db)
scene_playlist_store = ScenePlaylistStore(db)
sync_clock_store = SyncClockStore(db)
cspt_store = ColorStripProcessingTemplateStore(db)
gradient_store = GradientStore(db)
@@ -181,6 +187,10 @@ pattern_template_store = PatternTemplateStore(db)
game_event_bus = GameEventBus()
register_community_adapters()
# Activity log repository — constructed at module level like other stores so
# it migrates the DB schema (``002_add_activity_log``) on import.
activity_log_repo = ActivityLogRepository(db)
processor_manager = ProcessorManager(
ProcessorDependencies(
picture_source_store=picture_source_store,
@@ -254,6 +264,15 @@ async def lifespan(app: FastAPI):
client_labels = ", ".join(config.auth.api_keys.keys())
logger.info(f"Authorized clients: {client_labels}")
# Warn when the OpenAPI docs surface is exposed without a token.
if config.auth.expose_docs:
logger.warning(
"auth.expose_docs is ON: /docs, /redoc and /openapi.json load "
"without an API key (loopback and LAN). The API surface (routes + "
"schemas) is readable by anyone who can reach the server; endpoint "
"calls still require a token."
)
# One-shot migration: legacy global ``mqtt:`` config block → first MQTTSource.
# No-op once the store has any entries.
try:
@@ -278,6 +297,26 @@ async def lifespan(app: FastAPI):
value_source_store=value_source_store,
)
# Create playlist engine — auto-cycles scene presets, one playlist at a
# time. Idle (no background task) until a playlist is started via the API.
playlist_engine = PlaylistEngine(
playlist_store=scene_playlist_store,
scene_preset_store=scene_preset_store,
target_store=output_target_store,
processor_manager=processor_manager,
)
# Create activity recorder + retention engine. The recorder needs the
# processor_manager to fire live events, so it is built after that is
# already constructed at module level.
activity_recorder = ActivityRecorder(activity_log_repo, processor_manager)
activity_recorder.ensure_loop()
activity_log_retention_engine = ActivityLogRetentionEngine(
repo=activity_log_repo,
db=db,
recorder=activity_recorder,
)
# Create auto-backup engine — derive paths from database location so that
# demo mode auto-backups go to data/demo/ instead of data/.
_data_dir = Path(config.storage.database_file).parent
@@ -314,7 +353,9 @@ async def lifespan(app: FastAPI):
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,
@@ -333,7 +374,13 @@ async def lifespan(app: FastAPI):
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,
)
# Expose the recorder via the module singleton so non-DI sites
# (fire_entity_event, device threads) can call record() without FastAPI DI.
set_module_recorder(activity_recorder)
# Register devices in processor manager for health monitoring
devices = device_store.get_all_devices()
@@ -376,6 +423,9 @@ async def lifespan(app: FastAPI):
# Start auto-backup engine (periodic configuration backups)
await auto_backup_engine.start()
# Start activity log retention engine (hourly prune of old entries)
await activity_log_retention_engine.start()
# Start update checker (periodic release polling)
await update_service.start()
@@ -424,6 +474,19 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error("Shutdown step '%s' raised: %s", label, e)
# Record the shutdown event FIRST — before any engine teardown — so there
# is always a final log entry on graceful shutdown.
try:
activity_recorder.record(
category="system",
action="server.shutting_down",
severity="info",
actor="system",
message="Server is shutting down",
)
except Exception as e:
logger.error("Failed to record shutdown event: %s", e)
# Legacy hook — SQLite stores are write-through so this only logs.
# Durability comes from PRAGMA synchronous=FULL + the explicit
# wal_checkpoint(TRUNCATE) in Database.close() at the end of this block.
@@ -436,6 +499,16 @@ async def lifespan(app: FastAPI):
# would talk to processors mid-shutdown.
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
# Tear down any active calibration session BEFORE stop_all so the device
# isn't left stuck in the white-chase and its prior target is restored.
# stop() is a no-op when no session is active.
from ledgrab.core.capture.calibration_session import get_calibration_session
await _bounded("calibration_session.stop", get_calibration_session().stop(), timeout=1.0)
# Stop discovery watcher and OS notification listener so they stop
# firing events into a shutting-down processor manager.
if discovery_watcher is not None:
@@ -486,6 +559,7 @@ async def lifespan(app: FastAPI):
await _bounded("update_service.stop", update_service.stop(), timeout=0.5)
await _bounded("auto_backup_engine.stop", auto_backup_engine.stop(), timeout=0.5)
await _bounded("activity_log_retention.stop", activity_log_retention_engine.stop(), timeout=0.5)
# Close the DB last so it runs a TRUNCATE checkpoint, flushing the WAL
# into the main file. Without this, writes can survive a graceful app
@@ -606,25 +680,27 @@ async def _access_log(request: Request, call_next):
# ── Auth-gated OpenAPI surface ────────────────────────────────────────────
# Re-add the docs endpoints we disabled above, now protected by the same
# Bearer auth as the rest of the API. When auth is unconfigured, loopback
# clients still get in anonymously (per ``verify_api_key`` policy).
# Re-add the docs endpoints we disabled above, protected by the same Bearer
# auth as the rest of the API. The ``DocsAccess`` dependency relaxes this to
# anonymous access (loopback + LAN) when ``auth.expose_docs`` is set, so the
# docs can be viewed in a browser without a token. When auth is unconfigured,
# loopback clients still get in anonymously (per ``verify_api_key`` policy).
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html # noqa: E402
from ledgrab.api.auth import AuthRequired # noqa: E402
from ledgrab.api.auth import DocsAccess # noqa: E402
@app.get("/openapi.json", include_in_schema=False)
async def _openapi(_auth: AuthRequired):
async def _openapi(_auth: DocsAccess):
return JSONResponse(app.openapi())
@app.get("/docs", include_in_schema=False)
async def _swagger_docs(_auth: AuthRequired):
async def _swagger_docs(_auth: DocsAccess):
return get_swagger_ui_html(openapi_url="/openapi.json", title=f"{app.title} — API docs")
@app.get("/redoc", include_in_schema=False)
async def _redoc_docs(_auth: AuthRequired):
async def _redoc_docs(_auth: DocsAccess):
return get_redoc_html(openapi_url="/openapi.json", title=f"{app.title} — API docs")
@@ -0,0 +1,770 @@
/*
Activity Log audit viewer tab
Design language: precision-instrument / ledger. Monospaced timestamps,
color-coded severity rail, thin category pills. Clean "terminal" feel
without being cold the primary green accent anchors the live-update dot.
*/
/* ── Panel wrapper ───────────────────────────────────────────────────────── */
.al-panel {
display: flex;
flex-direction: column;
gap: 0;
max-width: 1400px;
margin: 0 auto;
padding: var(--space-lg) var(--space-lg) var(--space-xl);
min-height: 0;
}
/* ── Filter toolbar ──────────────────────────────────────────────────────── */
.al-toolbar {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md) var(--space-md);
/* Match the elevated card surface used by entity cards (.dashboard-target),
not the near-black --bg-secondary, so the panel reads as one of the app's
cards rather than a separate flat sheet. */
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline) solid var(--lux-line, var(--border-color));
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
}
.al-toolbar-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
.al-toolbar-search {
gap: var(--space-sm);
}
/* Search input */
.al-search-wrap {
position: relative;
flex: 1;
min-width: 200px;
max-width: 420px;
}
.al-search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--text-muted);
display: flex;
align-items: center;
}
.al-search-icon .icon {
width: 15px;
height: 15px;
}
.al-search-input {
width: 100%;
padding: 6px 10px 6px 34px;
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-color);
font-size: 0.875rem;
font-family: var(--font-body);
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
outline: none;
}
.al-search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
}
.al-search-input::placeholder { color: var(--text-muted); }
/* Quick presets */
.al-presets {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.al-preset-btn {
padding: 4px 10px;
font-size: 0.75rem;
font-weight: 500;
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-pill);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
white-space: nowrap;
}
.al-preset-btn:hover {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--primary-contrast);
}
/* Clear button */
.al-clear-btn {
padding: 4px 6px;
margin-left: auto;
color: var(--text-secondary);
}
.al-clear-btn:hover { color: var(--danger-color); border-color: var(--danger-color); }
/* Export button + dropdown */
.al-export-wrap {
position: relative;
margin-left: auto;
}
.al-export-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
font-size: 0.8125rem;
}
.al-export-btn .icon { width: 14px; height: 14px; }
/* Caret signals this button opens a menu (rather than firing a direct action),
and rotates to point up while the menu is open. */
.al-export-caret {
display: inline-flex;
margin-left: 1px;
transition: transform var(--duration-fast) var(--ease-out);
}
.al-export-caret .icon { width: 12px; height: 12px; }
.al-export-wrap.open .al-export-caret { transform: rotate(180deg); }
.al-export-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 4px);
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px var(--shadow-color);
z-index: 100;
min-width: 140px;
overflow: hidden;
}
.al-export-wrap.open .al-export-menu { display: block; }
.al-export-menu button {
display: block;
width: 100%;
padding: 8px 14px;
text-align: left;
background: none;
border: none;
color: var(--text-color);
font-size: 0.8125rem;
cursor: pointer;
}
.al-export-menu button:hover,
.al-export-menu button:focus-visible { background: var(--bg-secondary); outline: none; }
/* Filter label */
.al-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
flex-shrink: 0;
}
.al-filter-label-sep { margin-left: var(--space-sm); }
/* Category / severity chips */
.al-chip-group {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.al-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
font-size: 0.75rem;
font-weight: 500;
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-pill);
background: var(--card-bg);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
white-space: nowrap;
}
.al-chip .icon { width: 12px; height: 12px; }
.al-chip:hover {
background: var(--bg-secondary);
border-color: var(--text-secondary);
color: var(--text-color);
}
.al-chip.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--primary-contrast);
}
/* Severity chip colors when active */
.al-sev-chip-error.active { background: var(--danger-color); border-color: var(--danger-color); }
.al-sev-chip-warning.active { background: var(--warning-color); border-color: var(--warning-color); }
.al-sev-chip-info.active { background: var(--info-color); border-color: var(--info-color); }
/* Advanced field row */
.al-toolbar-advanced {
gap: var(--space-sm);
padding-top: var(--space-xs);
border-top: var(--lux-hairline) solid var(--border-color);
}
.al-field-group {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 160px;
flex: 1;
}
.al-field-label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.al-field-input {
padding: 4px 8px;
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-color);
font-size: 0.8125rem;
font-family: var(--font-body);
outline: none;
transition: border-color var(--duration-fast);
}
.al-field-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.12);
}
/* ── List header (count + live dot) ─────────────────────────────────────── */
.al-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-xs) 0;
margin-bottom: var(--space-xs);
}
.al-count {
font-size: 0.8125rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.al-live-indicator {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.75rem;
font-weight: 500;
color: var(--primary-text-color);
letter-spacing: 0.02em;
}
.al-live-dot {
width: 7px;
height: 7px;
background: var(--primary-color);
border-radius: 50%;
animation: al-pulse 2s infinite;
}
@keyframes al-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.85); }
}
/* ── Entry rows ─────────────────────────────────────────────────────────── */
.al-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.al-entry {
/* Same elevated surface + hairline as entity cards (see .al-toolbar). */
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline) solid var(--lux-line, var(--border-color));
border-radius: var(--radius-sm);
overflow: hidden;
transition: border-color var(--duration-fast);
}
.al-entry:hover { border-color: var(--text-muted); }
/* New-entry flash settles on the card surface (animation-fill-mode: forwards
holds the 100% frame, so it must match the .al-entry background exactly). */
@keyframes al-new-flash {
0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--lux-bg-1, var(--card-bg))); }
100% { background: var(--lux-bg-1, var(--card-bg)); }
}
.al-entry-new.al-entry-appear { animation: al-new-flash 1.8s var(--ease-out) forwards; }
.al-entry-row {
display: grid;
/* icon | time | badge | actor | message | entity | chevron
badge is fixed so all category names (AUTHCAPTURE) occupy identical
width; actor is capped so long usernames don't push the message over;
message takes all remaining space. */
grid-template-columns: 24px 80px 78px minmax(0, 110px) 1fr auto 20px;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-md);
cursor: pointer;
min-height: 36px;
outline: none;
}
.al-entry-row:focus-visible { box-shadow: inset 0 0 0 2px var(--primary-color); }
/* Severity rail icon */
.al-sev { display: flex; align-items: center; justify-content: center; }
.al-sev .icon { width: 14px; height: 14px; }
.al-sev-info .icon { color: var(--info-color); }
.al-sev-warning .icon { color: var(--warning-color); }
.al-sev-error .icon { color: var(--danger-color); }
/* Time */
.al-time {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
color: var(--text-muted);
white-space: nowrap;
}
/* Category badge */
.al-cat-badge {
display: inline-block;
padding: 1px 7px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
border-radius: var(--radius-pill);
white-space: nowrap;
border: var(--lux-hairline) solid transparent;
}
/* Per-category colors — subtle tinted backgrounds */
.al-cat-auth { background: rgba(33, 150, 243, 0.12); color: var(--info-color); border-color: rgba(33, 150, 243, 0.25); }
.al-cat-device { background: rgba(156, 39, 176, 0.10); color: #ab47bc; border-color: rgba(156, 39, 176, 0.22); }
.al-cat-entity { background: rgba(76, 175, 80, 0.12); color: var(--primary-text-color); border-color: rgba(76, 175, 80, 0.25); }
.al-cat-capture { background: rgba(255, 152, 0, 0.12); color: var(--warning-color); border-color: rgba(255, 152, 0, 0.25); }
.al-cat-system { background: rgba(120, 120, 120, 0.12); color: var(--text-secondary); border-color: rgba(120, 120, 120, 0.25); }
[data-theme="light"] .al-cat-auth { background: rgba(33, 150, 243, 0.08); }
/* Darker purple text in light theme the dark-theme #ab47bc fails AA contrast
on the pale tinted background at this small badge size. */
[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); color: #8e24aa; }
[data-theme="light"] .al-cat-entity { background: rgba(76, 175, 80, 0.08); }
[data-theme="light"] .al-cat-capture { background: rgba(255, 152, 0, 0.08); }
[data-theme="light"] .al-cat-system { background: rgba(120, 120, 120, 0.08); }
/* Actor — constrained by its grid column (minmax(0, 110px)) */
.al-actor {
font-size: 0.8125rem;
font-family: var(--font-mono);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* Message — min-width:0 lets the 1fr column actually truncate */
.al-msg {
font-size: 0.8125rem;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
text-align: left;
}
/* Entity crosslink */
.al-entity { display: flex; align-items: center; }
.al-entity-link {
font-size: 0.75rem;
color: var(--primary-text-color);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
display: block;
}
.al-entity-link:hover { color: var(--primary-hover); text-decoration-style: solid; }
.al-entity-name {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Expand chevron */
.al-expand-chevron {
font-size: 0.6875rem;
color: var(--text-muted);
justify-self: end;
user-select: none;
}
/* ── Entry detail drawer ─────────────────────────────────────────────────── */
.al-detail {
padding: var(--space-sm) var(--space-md) var(--space-md);
border-top: var(--lux-hairline) solid var(--border-color);
background: var(--bg-secondary);
animation: al-detail-open var(--duration-fast) var(--ease-out);
}
@keyframes al-detail-open {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.al-detail-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px var(--space-md);
font-size: 0.8125rem;
}
.al-detail-grid dt {
color: var(--text-muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
align-self: start;
padding-top: 2px;
white-space: nowrap;
}
.al-detail-grid dd {
color: var(--text-color);
word-break: break-all;
}
.al-detail-grid code {
font-family: var(--font-mono);
font-size: 0.8125rem;
background: var(--bg-color);
padding: 1px 5px;
border-radius: var(--radius-sm);
border: var(--lux-hairline) solid var(--border-color);
}
.al-meta-pre {
font-family: var(--font-mono);
font-size: 0.75rem;
background: var(--bg-color);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
padding: var(--space-sm);
overflow-x: auto;
white-space: pre;
color: var(--text-secondary);
max-height: 220px;
overflow-y: auto;
margin: 0;
}
/* ── Load More ───────────────────────────────────────────────────────────── */
.al-load-more {
display: block;
width: 100%;
margin-top: var(--space-md);
text-align: center;
font-size: 0.875rem;
}
/* ── Empty / loading / error states ─────────────────────────────────────── */
.al-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xl);
color: var(--text-muted);
font-size: 0.875rem;
text-align: center;
}
.al-state-icon .icon {
width: 36px;
height: 36px;
opacity: 0.35;
}
.al-loading { flex-direction: row; padding: var(--space-lg); }
.al-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: al-spin 0.6s linear infinite;
flex-shrink: 0;
}
@keyframes al-spin { to { transform: rotate(360deg); } }
.al-error .al-state-icon .icon { color: var(--danger-color); opacity: 0.6; }
/* ── List container ──────────────────────────────────────────────────────── */
.al-list-container {
flex: 1;
min-height: 0;
}
/* Subtle busy state while a slow re-query is in flight: the current rows stay
visible (no spinner flash) but dim slightly and stop accepting clicks until
the fresh results swap in. Only applied after a short delay, so instant
filtering shows nothing. */
.al-list-container.al-busy {
opacity: 0.55;
pointer-events: none;
transition: opacity var(--duration-fast) var(--ease-out);
}
/* ── Tabular-nums utility ────────────────────────────────────────────────── */
.tabular-nums { font-variant-numeric: tabular-nums; }
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.al-entry-row {
/* icon | time | badge (fixed) | message | chevron — actor+entity hidden */
grid-template-columns: 20px 70px 78px 1fr 18px;
}
/* Hide actor and entity link at small widths */
.al-actor,
.al-entity { display: none; }
}
@media (max-width: 600px) {
.al-panel { padding: var(--space-sm); }
.al-entry-row {
grid-template-columns: 20px 1fr auto 18px;
grid-template-rows: auto auto;
gap: 4px var(--space-xs);
}
/* Row 1: [sev] [message] [badge] [chevron]; Row 2: [time] under the message.
message stays in its own 1fr column so it never overlaps the badge. */
.al-time { grid-column: 2; grid-row: 2; font-size: 0.6875rem; }
.al-cat-badge{ grid-column: 3; grid-row: 1; }
.al-msg { grid-column: 2; grid-row: 1; }
.al-toolbar-advanced .al-field-group { min-width: 100%; }
}
/*
Dashboard "Recent Activity" widget (.dal-*)
Compact, consistent with the precision-instrument language of the full tab.
Rows are tighter than the full viewer just sev icon + relative time + msg.
*/
/* List container */
.dal-list {
display: flex;
flex-direction: column;
gap: 0;
}
/* Compact entry row */
.al-compact-row {
display: grid;
grid-template-columns: 18px 52px 1fr;
align-items: center;
gap: 0 8px;
padding: 5px 4px;
border-bottom: 1px solid var(--border-color);
min-height: 28px;
transition: background 0.12s;
}
.al-compact-row:last-child {
border-bottom: none;
}
.al-compact-row:hover {
background: var(--bg-secondary);
}
.al-compact-icon .icon {
width: 14px;
height: 14px;
display: block;
flex-shrink: 0;
}
.al-compact-time {
font-family: var(--font-mono, monospace);
font-size: 0.6875rem;
color: var(--text-muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.al-compact-msg {
font-size: 0.8125rem;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Severity color on the row itself (row inherits .al-sev-* from renderCompactEntry) */
.al-compact-row.al-sev-error .al-compact-icon .icon { color: var(--danger-color); }
.al-compact-row.al-sev-warning .al-compact-icon .icon { color: var(--warning-color); }
.al-compact-row.al-sev-info .al-compact-icon .icon { color: var(--info-color); }
/* Empty state inside widget */
.dal-empty {
padding: 16px 8px;
}
.dal-empty p {
font-size: 0.8125rem;
color: var(--text-muted);
}
/* Loading state placeholder */
.dal-loading {
padding: 16px 8px;
display: flex;
align-items: center;
justify-content: center;
}
/* Footer — "View all →" link */
.dal-footer {
padding: 6px 4px 2px;
display: flex;
justify-content: flex-end;
}
.dal-view-all {
font-size: 0.8125rem;
color: var(--primary-text-color);
text-decoration: none;
border: none;
background: none;
cursor: pointer;
padding: 2px 4px;
transition: opacity 0.15s;
}
.dal-view-all:hover {
opacity: 0.75;
text-decoration: underline;
}
/*
Settings panel helpers (ds-info-note, ds-inline-link)
These are general enough to live here but scoped tightly enough to not
bleed into the rest of the settings layout.
*/
.ds-info-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
margin-bottom: 16px;
background: color-mix(in srgb, var(--info-color) 8%, var(--bg-secondary));
border: 1px solid color-mix(in srgb, var(--info-color) 25%, var(--border-color));
border-radius: var(--radius-sm, 4px);
font-size: 0.8125rem;
color: var(--text-color);
line-height: 1.5;
}
.ds-info-note .icon {
width: 15px;
height: 15px;
flex-shrink: 0;
margin-top: 1px;
color: var(--info-color);
}
/* Inline text button that looks like a link (used in ds-info-note, hints) */
.ds-inline-link {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: var(--primary-text-color);
font-size: inherit;
font-family: inherit;
text-decoration: underline;
text-underline-offset: 2px;
display: inline;
}
.ds-inline-link:hover {
opacity: 0.8;
}
+1
View File
@@ -19,5 +19,6 @@
@import './graph-editor.css';
@import './appearance.css';
@import './game-integration.css';
@import './activity-log.css';
@import './mobile.css';
@import './tv.css';
@@ -152,6 +152,50 @@
border-left: 1px solid var(--border-color);
}
/* Weekday + timezone scheduling (time_of_day rule) */
.rule-weekday-block,
.rule-tz-block {
margin-top: 12px;
}
.rule-field-label {
display: block;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 6px;
}
.weekday-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.weekday-chip {
flex: 1 1 auto;
min-width: 40px;
padding: 6px 8px;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--card-bg);
color: var(--text-muted);
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.weekday-chip:hover {
border-color: var(--primary-color);
}
.weekday-chip.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
.rule-tz-block input.rule-timezone {
width: 100%;
}
.time-range-label {
font-size: 0.65rem;
font-weight: 700;
@@ -79,6 +79,12 @@
opacity: 1;
}
.preview-screen-total:focus-visible {
opacity: 1;
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.preview-screen-total.mismatch {
color: #FFC107;
}
@@ -123,6 +129,12 @@
background: rgba(128, 128, 128, 0.25);
}
.edge-toggle:focus-visible {
background: rgba(128, 128, 128, 0.25);
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.preview-edge.edge-disabled {
opacity: 0.25;
pointer-events: none;
@@ -374,6 +386,13 @@
color: rgba(76, 175, 80, 0.6);
}
.preview-corner:focus-visible {
color: rgba(76, 175, 80, 0.6);
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: 4px;
}
.preview-corner.active:hover {
transform: none;
}
@@ -412,6 +431,12 @@
background: rgba(255, 255, 255, 0.25);
}
.direction-toggle:focus-visible {
background: rgba(255, 255, 255, 0.25);
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.direction-toggle #direction-icon {
font-size: 14px;
}
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -2512,7 +2512,11 @@
padding-top: 8px;
}
.error-message {
/* `.modal-error` is the convention recommended in contexts/frontend.md and is
used by several modals; it aliases `.error-message` so both render the same
inline error banner. */
.error-message,
.modal-error {
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
border: 1px solid var(--danger-color);
color: var(--danger-color);
+118 -3
View File
@@ -36,7 +36,16 @@ import {
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
startIntegrationsTutorial,
closeTutorial, tutorialNext, tutorialPrev,
TOUR_KEY,
} from './features/tutorials.ts';
import {
openSetupWizard, closeSetupWizard,
checkAndOpenWizardIfNeeded,
wizardNext, wizardBack, wizardSkip, wizardFinish,
wizardShowManual, wizardHideManual, wizardRescan,
wizardSelectDiscovered, wizardAddManualDevice, wizardUseExistingDevice,
wizardSelectDisplay,
} from './features/setup-wizard.ts';
// Layer 4: devices, dashboard, streams, pattern-templates, automations
import {
@@ -116,6 +125,11 @@ import {
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
addSceneTarget,
} from './features/scene-presets.ts';
import {
openPlaylistEditor, editPlaylist, savePlaylist, closePlaylistEditor,
clonePlaylist, deletePlaylist, addPlaylistItem,
startScenePlaylist, stopScenePlaylist,
} from './features/scene-playlists.ts';
// Layer 5: device-discovery, targets
import {
@@ -198,12 +212,39 @@ import {
updateOffsetSkipLock, updateCalibrationPreview,
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
showCSSCalibration, toggleCalibrationOverlay,
openAutoCalFromCalibration,
} from './features/calibration.ts';
import {
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
updateCalibrationLine, resetCalibrationView,
} from './features/advanced-calibration.ts';
import {
showAutoCalibration, closeAutoCalModal,
autoCalSelectDevice, autoCalSetCorner, autoCalSetDirection,
autoCalBackToCorner, autoCalBackToDirection,
autoCalSweepForward, autoCalSweepBack, autoCalMarkCorner,
autoCalSolve, autoCalSave, autoCalCancel,
mountAutoCalibration, unmountAutoCalibration,
} from './features/auto-calibration.ts';
// Layer 5: activity log
import {
loadActivityLog,
activityLogToggleDetail,
activityLogToggleCat,
activityLogToggleSev,
activityLogOnSearch,
activityLogOnActor,
activityLogOnEntityType,
activityLogOnSince,
activityLogOnUntil,
activityLogClearFilters,
activityLogPreset,
activityLogLoadMore,
activityLogExport,
activityLogNavigateToEntity,
} from './features/activity-log.ts';
// Layer 5.5: graph editor
import {
@@ -234,6 +275,7 @@ import {
loadDaylightTimezone, saveDaylightTimezone,
requestNotifPermissionFromSettings, testNotifFromSettings,
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
loadActivityLogSettings, saveActivityLogSettings, activityLogSettingsExport, clearActivityLog,
} from './features/settings.ts';
import {
loadUpdateStatus, initUpdateListener, checkForUpdates,
@@ -315,6 +357,21 @@ Object.assign(window, {
selectDisplay,
formatDisplayLabel,
// setup wizard
openSetupWizard,
closeSetupWizard,
wizardNext,
wizardBack,
wizardSkip,
wizardFinish,
wizardShowManual,
wizardHideManual,
wizardRescan,
wizardSelectDiscovered,
wizardAddManualDevice,
wizardUseExistingDevice,
wizardSelectDisplay,
// tutorials
startCalibrationTutorial,
startDeviceTutorial,
@@ -463,6 +520,17 @@ Object.assign(window, {
recaptureScenePreset,
addSceneTarget,
// scene playlists — modal buttons + mod-card inline handlers
openPlaylistEditor,
editPlaylist,
savePlaylist,
closePlaylistEditor,
clonePlaylist,
deletePlaylist,
addPlaylistItem,
startScenePlaylist,
stopScenePlaylist,
// integrations
loadIntegrations,
switchIntegrationTab,
@@ -604,6 +672,24 @@ Object.assign(window, {
toggleTestEdge,
showCSSCalibration,
toggleCalibrationOverlay,
openAutoCalFromCalibration,
// auto-calibration wizard
showAutoCalibration,
closeAutoCalModal,
autoCalSelectDevice,
autoCalSetCorner,
autoCalSetDirection,
autoCalBackToCorner,
autoCalBackToDirection,
autoCalSweepForward,
autoCalSweepBack,
autoCalMarkCorner,
autoCalSolve,
autoCalSave,
autoCalCancel,
mountAutoCalibration,
unmountAutoCalibration,
// advanced calibration
showAdvancedCalibration,
@@ -674,6 +760,10 @@ Object.assign(window, {
saveExternalUrl,
revertExternalUrl,
getBaseOrigin,
loadActivityLogSettings,
saveActivityLogSettings,
activityLogSettingsExport,
clearActivityLog,
// update
checkForUpdates,
@@ -695,6 +785,22 @@ Object.assign(window, {
applyStylePreset,
applyBgEffect,
renderAppearanceTab,
// activity log
loadActivityLog,
activityLogToggleDetail,
activityLogToggleCat,
activityLogToggleSev,
activityLogOnSearch,
activityLogOnActor,
activityLogOnEntityType,
activityLogOnSince,
activityLogOnUntil,
activityLogClearFilters,
activityLogPreset,
activityLogLoadMore,
activityLogExport,
activityLogNavigateToEntity,
});
// ─── Global keyboard shortcuts ───
@@ -712,7 +818,7 @@ document.addEventListener('keydown', (e) => {
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs)
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph' };
const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph', '7': 'activity_log' };
const tab = tabMap[e.key];
if (tab) {
e.preventDefault();
@@ -908,8 +1014,17 @@ document.addEventListener('DOMContentLoaded', async () => {
setProjectUrls(serverRepoUrl, serverDonateUrl);
initDonationBanner();
// Show getting-started tutorial on first visit
if (!localStorage.getItem('tour_completed')) {
// First-run: wizard wins over the tooltip tour.
//
// Precedence (explicit):
// 1. If backend says onboarded=false AND no output targets exist
// → open the setup wizard (suppresses tooltip tour — wizard owns
// the first-run experience; it sets localStorage TOUR_KEY on
// completion/skip so the tour never double-fires on reload).
// 2. Otherwise (already onboarded, or has targets but no wizard flag)
// → fall back to the existing tooltip tour logic unchanged.
const wizardOpened = await checkAndOpenWizardIfNeeded();
if (!wizardOpened && !localStorage.getItem(TOUR_KEY)) {
setTimeout(() => startGettingStartedTutorial(), 600);
}
} catch (err) {
@@ -8,7 +8,7 @@
import {
devicesCache, outputTargetsCache, colorStripSourcesCache,
streamsCache, audioSourcesCache, valueSourcesCache,
syncClocksCache, automationsCacheObj, scenePresetsCache,
syncClocksCache, automationsCacheObj, scenePresetsCache, scenePlaylistsCache,
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
patternTemplatesCache,
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
@@ -26,6 +26,7 @@ const ENTITY_CACHE_MAP = {
sync_clock: syncClocksCache,
automation: automationsCacheObj,
scene_preset: scenePresetsCache,
scene_playlist: scenePlaylistsCache,
capture_template: captureTemplatesCache,
audio_template: audioTemplatesCache,
pp_template: ppTemplatesCache,
@@ -51,6 +52,7 @@ const ENTITY_LOADER_MAP = {
pp_template: 'loadPictureSources',
automation: 'loadAutomations',
scene_preset: 'loadAutomations',
scene_playlist: 'loadAutomations',
weather_source: 'loadIntegrations',
home_assistant_source: 'loadIntegrations',
mqtt_source: 'loadIntegrations',
@@ -32,6 +32,7 @@ import { openAuthedWs } from './ws-auth.ts';
* update_download_progress update_service.py (consumed by features/update.ts)
* device_discovered discovery_watcher.py (consumed by features/notifications-watcher.ts)
* device_lost discovery_watcher.py (consumed by features/notifications-watcher.ts)
* activity_logged core/activity_log/recorder.py (consumed by features/activity-log.ts)
*
* Missing any of these silently breaks the corresponding UI flow keep
* this list in sync when adding new event types on the server side.
@@ -40,12 +41,14 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
'server_restarting',
'state_change',
'automation_state_changed',
'playlist_state_changed',
'entity_changed',
'device_health_changed',
'update_available',
'update_download_progress',
'device_discovered',
'device_lost',
'activity_logged', // source: core/activity_log/recorder.py
]);
interface ServerEventEnvelope {
@@ -135,6 +135,17 @@ export const armchair = '<path d="M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3"/>
// Lucide: leaf
export const leaf = '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19.2 2.96c1 4.34.06 9.65-3.4 13.04A6.96 6.96 0 0 1 11 20z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>';
// Lucide: scroll-text (audit / activity log)
export const scrollText = '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>';
// Lucide: circle-alert (error severity)
export const circleAlert = '<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>';
// Lucide: info (info severity)
export const info = '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>';
// Lucide: filter (filter toolbar)
export const filter = '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>';
// Lucide: x-circle (clear/reset)
export const xCircle = '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>';
// Easing curve glyphs — custom mini-charts that draw the actual curve.
// Curve travels from (4, 20) to (20, 4); each path renders the easing
// function directly so the picker shows the shape, not a metaphor.
@@ -354,6 +354,15 @@ export const ICON_CIRCLE = _svg(P.circle);
export const ICON_GIT_MERGE = _svg(P.gitMerge);
export const ICON_COPY = _svg(P.copy);
// ── Activity log icons ─────────────────────────────────────
export const ICON_ACTIVITY_LOG = _svg(P.scrollText);
export const ICON_SEVERITY_INFO = _svg(P.info);
export const ICON_SEVERITY_WARN = _svg(P.triangleAlert);
export const ICON_SEVERITY_ERR = _svg(P.circleAlert);
export const ICON_FILTER = _svg(P.filter);
export const ICON_X_CIRCLE = _svg(P.xCircle);
// ── Game integration icons ─────────────────────────────────
export const ICON_GAMEPAD = _svg(P.gamepad2);
+6 -1
View File
@@ -15,7 +15,7 @@
import { DataCache } from './cache.ts';
import type {
Device, OutputTarget, ColorStripSource, PatternTemplate,
ValueSource, AudioSource, PictureSource, ScenePreset,
ValueSource, AudioSource, PictureSource, ScenePreset, ScenePlaylist,
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
@@ -436,6 +436,11 @@ export const scenePresetsCache = new DataCache<ScenePreset[]>({
extractData: json => json.presets || [],
});
export const scenePlaylistsCache = new DataCache<ScenePlaylist[]>({
endpoint: '/scene-playlists',
extractData: json => json.playlists || [],
});
export interface GradientEntity {
id: string;
name: string;
@@ -28,6 +28,7 @@ const TAB_REGISTRY: Readonly<Record<string, TabConfig>> = {
automations: { loadFnName: 'loadAutomations',
subTab: { storageKey: 'activeAutomationTab', defaultSubTab: 'automations', switchFnName: 'switchAutomationTab' } },
graph: { loadFnName: 'loadGraphEditor' },
activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false },
};
/** Get the full config for a tab, or undefined if not registered. */
+84
View File
@@ -555,6 +555,90 @@ export function formatCompact(n: number | null | undefined) {
return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B';
}
/**
* Format an ISO-8601 timestamp (or ms epoch) into a locale-aware string.
* Returns "Today · HH:MM", "Yesterday · HH:MM", or "DD MMM · HH:MM".
* Use `font-variant-numeric: tabular-nums` on the element for stable layout.
*/
export function formatTimestamp(isoOrMs: string | number): string {
const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs);
if (isNaN(d.getTime())) return String(isoOrMs);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yestStart = new Date(todayStart.getTime() - 86400000);
const hhmm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
if (d >= todayStart) {
return `${t('time.today')} · ${hhmm}`;
} else if (d >= yestStart) {
return `${t('time.yesterday')} · ${hhmm}`;
} else {
const dateStr = d.toLocaleDateString([], { day: 'numeric', month: 'short' });
return `${dateStr} · ${hhmm}`;
}
}
/**
* Format an ISO-8601 timestamp (or ms epoch) as a compact relative string.
* Examples: "just now", "2m ago", "3h ago", "5d ago".
* Use font-variant-numeric: tabular-nums on elements that update frequently.
*/
export function formatRelativeTime(isoOrMs: string | number): string {
const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs);
if (isNaN(d.getTime())) return String(isoOrMs);
const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
if (diffSec < 10) return t('time.just_now');
if (diffSec < 60) return t('time.seconds_ago', { n: diffSec });
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return t('time.minutes_ago', { n: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return t('time.hours_ago', { n: diffHr });
const diffDays = Math.floor(diffHr / 24);
return t('time.days_ago', { n: diffDays });
}
// ── Shared relative-time ticker ──────────────────────────────────────────────
// A single process-wide interval that keeps every `[data-reltime]` element
// up to date. Call `ensureRelativeTimeTicker()` from any feature that renders
// such elements — repeated calls are idempotent (one interval, ever).
let _relTimeIntervalId: ReturnType<typeof setInterval> | null = null;
let _relTimeVisibilityBound = false;
/** Refresh every `[data-reltime]` element's text content to the current
* relative-time label produced by `formatRelativeTime`. */
function _tickRelativeTimes(): void {
if (document.hidden) return;
document.querySelectorAll<HTMLElement>('[data-reltime]').forEach(el => {
const iso = el.getAttribute('data-reltime');
if (iso) el.textContent = formatRelativeTime(iso);
});
}
/**
* Start the shared relative-time ticker (idempotent safe to call many times).
* Ticks every 30 s, skips work when the tab is hidden, and fires one
* immediate refresh when the tab becomes visible again.
* Also fires one immediate refresh on each `languageChanged` event so
* freshly-translated labels appear without waiting for the next tick.
*/
export function ensureRelativeTimeTicker(): void {
// One-time visibility + language listeners
if (!_relTimeVisibilityBound) {
_relTimeVisibilityBound = true;
document.addEventListener('visibilitychange', () => {
if (!document.hidden) _tickRelativeTimes();
});
document.addEventListener('languageChanged', () => _tickRelativeTimes());
}
// Idempotent: only start the interval once
if (_relTimeIntervalId !== null) return;
_relTimeIntervalId = setInterval(_tickRelativeTimes, 30_000);
}
export function formatUptime(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '-';
const total = Math.floor(seconds);
@@ -0,0 +1,874 @@
/**
* Activity Log tab persistent, queryable audit log viewer.
*
* This is a READ-ONLY viewer (no CRUD), differentiated from the debug
* Log Viewer (utils/log_broadcaster.py) which is an ephemeral 500-line tail.
* This tab shows structured, semantic audit entries backed by the SQLite
* activity_log table.
*
* Phase 5: Activity tab + smart filtering + live updates.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, formatRelativeTime, ensureRelativeTimeTicker } from '../core/ui.ts';
import { navigateToCard } from '../core/navigation.ts';
import {
ICON_ACTIVITY_LOG, ICON_SEVERITY_INFO, ICON_SEVERITY_WARN, ICON_SEVERITY_ERR,
ICON_X_CIRCLE, ICON_DOWNLOAD, ICON_SEARCH,
ICON_CHEVRON_UP, ICON_CHEVRON_DOWN,
} from '../core/icons.ts';
/**
* Escape a string for safe use inside an HTML attribute value (quoted with
* either `"` or `'`). Extends escapeHtml's `<>&` coverage with `"` and `'`.
*/
function _escapeAttr(text: string): string {
if (!text) return '';
return escapeHtml(text)
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ─── Types ───────────────────────────────────────────────────
export interface ActivityEntry {
id: string;
ts: string;
category: string;
action: string;
severity: string;
actor: string;
entity_type: string | null;
entity_id: string | null;
entity_name: string | null;
message: string;
metadata: Record<string, unknown>;
}
interface ActivityPage {
entries: ActivityEntry[];
next_before_seq: number | null;
has_more: boolean;
total: number;
}
interface ActiveFilters {
categories: string[];
severities: string[];
actor: string;
entity_type: string;
since: string;
until: string;
q: string;
}
// ─── Module state ────────────────────────────────────────────
let _initialized = false;
let _loading = false;
let _entries: ActivityEntry[] = [];
let _nextBeforeSeq: number | null = null;
let _hasMore = false;
let _total = 0;
let _expandedIds = new Set<string>();
let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
let _liveEventListener: ((e: Event) => void) | null = null;
// Loading UX: `_showSpinner` gates the full-panel spinner so it only appears
// after a short delay (slow requests), never flashing on instant filtering.
// `_hasLoadedOnce` distinguishes the genuine first load (spinner immediately)
// from re-queries (keep current rows, subtle delayed busy hint).
let _loadingDelayTimer: ReturnType<typeof setTimeout> | null = null;
let _showSpinner = false;
let _hasLoadedOnce = false;
const _filters: ActiveFilters = {
categories: [],
severities: [],
actor: '',
entity_type: '',
since: '',
until: '',
q: '',
};
// ─── Category → navigation target map (entity crosslinks) ──
const _ENTITY_NAV: Record<string, { tab: string; subTab: string | null; attr: string } | null> = {
output_target: { tab: 'targets', subTab: 'led-targets', attr: 'data-target-id' },
device: { tab: 'targets', subTab: 'led-devices', attr: 'data-device-id' },
picture_source: { tab: 'streams', subTab: 'raw', attr: 'data-stream-id' },
color_strip_source: { tab: 'streams', subTab: 'color_strip', attr: 'data-css-id' },
audio_source: { tab: 'streams', subTab: 'audio_capture', attr: 'data-id' },
automation: { tab: 'automations', subTab: 'automations', attr: 'data-automation-id' },
scene_preset: { tab: 'automations', subTab: 'scenes', attr: 'data-scene-id' },
scene_playlist: { tab: 'automations', subTab: 'playlists', attr: 'data-playlist-id' },
};
// ─── Severity icon helper ────────────────────────────────────
function _severityIcon(severity: string): string {
if (severity === 'error') return ICON_SEVERITY_ERR;
if (severity === 'warning') return ICON_SEVERITY_WARN;
return ICON_SEVERITY_INFO;
}
function _severityClass(severity: string): string {
if (severity === 'error') return 'al-sev-error';
if (severity === 'warning') return 'al-sev-warning';
return 'al-sev-info';
}
// ─── Category label helper ───────────────────────────────────
function _categoryLabel(category: string): string {
return t(`activity_log.category.${category}`);
}
// ─── Localized entity-type label ────────────────────────────
function _entityTypeLabel(entityType: string): string {
const key = `activity_log.entity_type.${entityType}`;
const translated = t(key);
// If t() returned the key unchanged there is no translation — humanize it
if (translated === key) {
return entityType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
return translated;
}
// ─── Client-side message localization ───────────────────────
//
// Maps entry.action → an i18n template key and extracts placeholders
// from the structured fields so the displayed description is rendered
// in the user's locale rather than the server-generated English string.
//
// Fallback: if the template key is missing (t() returns the key
// unchanged) we return entry.message (the original server string) so
// the UI always shows something sensible.
export function localizeMessage(entry: ActivityEntry): string {
const meta = entry.metadata || {};
// Build a params bag from structured fields + metadata.
// Keys match the {placeholder} names used in locale templates.
const params: Record<string, string> = {
name: entry.entity_name ?? '',
actor: entry.actor ?? '',
type: entry.entity_type ? _entityTypeLabel(entry.entity_type) : '',
key: String(meta.setting_key ?? ''),
address: String(meta.address ?? meta.url ?? ''),
reason: String(meta.reason ?? ''),
client: String(meta.client ?? ''),
device_type: String(meta.device_type ?? ''),
filename: String(meta.filename ?? ''),
};
// The backend always emits dotted actions (e.g. "entity.created",
// "auth.ws_connected"), so the template key is a direct 1:1 mapping.
const templateKey = `activity_log.msg.${entry.action}`;
const localized = t(templateKey, params);
// t() returns the key unchanged when there is no matching translation.
if (localized === templateKey) {
return entry.message;
}
return localized;
}
// ─── Build query string from active filters + cursor ────────
function _buildQuery(beforeSeq: number | null = null): string {
const params = new URLSearchParams();
for (const cat of _filters.categories) params.append('categories', cat);
for (const sev of _filters.severities) params.append('severities', sev);
if (_filters.actor) params.set('actor', _filters.actor);
if (_filters.entity_type) params.set('entity_type', _filters.entity_type);
if (_filters.since) params.set('since', _filters.since);
if (_filters.until) params.set('until', _filters.until);
if (_filters.q) params.set('q', _filters.q);
if (beforeSeq != null) params.set('before_seq', String(beforeSeq));
params.set('limit', '50');
const qs = params.toString();
return qs ? `?${qs}` : '';
}
// ─── Entry rendering ─────────────────────────────────────────
function _renderEntryRow(entry: ActivityEntry, isNew = false): string {
const relTime = formatRelativeTime(entry.ts);
const iso = entry.ts;
const expanded = _expandedIds.has(entry.id);
const sevClass = _severityClass(entry.severity);
const sevIcon = _severityIcon(entry.severity);
// Entity crosslink — use data-* attributes + delegated listener (no JSON in onclick)
let entityHtml = '';
if (entry.entity_type && entry.entity_name) {
const nav = _ENTITY_NAV[entry.entity_type];
if (nav) {
const escapedName = escapeHtml(entry.entity_name);
const attrEntityType = _escapeAttr(entry.entity_type);
const attrEntityId = _escapeAttr(entry.entity_id || '');
entityHtml = `<button class="al-entity-link" type="button"
data-entity-type="${attrEntityType}" data-entity-id="${attrEntityId}"
title="${_escapeAttr(entry.entity_name)}">${escapedName}</button>`;
} else {
entityHtml = `<span class="al-entity-name">${escapeHtml(entry.entity_name)}</span>`;
}
}
const detailHtml = expanded ? _renderEntryDetail(entry) : '';
const attrEntryId = _escapeAttr(entry.id);
return `<div class="al-entry${isNew ? ' al-entry-new' : ''}" data-al-id="${_escapeAttr(entry.id)}">
<div class="al-entry-row" data-toggle-id="${attrEntryId}"
role="button" tabindex="0" aria-expanded="${expanded}">
<span class="al-sev ${sevClass}" title="${_escapeAttr(t(`activity_log.severity.${entry.severity}`))}">${sevIcon}</span>
<span class="al-time tabular-nums" title="${_escapeAttr(iso)}" data-reltime="${_escapeAttr(iso)}">${escapeHtml(relTime)}</span>
<span class="al-cat-badge al-cat-${escapeHtml(entry.category)}">${escapeHtml(_categoryLabel(entry.category))}</span>
<span class="al-actor">${escapeHtml(entry.actor)}</span>
<span class="al-msg">${escapeHtml(localizeMessage(entry))}</span>
${entityHtml ? `<span class="al-entity">${entityHtml}</span>` : ''}
<span class="al-expand-chevron" aria-hidden="true">${expanded ? ICON_CHEVRON_UP : ICON_CHEVRON_DOWN}</span>
</div>
${detailHtml}
</div>`;
}
function _renderEntryDetail(entry: ActivityEntry): string {
const metaJson = JSON.stringify(entry.metadata, null, 2);
const absTime = entry.ts ? new Date(entry.ts).toLocaleString() : '';
return `<div class="al-detail" role="region" aria-label="${escapeHtml(t('activity_log.detail.title'))}">
<dl class="al-detail-grid">
<dt>${escapeHtml(t('activity_log.detail.id'))}</dt>
<dd class="tabular-nums"><code>${escapeHtml(entry.id)}</code></dd>
<dt>${escapeHtml(t('activity_log.detail.timestamp'))}</dt>
<dd class="tabular-nums">${escapeHtml(absTime)}</dd>
<dt>${escapeHtml(t('activity_log.detail.action'))}</dt>
<dd><code>${escapeHtml(entry.action)}</code></dd>
<dt>${escapeHtml(t('activity_log.detail.actor'))}</dt>
<dd>${escapeHtml(entry.actor)}</dd>
${entry.entity_type ? `<dt>${escapeHtml(t('activity_log.detail.entity'))}</dt>
<dd>${escapeHtml(entry.entity_type)}${entry.entity_id ? ` / <code>${escapeHtml(entry.entity_id)}</code>` : ''}${entry.entity_name ? ` (${escapeHtml(entry.entity_name)})` : ''}</dd>` : ''}
<dt>${escapeHtml(t('activity_log.detail.metadata'))}</dt>
<dd><pre class="al-meta-pre">${escapeHtml(metaJson)}</pre></dd>
</dl>
</div>`;
}
// ─── Filter toolbar rendering ────────────────────────────────
const CATEGORIES = ['auth', 'device', 'entity', 'capture', 'system'];
const SEVERITIES = ['info', 'warning', 'error'];
function _renderFilterToolbar(): string {
const catChips = CATEGORIES.map(cat => {
const active = _filters.categories.includes(cat);
return `<button class="al-chip al-cat-chip${active ? ' active' : ''} al-cat-${cat}"
type="button" onclick="activityLogToggleCat('${cat}')"
aria-pressed="${active}">${escapeHtml(_categoryLabel(cat))}</button>`;
}).join('');
const sevChips = SEVERITIES.map(sev => {
const active = _filters.severities.includes(sev);
const icon = _severityIcon(sev);
return `<button class="al-chip al-sev-chip${active ? ' active' : ''} al-sev-chip-${sev}"
type="button" onclick="activityLogToggleSev('${sev}')"
aria-pressed="${active}">${icon} ${escapeHtml(t(`activity_log.severity.${sev}`))}</button>`;
}).join('');
const presets = [
{ key: 'today', label: t('activity_log.preset.today') },
{ key: 'errors', label: t('activity_log.preset.errors') },
{ key: 'auth', label: t('activity_log.preset.auth') },
{ key: 'devices', label: t('activity_log.preset.devices') },
];
const presetBtns = presets.map(p =>
`<button class="al-preset-btn" type="button" onclick="activityLogPreset('${p.key}')">${escapeHtml(p.label)}</button>`
).join('');
const hasFilters = _filters.categories.length || _filters.severities.length ||
_filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q;
return `<div class="al-toolbar" role="search" aria-label="${escapeHtml(t('activity_log.filter.title'))}">
<div class="al-toolbar-row al-toolbar-search">
<div class="al-search-wrap">
<span class="al-search-icon" aria-hidden="true">${ICON_SEARCH}</span>
<input class="al-search-input" type="search" id="al-search-input"
placeholder="${escapeHtml(t('activity_log.filter.search'))}"
value="${_escapeAttr(_filters.q)}"
oninput="activityLogOnSearch(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.search'))}">
</div>
<div class="al-presets">${presetBtns}</div>
${hasFilters ? `<button class="al-clear-btn btn btn-icon btn-secondary" type="button"
onclick="activityLogClearFilters()" title="${escapeHtml(t('activity_log.filter.clear'))}"
aria-label="${escapeHtml(t('activity_log.filter.clear'))}">${ICON_X_CIRCLE}</button>` : ''}
<div class="al-export-wrap">
<button class="btn btn-secondary al-export-btn" type="button"
data-al-export-toggle aria-haspopup="menu" aria-expanded="false"
title="${escapeHtml(t('activity_log.export'))}"
aria-label="${escapeHtml(t('activity_log.export'))}">${ICON_DOWNLOAD} <span>${escapeHtml(t('activity_log.export'))}</span><span class="al-export-caret" aria-hidden="true">${ICON_CHEVRON_DOWN}</span></button>
<div class="al-export-menu" role="menu">
<button type="button" role="menuitem" onclick="activityLogExport('csv')">${escapeHtml(t('activity_log.export.csv'))}</button>
<button type="button" role="menuitem" onclick="activityLogExport('json')">${escapeHtml(t('activity_log.export.json'))}</button>
</div>
</div>
</div>
<div class="al-toolbar-row al-toolbar-chips">
<span class="al-filter-label">${escapeHtml(t('activity_log.filter.category'))}</span>
<div class="al-chip-group" role="group" aria-label="${escapeHtml(t('activity_log.filter.category'))}">${catChips}</div>
<span class="al-filter-label al-filter-label-sep">${escapeHtml(t('activity_log.filter.severity'))}</span>
<div class="al-chip-group" role="group" aria-label="${escapeHtml(t('activity_log.filter.severity'))}">${sevChips}</div>
</div>
<div class="al-toolbar-row al-toolbar-advanced" id="al-toolbar-advanced">
<div class="al-field-group">
<label for="al-actor-input" class="al-field-label">${escapeHtml(t('activity_log.filter.actor'))}</label>
<input type="text" id="al-actor-input" class="al-field-input"
value="${_escapeAttr(_filters.actor)}"
placeholder="${_escapeAttr(t('activity_log.filter.actor.placeholder'))}"
oninput="activityLogOnActor(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.actor'))}">
</div>
<div class="al-field-group">
<label for="al-entity-type-input" class="al-field-label">${escapeHtml(t('activity_log.filter.entity_type'))}</label>
<input type="text" id="al-entity-type-input" class="al-field-input"
value="${_escapeAttr(_filters.entity_type)}"
placeholder="${_escapeAttr(t('activity_log.filter.entity_type.placeholder'))}"
oninput="activityLogOnEntityType(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.entity_type'))}">
</div>
<div class="al-field-group">
<label for="al-since-input" class="al-field-label">${escapeHtml(t('activity_log.filter.since'))}</label>
<input type="datetime-local" id="al-since-input" class="al-field-input"
value="${_escapeAttr(_filters.since)}"
onchange="activityLogOnSince(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.since'))}">
</div>
<div class="al-field-group">
<label for="al-until-input" class="al-field-label">${escapeHtml(t('activity_log.filter.until'))}</label>
<input type="datetime-local" id="al-until-input" class="al-field-input"
value="${_escapeAttr(_filters.until)}"
onchange="activityLogOnUntil(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.until'))}">
</div>
</div>
</div>`;
}
// ─── List and state rendering ────────────────────────────────
function _renderList(): string {
if (_showSpinner && _entries.length === 0) {
return `<div class="al-state al-loading" role="status" aria-live="polite">
<div class="al-spinner"></div>
<span>${escapeHtml(t('activity_log.loading'))}</span>
</div>`;
}
if (_entries.length === 0) {
const hasFilters = _filters.categories.length || _filters.severities.length ||
_filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q;
const emptyKey = hasFilters ? 'activity_log.empty' : 'activity_log.empty_no_filters';
return `<div class="al-state al-empty" role="status">
<span class="al-state-icon" aria-hidden="true">${ICON_ACTIVITY_LOG}</span>
<p>${escapeHtml(t(emptyKey))}</p>
</div>`;
}
const rows = _entries.map(e => _renderEntryRow(e)).join('');
const loadMore = _hasMore
? `<button class="al-load-more btn btn-secondary" type="button"
onclick="activityLogLoadMore()" aria-label="${escapeHtml(t('activity_log.load_more'))}">${escapeHtml(t('activity_log.load_more'))}</button>`
: '';
const countLabel = t('activity_log.n_entries', { n: _total });
return `<div class="al-list-header">
<span class="al-count tabular-nums">${escapeHtml(countLabel)}</span>
<div class="al-live-indicator" id="al-live-indicator" aria-live="polite">
<span class="al-live-dot" aria-hidden="true"></span>
<span>${escapeHtml(t('activity_log.live'))}</span>
</div>
</div>
<div class="al-list" role="log" aria-label="${escapeHtml(t('activity_log.title'))}" aria-live="polite">
${rows}
</div>
${loadMore}`;
}
// ─── Delegated click handler for entry rows and entity links ─
let _delegatedClickAttached = false;
/** Collapse the export dropdown if open (idempotent). */
function _closeExportMenu(): void {
const wrap = document.getElementById('tab-activity_log')
?.querySelector<HTMLElement>('.al-export-wrap.open');
if (!wrap) return;
wrap.classList.remove('open');
wrap.querySelector('[data-al-export-toggle]')?.setAttribute('aria-expanded', 'false');
}
function _attachDelegatedClicks(): void {
if (_delegatedClickAttached) return;
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
_delegatedClickAttached = true;
panel.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Export menu: toggle button opens/closes the CSV/JSON dropdown.
const exportToggle = target.closest<HTMLElement>('[data-al-export-toggle]');
if (exportToggle) {
const wrap = exportToggle.closest<HTMLElement>('.al-export-wrap');
const open = wrap?.classList.toggle('open') ?? false;
exportToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
return;
}
// Export menu: a menu item's inline onclick triggers the download (it
// runs first, on the deeper element) — we just collapse the menu after.
if (target.closest('.al-export-menu')) {
_closeExportMenu();
return;
}
// Any other click in the panel dismisses an open export menu, then
// continues to row / entity handling below.
_closeExportMenu();
// Entity navigation: click on data-entity-type button
const entityBtn = target.closest<HTMLElement>('button.al-entity-link[data-entity-type]');
if (entityBtn) {
e.stopPropagation();
const entityType = entityBtn.dataset.entityType ?? '';
const entityId = entityBtn.dataset.entityId ?? '';
activityLogNavigateToEntity(entityType, entityId);
return;
}
// Entry row toggle: click on al-entry-row with data-toggle-id
const row = target.closest<HTMLElement>('.al-entry-row[data-toggle-id]');
if (row) {
const entryId = row.dataset.toggleId ?? '';
if (entryId) activityLogToggleDetail(entryId);
return;
}
});
panel.addEventListener('keydown', (e: KeyboardEvent) => {
// Escape closes the export menu and restores focus to its trigger.
if (e.key === 'Escape') {
const toggle = panel.querySelector<HTMLElement>('.al-export-wrap.open [data-al-export-toggle]');
if (toggle) {
_closeExportMenu();
toggle.focus();
}
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
const row = (e.target as HTMLElement).closest<HTMLElement>('.al-entry-row[data-toggle-id]');
if (row) {
e.preventDefault();
const entryId = row.dataset.toggleId ?? '';
if (entryId) activityLogToggleDetail(entryId);
}
});
}
// ─── Full panel render ───────────────────────────────────────
function _render(): void {
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
panel.innerHTML = `<div class="al-panel">
${_renderFilterToolbar()}
<div id="al-list-container" class="al-list-container">
${_renderList()}
</div>
</div>`;
_attachDelegatedClicks();
}
// ─── Partial re-render helpers ───────────────────────────────
function _updateListContainer(): void {
const container = document.getElementById('al-list-container');
if (!container) return;
container.innerHTML = _renderList();
}
// ─── Data fetching ───────────────────────────────────────────
/** Surface a loading affordance only when a request is slow enough to notice. */
function _showDelayedBusy(): void {
if (!_loading) return;
if (_entries.length === 0) {
// Nothing to keep on screen — fall back to the full spinner.
_showSpinner = true;
_updateListContainer();
} else {
// Re-query of a populated list: keep the current rows, just dim them.
const c = document.getElementById('al-list-container');
c?.classList.add('al-busy');
c?.setAttribute('aria-busy', 'true');
}
}
/** Clear all loading affordances (timer, spinner flag, busy dim). Idempotent. */
function _clearBusy(): void {
if (_loadingDelayTimer) {
clearTimeout(_loadingDelayTimer);
_loadingDelayTimer = null;
}
_showSpinner = false;
const c = document.getElementById('al-list-container');
c?.classList.remove('al-busy');
c?.removeAttribute('aria-busy');
}
async function _fetchPage(beforeSeq: number | null = null, append = false): Promise<void> {
if (_loading) return;
_loading = true;
if (!append) {
// Reset the cursor for a fresh query, but DON'T clear `_entries` — keep
// the current rows on screen so filtering an already-populated list
// never flashes the full "Loading" state (the new results replace them
// on arrival).
_nextBeforeSeq = null;
_hasMore = false;
}
if (!_hasLoadedOnce && !append) {
// Genuine first load — there's nothing to show yet, so the spinner is
// the correct (and expected) initial state. Show it immediately.
_showSpinner = true;
_updateListContainer();
} else if (!append) {
// Re-query (filter change / language change): defer any loading hint so
// near-instant responses show nothing at all; a slow request gets a
// subtle dim after the delay.
if (_loadingDelayTimer) clearTimeout(_loadingDelayTimer);
_loadingDelayTimer = setTimeout(_showDelayedBusy, 180);
}
// append (load-more): keep existing rows, no loading indicator.
try {
const qs = _buildQuery(beforeSeq);
const res = await fetchWithAuth(`/activity-log${qs}`);
if (!res || !res.ok) {
throw new Error(`HTTP ${res?.status}`);
}
const page: ActivityPage = await res.json();
// API returns each page oldest-first within the page; reverse to newest-first
// so the in-memory list is newest at index 0 (top of the rendered log).
const pageEntries = [...page.entries].reverse();
if (append) {
_entries = [..._entries, ...pageEntries];
} else {
_entries = pageEntries;
}
_nextBeforeSeq = page.next_before_seq;
_hasMore = page.has_more;
_total = page.total;
_hasLoadedOnce = true;
// Clear loading affordances BEFORE rendering so a zero-result page
// renders the empty state (not the spinner) and a re-query swaps in the
// fresh, undimmed rows.
_clearBusy();
_loading = false;
_updateListContainer();
} catch (e: unknown) {
if (e && typeof e === 'object' && 'isAuth' in e) return;
_clearBusy();
const container = document.getElementById('al-list-container');
if (container) {
container.innerHTML = `<div class="al-state al-error" role="alert">
<span class="al-state-icon" aria-hidden="true">${ICON_SEVERITY_ERR}</span>
<p>${escapeHtml(t('activity_log.error'))}</p>
</div>`;
}
} finally {
_loading = false;
_clearBusy();
}
}
// ─── Filter re-query with debounce for text fields ───────────
function _requery(debounce = false): void {
if (debounce) {
if (_debounceTimer) clearTimeout(_debounceTimer);
_debounceTimer = setTimeout(() => { _fetchPage(null, false); }, 350);
} else {
_fetchPage(null, false);
}
}
// ─── Live event handling ──────────────────────────────────────
function _entryPassesFilters(entry: ActivityEntry): boolean {
if (_filters.categories.length && !_filters.categories.includes(entry.category)) return false;
if (_filters.severities.length && !_filters.severities.includes(entry.severity)) return false;
if (_filters.actor && entry.actor !== _filters.actor) return false;
if (_filters.entity_type && entry.entity_type !== _filters.entity_type) return false;
if (_filters.q) {
const q = _filters.q.toLowerCase();
if (!entry.message.toLowerCase().includes(q) &&
!entry.action.toLowerCase().includes(q) &&
!entry.actor.toLowerCase().includes(q)) return false;
}
// Date range filters: if an entry is brand-new it passes "since" checks trivially
if (_filters.since) {
const sinceMs = new Date(_filters.since).getTime();
if (!isNaN(sinceMs) && new Date(entry.ts).getTime() < sinceMs) return false;
}
if (_filters.until) {
const untilMs = new Date(_filters.until).getTime();
if (!isNaN(untilMs) && new Date(entry.ts).getTime() > untilMs) return false;
}
return true;
}
function _prependLiveEntry(entry: ActivityEntry): void {
if (!_entryPassesFilters(entry)) return;
_entries = [entry, ..._entries];
_total = _total + 1;
// Prepend the row into the existing list (no full re-render for performance)
const list = document.getElementById('tab-activity_log')?.querySelector('.al-list');
if (list) {
const html = _renderEntryRow(entry, true);
list.insertAdjacentHTML('afterbegin', html);
// Animate the new entry
const firstRow = list.firstElementChild as HTMLElement | null;
if (firstRow) {
requestAnimationFrame(() => { firstRow.classList.add('al-entry-appear'); });
}
// Update count badge
const countEl = list.closest('.al-panel')?.querySelector('.al-count');
if (countEl) countEl.textContent = t('activity_log.n_entries', { n: _total });
} else {
_updateListContainer();
}
}
function _startLiveUpdates(): void {
if (_liveEventListener) return;
_liveEventListener = (e: Event) => {
const ce = e as CustomEvent;
const entry = ce.detail?.entry as ActivityEntry | undefined;
if (!entry) return;
_prependLiveEntry(entry);
};
document.addEventListener('server:activity_logged', _liveEventListener);
}
// ─── Public window-exposed interaction functions ──────────────
export function activityLogToggleDetail(entryId: string): void {
if (_expandedIds.has(entryId)) {
_expandedIds.delete(entryId);
} else {
_expandedIds.add(entryId);
}
// Update just the affected row
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
const row = panel.querySelector(`[data-al-id="${CSS.escape(entryId)}"]`);
if (!row) return;
const entry = _entries.find(e => e.id === entryId);
if (!entry) return;
row.outerHTML = _renderEntryRow(entry, false);
}
export function activityLogToggleCat(cat: string): void {
const idx = _filters.categories.indexOf(cat);
if (idx >= 0) {
_filters.categories = _filters.categories.filter(c => c !== cat);
} else {
_filters.categories = [..._filters.categories, cat];
}
_render();
_requery();
}
export function activityLogToggleSev(sev: string): void {
const idx = _filters.severities.indexOf(sev);
if (idx >= 0) {
_filters.severities = _filters.severities.filter(s => s !== sev);
} else {
_filters.severities = [..._filters.severities, sev];
}
_render();
_requery();
}
export function activityLogOnSearch(val: string): void {
_filters.q = val;
_requery(true);
}
export function activityLogOnActor(val: string): void {
_filters.actor = val.trim();
_requery(true);
}
export function activityLogOnEntityType(val: string): void {
_filters.entity_type = val.trim();
_requery(true);
}
export function activityLogOnSince(val: string): void {
_filters.since = val;
_requery();
}
export function activityLogOnUntil(val: string): void {
_filters.until = val;
_requery();
}
export function activityLogClearFilters(): void {
_filters.categories = [];
_filters.severities = [];
_filters.actor = '';
_filters.entity_type = '';
_filters.since = '';
_filters.until = '';
_filters.q = '';
_render();
_requery();
}
export function activityLogPreset(key: string): void {
// Reset all filters first
_filters.categories = [];
_filters.severities = [];
_filters.actor = '';
_filters.entity_type = '';
_filters.q = '';
_filters.until = '';
switch (key) {
case 'today': {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
// datetime-local format: YYYY-MM-DDTHH:MM
_filters.since = todayStart.toISOString().slice(0, 16);
break;
}
case 'errors':
_filters.severities = ['error'];
_filters.since = '';
break;
case 'auth':
_filters.categories = ['auth'];
_filters.since = '';
break;
case 'devices':
_filters.categories = ['device'];
_filters.since = '';
break;
}
_render();
_requery();
}
export function activityLogLoadMore(): void {
if (_hasMore && !_loading) {
_fetchPage(_nextBeforeSeq, true);
}
}
export async function activityLogExport(format: 'csv' | 'json'): Promise<void> {
try {
showToast(t('activity_log.export.downloading'), 'info');
const qs = _buildQuery(null);
const sep = qs ? '&' : '?';
const url = `/activity-log/export${qs}${sep}format=${format}`;
const res = await fetchWithAuth(url);
if (!res || !res.ok) throw new Error(`HTTP ${res?.status}`);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `ledgrab-activity-${now}.${format}`;
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
} catch (e: unknown) {
if (e && typeof e === 'object' && 'isAuth' in e) return;
showToast(t('activity_log.export.error'), 'error');
}
}
export function activityLogNavigateToEntity(entityType: string, entityId: string): void {
const nav = _ENTITY_NAV[entityType];
if (!nav || !entityId) return;
navigateToCard(nav.tab, nav.subTab, null, nav.attr, entityId);
}
// ─── Public helpers for Phase 6 (Dashboard widget + Settings export) ────────
/**
* Fetch the N most-recent activity log entries without affecting the full-tab
* state (separate request, no state mutations).
*/
export async function fetchRecentEntries(limit = 5): Promise<ActivityEntry[]> {
try {
const res = await fetchWithAuth(`/activity-log?limit=${limit}`);
if (!res || !res.ok) return [];
const page: ActivityPage = await res.json();
// API returns oldest-first within page; reverse for newest-first.
return [...page.entries].reverse();
} catch {
return [];
}
}
/**
* Render a compact single-line entry row for the Dashboard widget.
* Re-uses the severity icon / class helpers and escapeHtml from the full viewer
* so the visual language is consistent.
*/
export function renderCompactEntry(entry: ActivityEntry): string {
const relTime = formatRelativeTime(entry.ts);
const sevIcon = _severityIcon(entry.severity);
const sevClass = _severityClass(entry.severity);
return `<div class="al-compact-row al-sev ${sevClass}" title="${_escapeAttr(entry.ts)}">
<span class="al-compact-icon" aria-label="${_escapeAttr(t(`activity_log.severity.${entry.severity}`))}">${sevIcon}</span>
<span class="al-compact-time tabular-nums" data-reltime="${_escapeAttr(entry.ts)}">${escapeHtml(relTime)}</span>
<span class="al-compact-msg">${escapeHtml(localizeMessage(entry))}</span>
</div>`;
}
// ─── Main loader (registered with tab-registry) ─────────────
export async function loadActivityLog(): Promise<void> {
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
_initialized = true;
_render();
await _fetchPage(null, false);
_startLiveUpdates();
ensureRelativeTimeTicker();
// Re-render on language change (baked-in t() calls)
document.addEventListener('languageChanged', _onLanguageChanged);
}
function _onLanguageChanged(): void {
if (!_initialized) return;
_render();
_fetchPage(null, false);
}
@@ -0,0 +1,840 @@
/**
* Auto-Calibration flow guided LED-chase corner-tap wizard.
*
* Exports `mountAutoCalibration` / `unmountAutoCalibration` so Phase 4's
* wizard can embed this as a step without modification.
*
* Flow:
* 1. Device selection (EntitySelect; skipped when deviceId supplied)
* 2. Start corner light index 0; user taps which corner is lit start_position
* 3. Direction advance a few indices; user identifies direction layout
* 4. Tap-to-mark-corners dot sweeps; user taps NEXT at each physical corner
* (first tap = corner at index 0, per Phase 1 solver contract)
* 5. Preview & Save POST /calibration/solve summary PUT CSS hot-reload
*
* Session contract (Phase 1 handoff):
* POST /api/v1/calibration/session start (stops running target)
* POST /api/v1/calibration/session/position advance chase pixel
* POST /api/v1/calibration/session/stop ALWAYS call on exit / error
* POST /api/v1/calibration/solve pure solver (no persist)
* PUT /api/v1/color-strip-sources/{id} persist + hot-reload
*
* CRITICAL: the first corner tap corresponds to LED index 0 so the solver's
* `corner_indices[0] == 0` matches `solve_calibration`'s assumption that the
* start corner is at strip index 0 (Phase 1 review finding).
*/
import { apiPost, apiPut } from '../core/api-client.ts';
import { colorStripSourcesCache, devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { renderDeviceIcon } from '../core/device-icons.ts';
import {
ICON_DEVICE, ICON_ROTATE_CW, ICON_ROTATE_CCW,
ICON_CALIBRATION, ICON_OK,
} from '../core/icons.ts';
// ── Types ─────────────────────────────────────────────────────────────────────
type StartPosition = 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right';
type Layout = 'clockwise' | 'counterclockwise';
type AutoCalStep = 'device' | 'corner' | 'direction' | 'corners' | 'preview';
interface CalibrationSessionState {
active: boolean;
device_id: string | null;
led_count: number;
prior_target_id: string | null;
last_activity: string | null;
}
interface SolvedCalibration {
mode: 'simple';
layout: string;
start_position: string;
leds_top: number;
leds_right: number;
leds_bottom: number;
leds_left: number;
offset: number;
}
interface AutoCalState {
step: AutoCalStep;
cssId: string;
cssSourceType: string;
deviceId: string;
ledCount: number;
startPosition: StartPosition | null;
layout: Layout | null;
/** Strip indices of the 4 physical corners, in strip-walk order.
* cornerIndices[0] is ALWAYS 0 (start corner = LED index 0). */
cornerIndices: number[];
currentIndex: number;
sessionActive: boolean;
busy: boolean;
solved: SolvedCalibration | null;
errorMsg: string;
}
/** Options for `mountAutoCalibration()`. */
export interface AutoCalOptions {
/** DOM container element to render wizard steps into. */
container: HTMLElement;
/** Color-strip source ID being calibrated. */
cssId: string;
/** Pre-selected device ID; if supplied the device-picker step is skipped. */
deviceId?: string;
/** Called after successful save. */
onComplete?: () => void;
/** Called after user cancels (session already stopped before this fires). */
onCancel?: () => void;
}
// ── Module-level singleton ─────────────────────────────────────────────────
let _state: AutoCalState | null = null;
let _opts: AutoCalOptions | null = null;
let _deviceEntitySelect: EntitySelect | null = null;
/** First render after mount: let the containing Modal's autofocus win; only
* steal focus on subsequent step transitions (for D-pad / TV navigation). */
let _firstRender = true;
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Mount the auto-calibration flow into the given container.
*
* Phase 4 usage:
* ```ts
* await mountAutoCalibration({
* container: document.getElementById('wizard-body')!,
* cssId: sourceId,
* deviceId: inferredDeviceId, // optional
* onComplete: () => wizard.next(),
* onCancel: () => wizard.close(),
* });
* ```
* Call `unmountAutoCalibration()` when the containing modal closes to guarantee
* the calibration session is stopped.
*/
export async function mountAutoCalibration(opts: AutoCalOptions): Promise<void> {
await unmountAutoCalibration();
_firstRender = true;
_opts = opts;
let cssSourceType = 'picture';
try {
const sources = await colorStripSourcesCache.fetch() as { id: string; source_type?: string }[];
const src = sources.find(s => s.id === opts.cssId);
if (src) cssSourceType = src.source_type || 'picture';
} catch { /* fallback */ }
_state = {
step: opts.deviceId ? 'corner' : 'device',
cssId: opts.cssId,
cssSourceType,
deviceId: opts.deviceId || '',
ledCount: 0,
startPosition: null,
layout: null,
cornerIndices: [],
currentIndex: 0,
sessionActive: false,
busy: false,
solved: null,
errorMsg: '',
};
_render();
if (opts.deviceId) {
_state.deviceId = opts.deviceId;
await _startSession();
}
}
/**
* Unmount: stop any active session, destroy widgets, clear container.
* Safe to call when nothing is mounted.
*/
export async function unmountAutoCalibration(): Promise<void> {
if (_deviceEntitySelect) { _deviceEntitySelect.destroy(); _deviceEntitySelect = null; }
if (_state?.sessionActive) {
await _stopSession().catch(() => { /* best effort */ });
}
if (_opts?.container) _opts.container.innerHTML = '';
_state = null;
_opts = null;
}
// ── Internal render ────────────────────────────────────────────────────────
function _render(): void {
if (!_opts || !_state) return;
switch (_state.step) {
case 'device': _renderDevice(); break;
case 'corner': _renderCorner(); break;
case 'direction': _renderDirection(); break;
case 'corners': _renderCorners(); break;
case 'preview': _renderPreview(); break;
}
_focusFirstControl();
}
/**
* Move focus to the step's first focusable control after a re-render so D-pad /
* TV WebView users don't land on a node that was just removed. Skipped on the
* very first render so it doesn't fight the Modal's own initial autofocus.
*/
function _focusFirstControl(): void {
if (_firstRender) {
_firstRender = false;
return;
}
const container = _opts?.container;
if (!container) return;
requestAnimationFrame(() => {
const el = container.querySelector(
'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])'
) as HTMLElement | null;
if (el && el.offsetParent !== null) el.focus();
});
}
// ── Step 1: Device picker ──────────────────────────────────────────────────
function _renderDevice(): void {
if (!_opts) return;
_opts.container.innerHTML = `
<div class="autocal-step" data-step="device">
<div class="autocal-step-header">
<span class="autocal-step-icon">${ICON_DEVICE}</span>
<div>
<div class="autocal-step-title">${_esc(t('autocal.device.title'))}</div>
<div class="autocal-step-desc">${_esc(t('autocal.device.desc'))}</div>
</div>
</div>
<div class="form-group" style="margin-top:16px;">
<label for="autocal-device-select">${_esc(t('autocal.device.label'))}</label>
<select id="autocal-device-select"></select>
</div>
<div id="autocal-error" class="autocal-error" style="display:none"></div>
<div class="autocal-footer">
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
<button class="btn btn-primary" onclick="autoCalSelectDevice()">${_esc(t('autocal.btn.next'))}</button>
</div>
</div>`;
_populateDeviceSelect();
_showError(_state?.errorMsg || '');
}
async function _populateDeviceSelect(): Promise<void> {
const sel = document.getElementById('autocal-device-select') as HTMLSelectElement | null;
if (!sel) return;
let devices: { id: string; name: string; led_count: number; icon?: string }[] = [];
try { devices = await devicesCache.fetch() as typeof devices; } catch { /* empty */ }
sel.innerHTML = '';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = d.name;
sel.appendChild(opt);
});
if (_deviceEntitySelect) { _deviceEntitySelect.destroy(); _deviceEntitySelect = null; }
if (devices.length > 0) {
_deviceEntitySelect = new EntitySelect({
target: sel,
getItems: () => devices.map(d => ({
value: d.id,
label: d.name,
icon: renderDeviceIcon(d.icon) || ICON_DEVICE,
desc: d.led_count ? `${d.led_count} LEDs` : '',
})),
placeholder: t('palette.search'),
} as ConstructorParameters<typeof EntitySelect>[0]);
}
// Auto-select LED-count-matched device
if (devices.length > 0 && _state) {
try {
const sources = await colorStripSourcesCache.fetch() as { id: string; led_count?: number }[];
const src = sources.find(s => s.id === _state!.cssId);
if (src?.led_count) {
const match = devices.find(d => d.led_count === src.led_count);
if (match) {
sel.value = match.id;
if (_deviceEntitySelect) _deviceEntitySelect.refresh();
}
}
} catch { /* fallback */ }
}
}
export async function autoCalSelectDevice(): Promise<void> {
if (!_state || _state.busy) return;
const sel = document.getElementById('autocal-device-select') as HTMLSelectElement | null;
if (!sel?.value) { _setError(t('autocal.error.no_device')); return; }
_state.deviceId = sel.value;
_state.step = 'corner';
_render();
await _startSession();
}
// ── Step 2: Start corner ──────────────────────────────────────────────────
function _renderCorner(): void {
if (!_opts) return;
const busy = _state?.busy ?? false;
const s = _state!;
_opts.container.innerHTML = `
<div class="autocal-step" data-step="corner">
<div class="autocal-step-header">
<span class="autocal-step-icon">${ICON_CALIBRATION}</span>
<div>
<div class="autocal-step-title">${_esc(t('autocal.corner.title'))}</div>
<div class="autocal-step-desc">${_esc(t('autocal.corner.desc'))}</div>
</div>
</div>
<div class="autocal-led-indicator">
<span class="autocal-led-dot ${busy ? '' : 'autocal-led-dot--active'}" aria-hidden="true"></span>
<span class="autocal-led-index">${_esc(t('autocal.corner.led_index', { index: '0' }))}</span>
</div>
<div class="autocal-corner-grid" ${busy ? 'aria-busy="true"' : ''}>
${(['top_left', 'top_right', 'bottom_left', 'bottom_right'] as StartPosition[]).map(pos =>
`<button class="autocal-corner-btn autocal-corner-btn--${pos.replace('_', '-')}"
onclick="autoCalSetCorner('${pos}')"
${busy ? 'disabled' : ''}
aria-label="${_esc(t(`autocal.position.${pos}`))}">
<span class="autocal-corner-glyph" aria-hidden="true"></span>
<span>${_esc(t(`autocal.position.${pos}`))}</span>
</button>`
).join('')}
</div>
<div id="autocal-error" class="autocal-error" style="display:none"></div>
<div class="autocal-footer">
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
</div>
</div>`;
_showError(s.errorMsg);
}
export async function autoCalSetCorner(position: StartPosition): Promise<void> {
if (!_state || _state.busy) return;
_state.startPosition = position;
_state.step = 'direction';
_state.busy = true;
_render();
try {
// LED is at index 0; advance to ~5% to show movement direction
await _setPosition(0);
await _delay(350);
// User may have cancelled during the delay (unmount sets _state = null).
if (!_state) return;
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
await _setPosition(advance);
if (!_state) return;
_state.busy = false;
} catch (err: unknown) {
// _state may have been torn down mid-await by a cancel.
if (!_state) return;
_state.busy = false;
_state.errorMsg = _errMsg(err);
_state.step = 'corner'; // revert on error
}
_render();
}
// ── Step 3: Direction ─────────────────────────────────────────────────────
function _renderDirection(): void {
if (!_opts || !_state) return;
const busy = _state.busy;
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
_opts.container.innerHTML = `
<div class="autocal-step" data-step="direction">
<div class="autocal-step-header">
<span class="autocal-step-icon">${ICON_ROTATE_CW}</span>
<div>
<div class="autocal-step-title">${_esc(t('autocal.direction.title', { step: String(advance) }))}</div>
<div class="autocal-step-desc">${_esc(t('autocal.direction.desc'))}</div>
</div>
</div>
<div class="autocal-direction-grid">
<button class="autocal-direction-btn" onclick="autoCalSetDirection('clockwise')" ${busy ? 'disabled' : ''}>
${ICON_ROTATE_CW}
<span>${_esc(t('calibration.direction.clockwise'))}</span>
</button>
<button class="autocal-direction-btn" onclick="autoCalSetDirection('counterclockwise')" ${busy ? 'disabled' : ''}>
${ICON_ROTATE_CCW}
<span>${_esc(t('calibration.direction.counterclockwise'))}</span>
</button>
</div>
<div id="autocal-error" class="autocal-error" style="display:none"></div>
<div class="autocal-footer">
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
<button class="btn btn-ghost" onclick="autoCalBackToCorner()">${_esc(t('autocal.btn.back'))}</button>
</div>
</div>`;
_showError(_state.errorMsg);
}
export async function autoCalSetDirection(layout: Layout): Promise<void> {
if (!_state || _state.busy) return;
_state.layout = layout;
// corner_indices[0] MUST be 0 (Phase 1 solver contract: start corner = index 0)
_state.cornerIndices = [0];
_state.currentIndex = 0;
_state.step = 'corners';
_render();
await _setPosition(0).catch(() => { /* best effort */ });
}
export async function autoCalBackToCorner(): Promise<void> {
if (!_state || _state.busy) return;
_state.step = 'corner';
_state.startPosition = null;
_state.errorMsg = '';
_render();
await _setPosition(0).catch(() => { /* best effort */ });
}
// ── Step 4: Tap-to-mark corners ───────────────────────────────────────────
function _renderCorners(): void {
if (!_opts || !_state) return;
const { cornerIndices, currentIndex, ledCount, busy } = _state;
const collected = cornerIndices.length; // starts at 1 (index 0 already in)
const isComplete = collected >= 4;
const cornerLabels = _cornerLabels(_state.startPosition!, _state.layout!);
const pips = [0, 1, 2, 3].map(i => {
const done = i < collected;
const active = i === collected - 1;
return `<span class="autocal-pip ${done ? 'autocal-pip--done' : ''} ${active ? 'autocal-pip--active' : ''}"
aria-label="${cornerLabels[i]}">${i + 1}</span>`;
}).join('');
const activeCornerLabel = isComplete ? '' : cornerLabels[collected - 1];
_opts.container.innerHTML = `
<div class="autocal-step" data-step="corners">
<div class="autocal-step-header">
<span class="autocal-step-icon">${ICON_CALIBRATION}</span>
<div>
<div class="autocal-step-title">${_esc(isComplete ? t('autocal.corners.title', { remaining: '0' }) : t('autocal.corners.title', { remaining: String(4 - collected) }))}</div>
<div class="autocal-step-desc">${_esc(
isComplete
? t('autocal.corners.desc_complete')
: t('autocal.corners.desc', { corner: activeCornerLabel })
)}</div>
</div>
</div>
<div class="autocal-corners-progress">
<div class="autocal-pips">${pips}</div>
<div class="autocal-index-badge">
<span class="autocal-index-label">${_esc(t('autocal.corners.index_label'))}</span>
<span class="autocal-index-value">${currentIndex}</span>
<span class="autocal-index-total">/ ${ledCount - 1}</span>
</div>
</div>
<div class="autocal-sweep-row">
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepBack()" ${busy || isComplete || currentIndex <= 0 ? 'disabled' : ''}
aria-label="${_esc(t('autocal.btn.step_back'))}">&#8592;</button>
<div class="autocal-led-track">
<div class="autocal-led-track-fill" style="width:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
<div class="autocal-led-cursor" style="left:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
</div>
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepForward()" ${busy || isComplete || currentIndex >= ledCount - 1 ? 'disabled' : ''}
aria-label="${_esc(t('autocal.btn.step_fwd'))}">&#8594;</button>
</div>
${isComplete ? '' : `
<button class="btn btn-primary autocal-mark-btn" onclick="autoCalMarkCorner()" ${busy ? 'disabled' : ''}>
${_esc(t('autocal.btn.mark_corner', { n: String(collected), label: activeCornerLabel }))}
</button>`}
<div id="autocal-error" class="autocal-error" style="display:none"></div>
<div class="autocal-footer">
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
<button class="btn btn-ghost" onclick="autoCalBackToDirection()">${_esc(t('autocal.btn.back'))}</button>
${isComplete ? `<button class="btn btn-primary" onclick="autoCalSolve()">${_esc(t('autocal.btn.solve'))}</button>` : ''}
</div>
</div>`;
_showError(_state.errorMsg);
}
function _cornerLabels(startPos: StartPosition, layout: Layout): string[] {
const all: StartPosition[] = ['top_left', 'top_right', 'bottom_right', 'bottom_left'];
const si = all.indexOf(startPos);
let ordered: StartPosition[];
if (layout === 'clockwise') {
ordered = [all[si % 4], all[(si + 1) % 4], all[(si + 2) % 4], all[(si + 3) % 4]];
} else {
ordered = [all[si % 4], all[(si + 3) % 4], all[(si + 2) % 4], all[(si + 1) % 4]];
}
return ordered.map(c => t(`autocal.position.${c}`));
}
export async function autoCalSweepForward(): Promise<void> {
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
const next = _state.currentIndex + 1;
if (next >= _state.ledCount) return;
_state.busy = true;
try {
await _setPosition(next);
_state.currentIndex = next;
_state.errorMsg = '';
} catch (err: unknown) {
_state.errorMsg = _errMsg(err);
} finally {
_state.busy = false;
_render();
}
}
export async function autoCalSweepBack(): Promise<void> {
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
const prev = _state.currentIndex - 1;
// Clamp to one past the last marked corner index to preserve monotonic ordering.
const lastMarked = _state.cornerIndices.length > 0
? _state.cornerIndices[_state.cornerIndices.length - 1]
: -1;
if (prev < 0 || prev <= lastMarked) return;
_state.busy = true;
try {
await _setPosition(prev);
_state.currentIndex = prev;
_state.errorMsg = '';
} catch (err: unknown) {
_state.errorMsg = _errMsg(err);
} finally {
_state.busy = false;
_render();
}
}
export async function autoCalMarkCorner(): Promise<void> {
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
_state.cornerIndices.push(_state.currentIndex);
if (_state.cornerIndices.length < 4) {
// Nudge forward so user can see the dot isn't stuck
const next = Math.min(_state.currentIndex + 1, _state.ledCount - 1);
_state.busy = true;
try {
await _setPosition(next);
_state.currentIndex = next;
} catch { /* best effort */ } finally {
_state.busy = false;
}
}
_render();
}
export async function autoCalBackToDirection(): Promise<void> {
if (!_state || _state.busy) return;
_state.step = 'direction';
_state.layout = null;
_state.cornerIndices = [];
_state.currentIndex = 0;
_state.errorMsg = '';
_render();
await _setPosition(0).catch(() => { /* best effort */ });
}
export async function autoCalSolve(): Promise<void> {
if (!_state || _state.busy || _state.cornerIndices.length !== 4) return;
_state.busy = true;
_state.errorMsg = '';
_render();
try {
const solved = await apiPost<SolvedCalibration>('/calibration/solve', {
device_id: _state.deviceId,
start_position: _state.startPosition,
layout: _state.layout,
corner_indices: _state.cornerIndices,
offset: 0,
}, { errorMessage: t('autocal.error.solve_failed') });
_state.solved = solved;
// Stop the chase session — device restored to prior target
await _stopSession();
_state.step = 'preview';
} catch (err: unknown) {
_state.errorMsg = _errMsg(err);
_state.busy = false;
_render();
return;
}
_state.busy = false;
_render();
}
// ── Step 5: Preview & Save ────────────────────────────────────────────────
function _renderPreview(): void {
if (!_opts || !_state?.solved) return;
const s = _state.solved;
const busy = _state.busy;
const dirLabel = s.layout === 'clockwise'
? t('calibration.direction.clockwise')
: t('calibration.direction.counterclockwise');
const dirIcon = s.layout === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW;
_opts.container.innerHTML = `
<div class="autocal-step" data-step="preview">
<div class="autocal-step-header">
<span class="autocal-step-icon autocal-step-icon--ok">${ICON_OK}</span>
<div>
<div class="autocal-step-title">${_esc(t('autocal.preview.title'))}</div>
<div class="autocal-step-desc">${_esc(t('autocal.preview.desc'))}</div>
</div>
</div>
<div class="autocal-solved-grid">
<div class="autocal-solved-item autocal-solved-item--wide">
<span class="autocal-solved-key">${_esc(t('autocal.preview.start'))}</span>
<span class="autocal-solved-val">${_esc(t(`autocal.position.${s.start_position}`))}</span>
</div>
<div class="autocal-solved-item autocal-solved-item--wide">
<span class="autocal-solved-key">${_esc(t('calibration.direction'))}</span>
<span class="autocal-solved-val">${dirIcon} ${_esc(dirLabel)}</span>
</div>
<div class="autocal-solved-item">
<span class="autocal-solved-key">${_esc(t('autocal.preview.top'))}</span>
<span class="autocal-solved-val autocal-led-count">${s.leds_top}</span>
</div>
<div class="autocal-solved-item">
<span class="autocal-solved-key">${_esc(t('autocal.preview.right'))}</span>
<span class="autocal-solved-val autocal-led-count">${s.leds_right}</span>
</div>
<div class="autocal-solved-item">
<span class="autocal-solved-key">${_esc(t('autocal.preview.bottom'))}</span>
<span class="autocal-solved-val autocal-led-count">${s.leds_bottom}</span>
</div>
<div class="autocal-solved-item">
<span class="autocal-solved-key">${_esc(t('autocal.preview.left'))}</span>
<span class="autocal-solved-val autocal-led-count">${s.leds_left}</span>
</div>
<div class="autocal-solved-item autocal-solved-item--wide">
<span class="autocal-solved-key">${_esc(t('autocal.preview.total'))}</span>
<span class="autocal-solved-val autocal-led-count">${s.leds_top + s.leds_right + s.leds_bottom + s.leds_left}</span>
</div>
</div>
<div id="autocal-error" class="autocal-error" style="display:none"></div>
<div class="autocal-footer">
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
<button class="btn btn-primary" id="autocal-save-btn" onclick="autoCalSave()" ${busy ? 'disabled' : ''}>
${_esc(t('autocal.btn.save'))}
</button>
</div>
</div>`;
_showError(_state.errorMsg);
}
export async function autoCalSave(): Promise<void> {
if (!_state || _state.busy || !_state.solved) return;
_state.busy = true;
_state.errorMsg = '';
const btn = document.getElementById('autocal-save-btn');
if (btn) btn.setAttribute('disabled', 'true');
try {
const s = _state.solved;
await apiPut(`/color-strip-sources/${_state.cssId}`, {
source_type: _state.cssSourceType,
calibration: {
mode: 'simple',
layout: s.layout,
start_position: s.start_position,
leds_top: s.leds_top,
leds_right: s.leds_right,
leds_bottom: s.leds_bottom,
leds_left: s.leds_left,
offset: s.offset,
span_top_start: 0, span_top_end: 1,
span_right_start: 0, span_right_end: 1,
span_bottom_start: 0, span_bottom_end: 1,
span_left_start: 0, span_left_end: 1,
skip_leds_start: 0,
skip_leds_end: 0,
border_width: 10,
roi_x: 0, roi_y: 0, roi_width: 1, roi_height: 1,
},
}, { errorMessage: t('autocal.error.save_failed') });
colorStripSourcesCache.invalidate();
showToast(t('autocal.saved'), 'success');
const onComplete = _opts?.onComplete;
await unmountAutoCalibration();
if (onComplete) onComplete();
} catch (err: unknown) {
_state.busy = false;
_state.errorMsg = _errMsg(err);
if (btn) btn.removeAttribute('disabled');
_showError(_state.errorMsg);
}
}
// ── Cancel ────────────────────────────────────────────────────────────────
export async function autoCalCancel(): Promise<void> {
const onCancel = _opts?.onCancel;
await unmountAutoCalibration();
if (onCancel) onCancel();
}
// ── Session lifecycle ─────────────────────────────────────────────────────
async function _startSession(): Promise<void> {
if (!_state) return;
_state.busy = true;
_render();
try {
const state = await apiPost<CalibrationSessionState>('/calibration/session', {
device_id: _state.deviceId,
}, { errorMessage: t('autocal.error.session_start_failed') });
_state.sessionActive = true;
_state.ledCount = state.led_count;
_state.busy = false;
await _setPosition(0);
_state.errorMsg = '';
_render();
} catch (err: unknown) {
// Session may already be live (POST /calibration/session succeeded before _setPosition threw),
// so call _stopSession() to let the backend tear down cleanly instead of flipping the flag directly.
await _stopSession().catch(() => { /* best effort */ });
_state.busy = false;
_state.errorMsg = _errMsg(err);
_render();
}
}
async function _stopSession(): Promise<void> {
if (!_state?.sessionActive) return;
try {
await apiPost<CalibrationSessionState>('/calibration/session/stop', undefined, {
errorMessage: t('autocal.error.session_stop_failed'),
});
} finally {
if (_state) _state.sessionActive = false;
}
}
async function _setPosition(index: number): Promise<void> {
if (!_state?.sessionActive) return;
await apiPost<CalibrationSessionState>('/calibration/session/position', {
index,
window: 1,
}, { errorMessage: t('autocal.error.position_failed') });
}
// ── Utilities ─────────────────────────────────────────────────────────────
function _delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function _errMsg(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
function _esc(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function _showError(msg: string): void {
const el = document.getElementById('autocal-error');
if (!el) return;
el.textContent = msg;
el.style.display = msg ? 'block' : 'none';
}
function _setError(msg: string): void {
if (_state) _state.errorMsg = msg;
_showError(msg);
}
// ── Standalone modal management ───────────────────────────────────────────
//
// The standalone modal is the Phase 3 surface: opened from the calibration
// modal's "Auto-calibrate" button. Phase 4 wizard uses mountAutoCalibration()
// directly (no modal wrapper needed — the wizard is itself a modal).
class AutoCalModal extends Modal {
constructor() { super('auto-calibration-modal'); }
snapshotValues(): Record<string, string> {
// No dirty-check needed for a wizard flow; always allow close.
return {};
}
onForceClose(): void {
// Unmount the flow asynchronously (session stop is async)
unmountAutoCalibration().catch(() => { /* best effort */ });
}
}
const _autoCalModal = new AutoCalModal();
/**
* Open the auto-calibration wizard for a color-strip source.
*
* Called from calibration.ts "Auto-calibrate" button.
*
* @param cssId The color-strip source ID to calibrate.
* @param deviceId Optional pre-selected device; if omitted, the device picker
* step is shown.
*/
export async function showAutoCalibration(cssId: string, deviceId?: string): Promise<void> {
const container = document.getElementById('autocal-step-container');
if (!container) return;
// Store context on the hidden inputs for reference
const cssIdInput = document.getElementById('autocal-modal-css-id') as HTMLInputElement | null;
const deviceIdInput = document.getElementById('autocal-modal-device-id') as HTMLInputElement | null;
if (cssIdInput) cssIdInput.value = cssId;
if (deviceIdInput) deviceIdInput.value = deviceId || '';
_autoCalModal.open();
_autoCalModal.snapshot();
await mountAutoCalibration({
container,
cssId,
deviceId,
onComplete: () => {
_autoCalModal.forceClose();
// Reload calibration view if open
if (window.loadTargetsTab) window.loadTargetsTab();
},
onCancel: () => {
_autoCalModal.forceClose();
},
});
}
/** Close the auto-calibration modal (stops session). */
export async function closeAutoCalModal(): Promise<void> {
await _autoCalModal.close();
}
@@ -4,7 +4,7 @@
import {
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
scenePresetsCache, _cachedHASources, haSourcesCache,
scenePresetsCache, scenePlaylistsCache, _cachedHASources, haSourcesCache,
_cachedValueSources, valueSourcesCache,
getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts';
@@ -18,7 +18,7 @@ import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts';
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE, ICON_LIST_CHECKS } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
@@ -32,6 +32,7 @@ import { enhanceMiniSelects } from '../core/mini-select.ts';
import { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import { csPlaylists, createPlaylistCard, initPlaylistDelegation } from './scene-playlists.ts';
import type { Automation, RuleType } from '../types.ts';
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
@@ -252,6 +253,7 @@ export async function loadAutomations() {
const [automations, scenes] = await Promise.all([
automationsCacheObj.fetch(),
scenePresetsCache.fetch(),
scenePlaylistsCache.fetch(),
haSourcesCache.fetch(),
valueSourcesCache.fetch(),
]);
@@ -291,38 +293,45 @@ function renderAutomations(automations: any, sceneMap: any) {
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
const playlistItems = csPlaylists.applySortOrder(scenePlaylistsCache.data.map(p => ({ key: p.id, html: createPlaylistCard(p) })));
const activeTab = getActiveSubTab('automations')!;
const treeItems = [
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
{ key: 'playlists', icon: ICON_LIST_CHECKS, titleKey: 'playlists.title', count: scenePlaylistsCache.data.length },
];
if (csAutomations.isMounted()) {
_automationsTree.updateCounts({
automations: automations.length,
scenes: scenePresetsCache.data.length,
playlists: scenePlaylistsCache.data.length,
});
csAutomations.reconcile(autoItems);
csScenes.reconcile(sceneItems);
csPlaylists.reconcile(playlistItems);
} else {
const panels = [
{ key: 'automations', html: csAutomations.render(autoItems) },
{ key: 'scenes', html: csScenes.render(sceneItems) },
{ key: 'playlists', html: csPlaylists.render(playlistItems) },
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
container!.innerHTML = panels;
CardSection.bindAll([csAutomations, csScenes]);
CardSection.bindAll([csAutomations, csScenes, csPlaylists]);
// Event delegation for scene preset card actions
// Event delegation for scene preset + playlist card actions
initScenePresetDelegation(container!);
initPlaylistDelegation(container!);
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_automationsTree.update(treeItems, activeTab);
_automationsTree.observeSections('automations-content', {
'automations': 'automations',
'scenes': 'scenes',
'playlists': 'playlists',
});
}
}
@@ -340,11 +349,15 @@ const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
},
time_of_day: (c) => ({
icon: ICON_CLOCK,
text: `${c.start_time || '00:00'} ${c.end_time || '23:59'}`,
title: t('automations.rule.time_of_day'),
}),
time_of_day: (c) => {
const days: number[] = Array.isArray(c.days_of_week) ? c.days_of_week : [];
let text = `${c.start_time || '00:00'} ${c.end_time || '23:59'}`;
if (days.length && days.length < 7) {
text += ` · ${[...days].sort((a, b) => a - b).map((d) => t('weekday.short.' + d)).join(' ')}`;
}
if (c.timezone) text += ` · ${c.timezone}`;
return { icon: ICON_CLOCK, text, title: t('automations.rule.time_of_day') };
},
system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
@@ -878,6 +891,11 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number);
const pad = (n: number) => String(n).padStart(2, '0');
const days: number[] = Array.isArray(data.days_of_week) ? data.days_of_week : [];
const tz: string = data.timezone || '';
const dayChips = [0, 1, 2, 3, 4, 5, 6]
.map((d) => `<button type="button" class="weekday-chip${days.includes(d) ? ' active' : ''}" data-day="${d}">${t('weekday.short.' + d)}</button>`)
.join('');
container.innerHTML = `
<div class="rule-fields">
<input type="hidden" class="rule-start-time" value="${startTime}">
@@ -901,9 +919,21 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
</div>
</div>
</div>
<div class="rule-weekday-block">
<span class="rule-field-label">${t('automations.rule.time_of_day.days')}</span>
<div class="weekday-chips">${dayChips}</div>
<small class="rule-hint-desc">${t('automations.rule.time_of_day.days_hint')}</small>
</div>
<div class="rule-tz-block">
<label class="rule-field-label">${t('automations.rule.time_of_day.timezone')}</label>
<input type="text" class="rule-timezone" placeholder="${t('automations.rule.time_of_day.timezone.placeholder')}" value="${tz}">
</div>
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
</div>`;
_wireTimeRangePicker(container);
container.querySelectorAll('.weekday-chip').forEach((chip) => {
chip.addEventListener('click', () => chip.classList.toggle('active'));
});
}
function _renderSystemIdleFields(container: HTMLElement, data: any): void {
@@ -1314,6 +1344,9 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
rule_type: 'time_of_day',
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
days_of_week: Array.from(row.querySelectorAll('.weekday-chip.active'))
.map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)),
timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(),
}),
system_idle: (row) => ({
rule_type: 'system_idle',

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