Compare commits

..

22 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adds 14 tests. Gate: ruff + tsc 5.9.3 + esbuild + pytest (2185 passed) +
compileDebugKotlin all green.
2026-06-09 16:35:08 +03:00
183 changed files with 29184 additions and 9825 deletions
+52
View File
@@ -354,6 +354,58 @@ jobs:
docker push "$REGISTRY:latest"
fi
# Best-effort arm64 (Raspberry Pi / arm64 HAOS hosts). Runs AFTER the
# amd64 push so the amd64 image always ships even if this fails.
# Deliberately avoids `docker buildx` (its docker-container driver needs
# nested networking the TrueNAS runners lack — see contexts/ci-cd.md):
# instead it cross-builds a single arm64 image via QEMU binfmt and folds
# amd64 + arm64 into multi-arch manifest lists under the existing tags.
# `continue-on-error` keeps a runner that can't emulate arm64 from
# failing the release; the plain amd64 tags pushed above remain valid.
- name: Build + publish arm64 (multi-arch manifest, best-effort)
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
continue-on-error: true
run: |
set -e
export DOCKER_CLI_EXPERIMENTAL=enabled
TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}"
VERSION="${{ steps.meta.outputs.version }}"
# Register arm64 emulation. If the runner forbids privileged
# containers this fails and the whole step is skipped.
docker run --privileged --rm tonistiigi/binfmt --install arm64
# Cross-build the arm64 image (QEMU-emulated — slow but uses arm64
# manylinux wheels, so no source compilation). Stays in the local
# daemon alongside the amd64 image from the previous build step.
DOCKER_BUILDKIT=1 docker build \
--platform linux/arm64 \
--build-arg APP_VERSION="$VERSION" \
--label "org.opencontainers.image.version=$VERSION" \
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
-t "$REGISTRY:$VERSION-arm64" \
./server
# Fold amd64 + arm64 into a multi-arch manifest list under each
# user-facing tag. The arch-suffixed tags remain pullable directly.
publish_manifest() {
local t="$1"
docker tag "$REGISTRY:$t" "$REGISTRY:$t-amd64"
docker push "$REGISTRY:$t-amd64"
docker tag "$REGISTRY:$VERSION-arm64" "$REGISTRY:$t-arm64"
docker push "$REGISTRY:$t-arm64"
docker manifest create --amend "$REGISTRY:$t" \
"$REGISTRY:$t-amd64" "$REGISTRY:$t-arm64"
docker manifest push "$REGISTRY:$t"
}
publish_manifest "$TAG"
publish_manifest "$VERSION"
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
publish_manifest "latest"
fi
# ── Publish the release (flip draft=false) ─────────────────
# Runs only after every build job succeeded so users never see a
# release that's missing artifacts or sha256 sidecars (the in-app
+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"
+33 -2
View File
@@ -2,9 +2,40 @@
## Code Search
**If `ast-index` is available, use it as the PRIMARY code search tool.** It is significantly faster than grep and returns structured, accurate results. Fall back to grep/Glob only when ast-index is not installed, returns empty results, or when searching regex patterns/string literals/comments.
**Priority order: `vex` (PRIMARY) → `ast-index` (fallback) → Grep/Glob (last resort).** This repo has a fully-featured `.vex.toml` index. Use vex first for any symbol/definition/usage/call-graph lookup. Fall back to ast-index only when vex legitimately can't help, and to Grep/Glob only for regex patterns, string literals, comments, config files, or unparsed languages.
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt.
**IMPORTANT — use ALL vex indexing features.** The index is built with every capability enabled, and queries must take advantage of them. Keep them ON and exploit them:
| Capability | Status | Powers |
| ---------- | ------ | ------ |
| Semantic embeddings (`jina-code`, 768-dim) | ON | `vex search` (semantic channel), `similar`, `find_similar`, `duplicates` |
| Call graph | ON | `vex callers`, `callees`, `paths`, `reachable`, `bundle --mode pr-impact` |
| BM25 | ON | hybrid RRF text channel in `vex search` |
| Pattern index | ON | `vex pattern` AST-shape matching |
| C++ includes | ON | include-graph resolution |
| Body tokens (incremental HNSW) | ON | fast incremental reindex |
| History | ON | `vex history`, `vex diff <rev>` blame/evolution queries |
**In-session, use the `mcp__vex__*` MCP tools** (`search`, `show`, `usages`, `callers`, `callees`, `bundle`, `outline`, `implementations`, `similar`, `grep`, `status`, etc.) — MCP output is far cheaper in tokens than `Bash("vex …")`. Drop to Bash `vex` only for CLI-only features (`pattern`, `diff`, `paths`, `reachable`, `bundle`, `history`, `--strict`/`--why` flags), for subagent prompts, or for shell composition.
```bash
vex search "query" --semantic # Hybrid semantic + BM25 search
vex show <Symbol> # Definition body (prefer over Read)
vex usages <Symbol> --strict # Reference sites (AST-precise on T1 langs)
vex callers <Function> # Call sites (function-scoped)
vex callees <Function> # Outgoing calls
vex paths --from <A> --to <B> # Multi-hop call-graph path
vex bundle --mode pr-impact --base master # Changed symbols + callers + reachable tests
vex pattern '$X async fn returning Response' # AST-shape (metavariables)
vex diff master # Symbol-level branch diff
vex history <Symbol> # Commit evolution of a symbol
```
**Maintenance:** the index has `auto_update = true`, so it refreshes on stale queries. After a `vex self-update`, rerun `vex index --history --semantic --embedder jina-code --device cuda` so newly-added extractors populate and all features stay enabled. Verify with `vex status` — every capability line should read `yes`.
**IMPORTANT for subagents:** Subagents don't inherit MCP. When spawning Agent subagents (Plan, Explore, general-purpose, etc.), instruct them to use `vex` via Bash for code search (e.g. include "Use `vex search`, `vex show`, `vex usages`, `vex callers` via Bash for code search; ast-index is the fallback"). Don't tell them to default to grep/Glob.
**Fallback — `ast-index`** (use only when vex is unavailable):
```bash
ast-index search "Query" # Universal search
+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 -78
View File
@@ -1,100 +1,91 @@
## v0.8.2 (2026-06-08)
## v0.9.0 (2026-06-23)
### User-facing changes
A large feature release: a full activity/audit log, two roadmap batches of
capture and smart-light improvements, per-pixel control for LIFX/Hue/Nanoleaf,
and new outbound integrations (webhooks + Home Assistant MQTT discovery).
#### Features
### Features
##### 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))
#### Activity Log
- Persistent activity/audit log: storage model with migration, recorder with
actor context and retention, and event instrumentation across four
categories ([1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f), [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e), [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c))
- REST API for list / export / settings / clear ([4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275))
- Activity tab with smart filtering, live updates, and export, plus a
dashboard widget and settings panel ([9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f), [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21))
##### 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))
#### Per-pixel smart lights
- LIFX multizone (SetExtendedColorZones) and Tile per-pixel streaming,
auto-detected on connect with single-colour fallback ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- Philips Hue gradient-lightstrip mapping: Entertainment v2 frames keyed by
channel id, with a `hue_gradient_mode` toggle ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- Nanoleaf extControl v2 per-panel UDP streaming (`per_panel` mode) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
##### 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))
#### Capture & effects
- Linear-light blending and spatio-temporal dithering, opt-in per calibration ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- Audio-reactive palette modulation across all 12 procedural effects ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- Color-harmony gradient generator (complementary / analogous / triadic / …) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
##### 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))
#### Automations & integrations
- Solar sunrise/sunset automation trigger (new `utils/solar.py`) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- Outbound webhook automation action (Discord / IFTTT / Zapier / Node-RED),
SSRF-gated at save and fire time ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- Home Assistant MQTT auto-discovery: read-only binary sensors per automation,
availability via birth/will, with cleanup on disable/delete ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- League of Legends poller wired via a `LoLPollManager` + shared runtime state ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- `auth.expose_docs` flag (default off) to view `/docs`, `/redoc`, and
`/openapi.json` without a token; all real endpoints stay protected ([126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2))
##### 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))
##### 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
- **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))
### Bug Fixes
- Pre-release review hardening: solar timezone crash, webhook header CRLF,
MQTT topic-prefix injection, thread-safe `get_stats` copy, MQTT discovery
lock, `reactive_mode` Literal, and calibration-modal accessibility ([0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db))
- Comprehensive review fixes across security, concurrency, performance,
Android, and UI ([17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0))
- Activity Log polish: accessible export menu, i18n placeholders, dashboard
section reconciliation, column alignment, ticking time, and no spinner
flash on instant filtering ([3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3), [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06), [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8), [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca), [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c))
---
### Development / Internal
#### 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))
#### CI/Build
- Best-effort arm64 multi-arch Docker manifest via QEMU + `docker manifest`
(amd64 path untouched) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
#### Frontend
- In-progress dashboard customization groundwork ([6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569))
#### Chores
- Activity Log feature plan/subplan scaffold, post-merge cleanup, and context
graduated into CLAUDE.md ([1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6), [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235))
#### 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
- Large new suites for calibration solver/session (incl. adversarial), setup & scene-playlist routes, playlist engine, and ROI capture. Full suite: **2149 passing, 2 skipped**
> Tests: ~180 new unit tests added across the activity log, roadmap features,
> and integrations. Release gate green: ruff + tsc + build clean,
> **pytest 2739 passed / 2 skipped**.
---
<details>
<summary>All Commits (31)</summary>
<summary>All Commits</summary>
| Hash | Message | Author |
| ---- | ------- | ------ |
| [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 |
|------|---------|--------|
| [0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db) | fix: address pre-release review findings (2026-06-23) | alexei.dolgolyov |
| [39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554) | feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations | alexei.dolgolyov |
| [6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25) | feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations | alexei.dolgolyov |
| [126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2) | feat(auth): add auth.expose_docs flag to view API docs without a token | alexei.dolgolyov |
| [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235) | chore(activity-log): post-merge cleanup + graduate context to CLAUDE.md | alexei.dolgolyov |
| [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c) | fix(activity-log): no spinner flash on instant filtering | alexei.dolgolyov |
| [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca) | fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix | alexei.dolgolyov |
| [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8) | fix(activity-log): dashboard section reconciliation + activity column alignment | alexei.dolgolyov |
| [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06) | fix(activity-log): post-test polish - localize descriptions, dashboard widget, ticking time | alexei.dolgolyov |
| [3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3) | fix(activity-log): final-review fixes - crosslink keys + sanitize parity | alexei.dolgolyov |
| [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21) | feat(activity-log): phase 6 - dashboard widget + settings panel + docs | alexei.dolgolyov |
| [9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f) | feat(activity-log): phase 5 - Activity tab (smart filtering, live updates, export) | alexei.dolgolyov |
| [4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275) | feat(activity-log): phase 4 - REST API (list/export/settings/clear) | alexei.dolgolyov |
| [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c) | feat(activity-log): phase 3 - event instrumentation (4 categories) | alexei.dolgolyov |
| [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e) | feat(activity-log): phase 2 - recorder, actor context, retention, lifecycle | alexei.dolgolyov |
| [1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f) | feat(activity-log): phase 1 - storage model, migration, repository | alexei.dolgolyov |
| [1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6) | chore(activity-log): scaffold feature plan and phase subplans | alexei.dolgolyov |
| [17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0) | fix: resolve comprehensive review findings (security, concurrency, perf, Android, UI) | alexei.dolgolyov |
</details>
+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.2"
versionName = "0.9.0"
// ABI selection. Detect armeabi-v7a wheel presence and opt the
// ABI in only when the matching pydantic-core wheel is on disk —
@@ -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).
private val prefs: SharedPreferences
init {
val (store, isEncrypted) = buildPrefs(appContext)
prefs = store
// Only run the plain→encrypted migration when the encrypted store is
// actually available; on the degraded plain path there is nothing to
// migrate INTO (and recoverLegacyKey reads the backup directly).
if (isEncrypted) migrateLegacyKeyIfPresent()
}
// Once we've materialised a key in this process, cache it so
// subsequent reads don't hit prefs and don't risk re-checking
@@ -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,115 @@ 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 {
createEncrypted(context) to true
} catch (e: Exception) {
// The keystore can become invalidated (OS upgrade, device restore,
// OEM keystore bug), after which create() throws on EVERY launch and
// the corrupt encrypted file is never cleaned up — degrading to plain
// prefs forever and (because the live key was only in the encrypted
// store) rotating the per-install key on the next mint, 401-ing every
// paired client. Self-heal once: delete the corrupt store + master key
// alias and retry create() before degrading.
Log.w(TAG, "EncryptedSharedPreferences unavailable, attempting one-time reset: ${e.message}")
runCatching {
context.deleteSharedPreferences(ENCRYPTED_PREFS_NAME)
runCatching {
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (ks.containsAlias(MasterKey.DEFAULT_MASTER_KEY_ALIAS)) {
ks.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
}
}.onFailure { Log.w(TAG, "Master-key alias cleanup failed: ${it.message}") }
createEncrypted(context) to true
}.getOrElse {
// Still failing after reset — degrade to plain prefs rather than
// crashing. Worst case the key is stored unencrypted on a
// single-user TV box, which is the pre-existing behaviour.
Log.w(TAG, "EncryptedSharedPreferences still unavailable after reset, using plain prefs: ${it.message}")
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
}
}
}
private fun createEncrypted(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
/**
* One-time migration: if a key exists in the legacy plain-text prefs file
* (from before encrypted storage), copy it into the encrypted store and
* remove the plain copy. Preserves the existing key so already-scanned QR
* clients keep working — generating a fresh key here would silently 401
* every LAN client (see the Data Migration Policy in CLAUDE.md).
*/
private fun migrateLegacyKeyIfPresent() {
// Don't migrate if the encrypted store already holds a key.
if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return
runCatching {
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val legacyKey = legacy.getString(KEY_API_KEY, null)
if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) {
// commit() returns false on write failure WITHOUT throwing, so the
// runCatching wrapper alone does NOT protect this path. Verify the
// encrypted store both committed AND reads back the identical value
// before touching the legacy copy — otherwise a silent write
// failure could delete the only surviving copy of the key and
// rotate it on next launch (401s every paired client — the exact
// silent-data-loss the Data Migration Policy forbids).
val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit()
if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) {
// Keep the value as a .migrated backup (don't hard-delete) per
// the migration policy; remove only the live legacy key so the
// plaintext copy no longer answers reads.
legacy.edit()
.putString(KEY_API_KEY_MIGRATED, legacyKey)
.remove(KEY_API_KEY)
.apply()
Log.i(TAG, "Migrated API key from plain to encrypted storage")
} else {
// Leave the legacy key untouched; getOrCreateKey() will recover
// it via recoverLegacyKey() rather than minting a fresh one.
Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating")
}
}
}.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") }
}
/**
* Recover a still-present key from the legacy plain store — either the live
* key (failed/never-run migration) or the `.migrated` backup. Returns null
* only when no valid key survives.
*
* This MUST run on the degraded plain-prefs path too (not just the encrypted
* path): after a successful migration the live key is moved to the
* `.migrated` backup in this same plain file, so when the keystore later
* fails and we degrade to plain prefs, the backup is the only surviving
* copy. Returning null here (the previous `if (!encrypted) return null`
* guard) would mint a fresh key and rotate the per-install key, 401-ing every
* paired client.
*/
private fun recoverLegacyKey(): String? {
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val candidate = legacy.getString(KEY_API_KEY, null)
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
return candidate?.takeIf { it.length >= MIN_KEY_LENGTH }
}
private fun generateKey(): String {
val bytes = ByteArray(KEY_BYTES)
SecureRandom().nextBytes(bytes)
@@ -88,7 +229,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,26 @@ 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()
// Tracks whether the listener is currently connected. ensureExecutor() only
// CREATES a new executor while connected — otherwise a notification racing
// onListenerDisconnected (which nulls pushExecutor) would spin up a fresh
// executor that nothing reaps until the next disconnect cycle (a thread leak).
@Volatile private var connected: Boolean = false
// packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working.
@@ -51,17 +72,37 @@ 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() ?: run {
Log.d(TAG, "no executor (listener disconnected) — skipping push")
return
}
runCatching {
executor.execute {
try {
Python.getInstance()
.getModule(PY_MODULE)
.callAttr("push_notification", label)
} catch (t: Throwable) {
// Never crash a system-bound service. Python.getInstance() throws
// IllegalStateException if Python.start() hasn't run (e.g. the
// service was bound at boot before the app process initialized).
// Log at debug — the label is potentially sensitive on a shared TV.
Log.d(TAG, "push_notification failed: ${t.message}")
}
}
}.onFailure { e ->
if (e is RejectedExecutionException) {
Log.d(TAG, "push rejected — listener disconnecting")
} else {
throw e
}
}
}
@@ -69,24 +110,64 @@ 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 AND
* the listener is connected. Returns null when disconnected so a notification
* racing teardown neither submits onto a shutting-down executor nor spins up
* a stray one. Safe against a concurrent onListenerConnected/onNotificationPosted
* race (single executor) and against a missing onListenerConnected callback.
*/
private fun ensureExecutor(): ExecutorService? {
pushExecutor?.let { return it }
synchronized(executorLock) {
if (!connected) return null
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
}
}
override fun onListenerConnected() {
Log.i(TAG, "Notification listener connected")
// Spin up the push executor on connect. The system can disconnect and
// later reconnect this service without destroying it, so own the
// executor here rather than in onCreate/onDestroy. onNotificationPosted
// also lazily creates it (via ensureExecutor) in case this callback is
// late or skipped on some ROMs.
connected = true
ensureExecutor()
}
override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected")
// Mark disconnected BEFORE nulling the executor so a racing ensureExecutor
// sees !connected and skips creating a replacement. Tear the executor
// down; a fresh one is created on the next onListenerConnected.
connected = false
pushExecutor?.let { exec ->
pushExecutor = null
exec.shutdown()
}
}
override fun onDestroy() {
pushExecutor.shutdown()
// Defensive: onListenerDisconnected normally clears this first, but
// shut down here too in case onDestroy fires without a prior disconnect.
connected = false
pushExecutor?.shutdown()
pushExecutor = null
super.onDestroy()
}
@@ -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
+7 -1
View File
@@ -31,9 +31,15 @@ Creates the Gitea release with a description table listing all artifacts. **The
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
### 4. `build-docker`
- Plain `docker build` + `docker push` (no Buildx — TrueNAS runners lack nested networking)
- Plain `docker build` + `docker push` for **amd64** (no Buildx — TrueNAS runners lack nested networking)
- Registry: `{gitea_host}/{repo}:{tag}`
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
- **arm64 is best-effort** (Raspberry Pi / arm64 HAOS): a `continue-on-error` step
cross-builds arm64 via **QEMU binfmt** (`tonistiigi/binfmt`) + `docker manifest`
(NOT buildx — sidesteps the docker-container-driver networking limit) and folds
amd64 + arm64 into multi-arch manifest lists under the same tags, plus
`:{tag}-amd64` / `:{tag}-arm64` arch-suffixed tags. If the runner can't run
privileged binfmt the step is skipped and the amd64 tags above remain valid.
## Build Scripts
+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
+8
View File
@@ -22,6 +22,14 @@ auth:
# 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
# (the whole dir — both the database and assets), or uncomment the block
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.8.2"
version = "0.9.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+2
View File
@@ -38,6 +38,7 @@ 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)
@@ -76,5 +77,6 @@ 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"]
+200 -7
View File
@@ -3,18 +3,137 @@
import asyncio
import json
import secrets
import threading
import time
from collections import OrderedDict
from typing import Annotated
from urllib.parse import urlparse
from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from starlette.websockets import WebSocket, WebSocketDisconnect
from ledgrab.config import get_config
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
logger = get_logger(__name__)
# ── Auth-failure audit throttle (H3) ───────────────────────────────────────
#
# Unauthenticated callers can hammer any auth path; without a recording
# throttle each attempt would write one SQLite row AND broadcast one WS event,
# providing a cheap disk/broadcast amplification vector.
#
# Mitigation: record at most one ``auth.rejected`` audit entry per client IP
# per _AUTH_RECORD_WINDOW seconds. The auth decision (401) is NEVER
# suppressed — only the *audit recording* is de-duplicated.
#
# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP
# entries. When the cap is exceeded the oldest-inserted IP is evicted in O(1)
# so the dict stays bounded regardless of the number of distinct source IPs an
# attacker can forge.
#
# Thread safety: the throttle dict is guarded by ``_auth_record_lock`` (mirrors
# ``_auth_fail_lock`` in routes/game_integration) so the compound
# read/evict/insert is atomic. The HTTP auth dependency runs on the event loop
# (``verify_api_key`` is async), but ``_record_auth_failure`` is reached from
# both the HTTP and WebSocket auth paths and must remain safe if ever called
# from a background thread — the lock is uncontended on the loop, so it costs
# nothing while preventing a KeyError / "dict changed size" from ever turning
# an intended 401 into a 500.
_AUTH_RECORD_WINDOW: float = 10.0 # seconds — one record per IP per window
_AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously
# ip -> monotonic timestamp of last *recorded* auth.rejected entry.
# OrderedDict so the oldest insertion can be evicted in O(1) via popitem.
_auth_record_last: "OrderedDict[str, float]" = OrderedDict()
_auth_record_lock = threading.Lock()
def _should_record_auth_failure(client_ip: str) -> bool:
"""Return True when an ``auth.rejected`` record should be written for *client_ip*.
Suppresses duplicates within _AUTH_RECORD_WINDOW seconds. Evicts the
oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent
unbounded memory growth under IP-spray attacks.
Thread-safe: the entire read/evict/insert is performed under
``_auth_record_lock`` so concurrent threadpool workers cannot corrupt the
dict or raise mid-mutation.
"""
now = time.monotonic()
with _auth_record_lock:
last = _auth_record_last.get(client_ip)
if last is not None and (now - last) < _AUTH_RECORD_WINDOW:
return False # suppress: within the de-dup window
# Enforce hard cap before inserting: evict the oldest entry in O(1).
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
_auth_record_last.popitem(last=False)
# Refresh recency: move/insert this IP to the most-recent end so the
# popitem(last=False) above always drops a genuinely old entry.
_auth_record_last[client_ip] = now
_auth_record_last.move_to_end(client_ip)
return True
def _record_auth_failure(reason: str, client_host: str | None) -> None:
"""Best-effort: record an auth failure audit entry (never raises).
SECURITY: the attempted token is NEVER passed here; only the reason and
the caller's IP/label are recorded.
THROTTLE: at most one ``auth.rejected`` record is written per client IP
per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification
DoS. The 401 response is always returned regardless.
The whole body is wrapped so an audit-path failure can never convert an
intended 401 into a 500 (honors the "never raises" contract).
"""
try:
if not _should_record_auth_failure(client_host or "unknown"):
return # throttled — drop duplicate recording for this IP/window
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.rejected",
severity=ActivitySeverity.WARNING,
actor="anonymous",
message=f"Authentication failed: {reason}",
metadata={"reason": reason, "client": client_host or "unknown"},
)
except Exception as exc: # never raise into the auth path
logger.warning("auth-failure audit recording failed: %s", exc)
def _record_ws_auth_success(label: str, client_host: str | None) -> None:
"""Best-effort: record a successful WebSocket session establishment."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.ws_connected",
severity=ActivitySeverity.INFO,
actor=label,
message=f"WebSocket session established by '{label}'",
metadata={"client": client_host or "unknown"},
)
# Security scheme for Bearer token
security = HTTPBearer(auto_error=False)
@@ -49,7 +168,7 @@ def _is_loopback(host: str | None) -> bool:
return _classify_is_loopback(host)
def verify_api_key(
async def verify_api_key(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str:
@@ -63,6 +182,13 @@ def verify_api_key(
LAN access requires an API key).
- When API keys ARE configured, valid Bearer credentials are required.
This is an ``async`` dependency on purpose: token comparison is CPU-trivial,
and an async dependency runs in the SAME task/context as the route handler,
so ``current_actor.set(...)`` below is visible to ``ActivityRecorder`` when
the handler later records an entity event. A sync dependency would run in a
throwaway threadpool context and the actor mutation would be discarded,
attributing every audited action to "system".
Args:
request: incoming request (used to read client host)
credentials: HTTP authorization credentials
@@ -81,10 +207,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,24 +225,32 @@ def verify_api_key(
# Check if credentials are provided
if not credentials:
logger.warning("Request missing Authorization header")
_record_auth_failure("missing Bearer token", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key - authentication is required",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract token
# Extract token — NEVER log or record the token value itself.
token = credentials.credentials
# Find matching key and return its label using constant-time comparison
# Find matching key and return its label using constant-time comparison.
# Compare UTF-8 byte encodings: secrets.compare_digest raises TypeError on
# non-ASCII str (an attacker can put 0x80-0xFF in the Authorization header,
# which Starlette latin-1-decodes to a non-ASCII str). Byte comparison is
# well-defined for any input and preserves constant-time behavior, so a
# bad/non-ASCII token cleanly falls through to the 401 below instead of 500.
token_b = (token or "").encode("utf-8")
authenticated_as = None
for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
authenticated_as = label
break
if not authenticated_as:
logger.warning("Invalid API key attempt")
_record_auth_failure("invalid Bearer token", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
@@ -127,6 +263,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 +274,31 @@ def verify_api_key(
AuthRequired = Annotated[str, Depends(verify_api_key)]
async def verify_docs_access(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str:
"""Auth gate for the OpenAPI docs routes (/docs, /redoc, /openapi.json).
When ``auth.expose_docs`` is True, the docs pages load anonymously from any
client (loopback and LAN) so they can be viewed in a browser without a
Bearer token. Only the API *surface* is exposed this way — every other
endpoint still goes through :func:`verify_api_key`.
When ``auth.expose_docs`` is False (default), this delegates to
:func:`verify_api_key`, so docs require a token exactly like the rest of
the API.
"""
if get_config().auth.expose_docs:
request.state.auth_label = "anonymous-docs"
return "anonymous-docs"
return await verify_api_key(request, credentials)
# Dependency for the OpenAPI docs routes — relaxed when auth.expose_docs is set
DocsAccess = Annotated[str, Depends(verify_docs_access)]
def require_authenticated(label: str) -> None:
"""Reject the anonymous (loopback) auth label.
@@ -190,12 +354,30 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
# a strong signal even before the token check. Non-browser clients
# legitimately omit Origin; those fall through to the auth handshake.
config = get_config()
client_host = websocket.client.host if websocket.client else None
origin = websocket.headers.get("origin")
if not _is_origin_allowed(origin, config.server.cors_origins):
logger.warning(
"Rejected WebSocket from origin %r (not in cors_origins)",
origin,
)
# Sanitize first so urlparse does not choke on control chars / ANSI / NUL
# embedded by an attacker in the Origin header (e.g. \n triggers IPv6 parse
# error in Python's urlsplit on malformed netloc).
_safe_origin_raw = sanitize_display(origin) if origin else ""
try:
_netloc = urlparse(_safe_origin_raw).netloc if _safe_origin_raw else ""
except ValueError:
# Malformed IPv6 addresses (e.g. "http://[::1" without closing "]")
# cause urlparse to raise ValueError. Fall back to "unknown" — do NOT
# fall back to the raw origin string, which could carry query params
# or path components containing secrets.
_netloc = ""
_safe_origin = sanitize_display(_netloc or "unknown")
_record_auth_failure(
f"WebSocket origin rejected: {_safe_origin!r}",
client_host,
)
try:
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
except _WS_SEND_BENIGN_EXC:
@@ -210,6 +392,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
except _WS_SEND_BENIGN_EXC:
pass
return None
_record_ws_auth_success(label, client_host)
return label
@@ -217,12 +400,19 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
def _match_api_key(token: str) -> str | None:
"""Return the label matching *token* using constant-time comparison, or None."""
"""Return the label matching *token* using constant-time comparison, or None.
Compares UTF-8 byte encodings so a non-ASCII token (a JSON string in the WS
auth message trivially carries non-ASCII) cannot raise TypeError out of
``secrets.compare_digest`` — it simply fails to match and yields a clean
``auth_error`` instead of crashing the handler.
"""
config = get_config()
if not token:
return None
token_b = token.encode("utf-8")
for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
return label
return None
@@ -275,6 +465,7 @@ async def verify_ws_auth(
return None
return "anonymous"
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
_record_auth_failure("WebSocket auth timeout", client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
except _WS_SEND_BENIGN_EXC:
@@ -332,6 +523,7 @@ async def verify_ws_auth(
await websocket.send_json({"type": "auth_ok"})
return "anonymous"
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
_record_auth_failure("LAN WebSocket rejected: no API key configured", client_host)
try:
await websocket.send_json(
{
@@ -343,10 +535,11 @@ async def verify_ws_auth(
pass
return None
# Keys configured: require a matching token.
# Keys configured: require a matching token. NEVER log the token value.
label = _match_api_key(token or "")
if not label:
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
_record_auth_failure("invalid WebSocket token", client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
except _WS_SEND_BENIGN_EXC:
+128 -2
View File
@@ -37,11 +37,17 @@ from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.pattern_template_store import PatternTemplateStore
from ledgrab.core.activity_log.recorder import ActivityRecorder, get_module_recorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
T = TypeVar("T")
@@ -168,6 +174,15 @@ def get_game_event_bus() -> GameEventBus:
return _get("game_event_bus", "Game event bus")
def get_lol_poll_manager() -> LoLPollManager | None:
"""LoL poll manager, or None if not wired (e.g. minimal test harnesses).
Polling is a best-effort background feature, so callers guard on None
rather than 500-ing a CRUD request when the manager is absent.
"""
return _deps.get("lol_poll_manager")
def get_mqtt_store() -> MQTTSourceStore:
return _get("mqtt_store", "MQTT source store")
@@ -196,16 +211,87 @@ def get_update_service() -> UpdateService:
return _get("update_service", "Update service")
def get_activity_recorder() -> ActivityRecorder:
return _get("activity_recorder", "Activity recorder")
def get_activity_log_repo() -> ActivityLogRepository:
return _get("activity_log_repo", "Activity log repository")
def get_activity_log_retention_engine() -> ActivityLogRetentionEngine:
return _get("activity_log_retention_engine", "Activity log retention engine")
# ── Event helper ────────────────────────────────────────────────────────
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus.
# entity_type → (_deps key, store method name) for human-name resolution.
# Module-level constant: built once at import rather than per audited mutation
# (``_resolve_entity_name`` is the create/update audit choke point).
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
"output_target": ("output_target_store", "get_target"),
"device": ("device_store", "get_device"),
"picture_source": ("picture_source_store", "get_source"),
"audio_source": ("audio_source_store", "get_source"),
"color_strip_source": ("color_strip_store", "get_source"),
"template": ("template_store", "get_template"),
"capture_template": ("template_store", "get_template"),
"pp_template": ("pp_template_store", "get_template"),
"automation": ("automation_store", "get_automation"),
"scene_preset": ("scene_preset_store", "get_preset"),
"scene_playlist": ("scene_playlist_store", "get_playlist"),
"sync_clock": ("sync_clock_store", "get_clock"),
"gradient": ("gradient_store", "get_gradient"),
"audio_template": ("audio_template_store", "get_template"),
"value_source": ("value_source_store", "get_source"),
"cspt": ("cspt_store", "get_template"),
"audio_processing_template": ("audio_processing_template_store", "get_template"),
"pattern_template": ("pattern_template_store", "get_template"),
"home_assistant_source": ("ha_store", "get_source"),
"mqtt_source": ("mqtt_store", "get_source"),
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
}
def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None:
"""Best-effort: look up a human name for *entity_id* from the matching store.
Returns ``None`` when the store is missing, the entity is gone, or any
exception occurs (e.g. during delete the entity may have just been removed).
"""
entry = _STORE_LOOKUP.get(entity_type)
if entry is None:
return None
store_key, method_name = entry
store = _deps.get(store_key)
if store is None:
return None
try:
obj = getattr(store, method_name)(entity_id)
if obj is not None:
return getattr(obj, "name", None)
except Exception:
pass
return None
def fire_entity_event(
entity_type: str,
action: str,
entity_id: str,
entity_name: str | None = None,
) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus and
record an audit entry.
Args:
entity_type: e.g. "device", "output_target", "color_strip_source"
action: "created", "updated", or "deleted"
entity_id: The entity's unique ID
entity_name: Human-readable name. For deletes: **must** be passed
explicitly (entity is already gone when we get here).
For create/update: resolved from the store when not supplied.
"""
pm = _deps.get("processor_manager")
if pm is not None:
@@ -218,6 +304,38 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
}
)
# ── Audit record (best-effort) ──────────────────────────────────────────
rec = get_module_recorder()
if rec is None:
return
# Resolve name when not explicitly provided (create / update paths).
# For deleted: entity already gone — rely on the explicitly passed name.
resolved_name = entity_name
if resolved_name is None and action != "deleted":
resolved_name = _resolve_entity_name(entity_type, entity_id)
# Build a concise human message.
# Sanitize the display name before interpolating into the free-text message
# (user-authored names hit the CSV/export trust surface).
safe_display_name = sanitize_display(resolved_name) if resolved_name else None
display_name = f"'{safe_display_name}'" if safe_display_name else entity_id
action_word = {"created": "created", "updated": "updated", "deleted": "deleted"}.get(
action, action
)
entity_label = entity_type.replace("_", " ")
message = f"{entity_label.capitalize()} {display_name} {action_word}"
rec.record(
category=ActivityCategory.ENTITY,
action=f"entity.{action}",
severity=ActivitySeverity.INFO,
entity_type=entity_type,
entity_id=entity_id,
entity_name=sanitize_display(resolved_name) if resolved_name else None,
message=message,
)
# ── Initialization ──────────────────────────────────────────────────────
@@ -252,11 +370,15 @@ def init_dependencies(
ha_manager: HomeAssistantManager | None = None,
game_integration_store: GameIntegrationStore | None = None,
game_event_bus: GameEventBus | None = None,
lol_poll_manager: LoLPollManager | None = None,
mqtt_store: MQTTSourceStore | None = None,
mqtt_manager: MQTTManager | None = None,
http_endpoint_store: HTTPEndpointStore | None = None,
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
pattern_template_store: PatternTemplateStore | None = None,
activity_recorder: ActivityRecorder | None = None,
activity_log_repo: ActivityLogRepository | None = None,
activity_log_retention_engine: ActivityLogRetentionEngine | None = None,
):
"""Initialize global dependencies."""
_deps.update(
@@ -290,10 +412,14 @@ def init_dependencies(
"ha_manager": ha_manager,
"game_integration_store": game_integration_store,
"game_event_bus": game_event_bus,
"lol_poll_manager": lol_poll_manager,
"mqtt_store": mqtt_store,
"mqtt_manager": mqtt_manager,
"http_endpoint_store": http_endpoint_store,
"audio_processing_template_store": audio_processing_template_store,
"pattern_template_store": pattern_template_store,
"activity_recorder": activity_recorder,
"activity_log_repo": activity_log_repo,
"activity_log_retention_engine": activity_log_retention_engine,
}
)
@@ -0,0 +1,468 @@
"""Activity-log REST API — query / filter / export / settings / clear.
Endpoints
---------
GET /api/v1/activity-log List (filterable, keyset-paginated)
GET /api/v1/activity-log/export Streaming CSV or JSON export
GET /api/v1/activity-log/settings Retention settings
PUT /api/v1/activity-log/settings Update retention settings (requires non-anonymous auth)
DELETE /api/v1/activity-log Clear all entries (requires non-anonymous auth)
Auth posture
------------
- List + read settings (``GET``): ``AuthRequired`` (loopback-anonymous is fine).
- Export, update settings (``PUT``), and clear: ``require_authenticated()``
(loopback-anonymous is rejected; mirrors the backup download / secret-reveal
pattern from ``backup.py``). Updating settings can disable auditing or prune
the trail, so it is gated like the destructive clear.
CSV injection
-------------
Cells that begin with =, +, -, @, TAB, or CR can trigger formula execution in
spreadsheet apps (OWASP Formula Injection). ``_csv_safe`` prefixes any such cell
with a single quote so formulas are inert. Fields already go through
``sanitize_display`` in Phase 3 instrumentation, but the CSV writer applies its
own guard as defence-in-depth.
Export generator + lock
-----------------------
``repo.iter_export()`` fetches rows in bounded batches, holding the DB ``_lock``
only around each batch fetch and releasing it before yielding — so a slow or
stalled client never blocks other DB operations. The ``StreamingResponse``
generator is wrapped in a ``try/finally`` block so the batch generator is closed
even when the client disconnects mid-stream.
"""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from typing import Annotated, Iterator
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from ledgrab.api.auth import AuthRequired, require_authenticated
from ledgrab.api.dependencies import (
get_activity_log_repo,
get_activity_log_retention_engine,
get_activity_recorder,
)
from ledgrab.api.schemas.activity_log import (
ActivityLogPageResponse,
ActivityLogSettingsResponse,
UpdateActivityLogSettingsRequest,
)
from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogFilters, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
router = APIRouter(prefix="/api/v1/activity-log", tags=["Activity Log"])
# Hard cap on the per-request limit to prevent runaway queries.
_MAX_LIMIT = 200
_DEFAULT_LIMIT = 50
# Bounds on the text filter params so a multi-KB ``q`` / actor / entity filter
# can't enlarge the LIKE pattern and bound params per page (FastAPI returns 422
# on overflow). The free-text ``q`` gets a larger budget than the id filters.
_MAX_TEXT_FILTER = 256
_MAX_ID_FILTER = 128
# CSV export columns (matches entry_to_dict key order)
_CSV_COLUMNS = [
"id",
"ts",
"category",
"action",
"severity",
"actor",
"entity_type",
"entity_id",
"entity_name",
"message",
"metadata",
]
# Characters that trigger formula injection in spreadsheet apps (OWASP).
# Leading TAB and CR are also recognised triggers by Excel / Google Sheets.
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
# Cap for export-cell sanitization. Effectively no truncation (a single audit
# field never approaches this) — we reuse sanitize_display only to strip
# NUL/control/ANSI from CSV cells, not to shorten them.
_EXPORT_CELL_MAXLEN = 1_000_000
def _csv_safe(value: str) -> str:
"""Prefix formula-injection triggers with a literal single-quote.
A cell starting with =, +, -, or @ can execute as a formula in Excel /
Google Sheets. OWASP recommends prepending a single quote to neutralise it.
"""
if value and value[0] in _FORMULA_PREFIXES:
return "'" + value
return value
def _redact_for_anon(entry_dict: dict, auth_label: str) -> dict:
"""Redact the source-IP metadata for anonymous (loopback) callers.
The streaming export is gated by ``require_authenticated`` precisely because
the log can contain client IPs (e.g. ``auth.rejected`` / ``auth.ws_connected``
store ``metadata.client``). The list endpoint allows loopback-anonymous
callers, so to keep the posture consistent we mask that one field for the
``"anonymous"`` label rather than handing it back what export withholds.
"""
if auth_label != "anonymous":
return entry_dict
meta = entry_dict.get("metadata")
if isinstance(meta, dict) and "client" in meta:
return {**entry_dict, "metadata": {**meta, "client": "[redacted]"}}
return entry_dict
def _build_filters(
categories: list[str] | None,
severities: list[str] | None,
actor: str | None,
entity_type: str | None,
entity_id: str | None,
since: datetime | None,
until: datetime | None,
q: str | None,
) -> ActivityLogFilters:
"""Assemble an ``ActivityLogFilters`` dataclass from query parameters."""
return ActivityLogFilters(
categories=categories or None,
severities=severities or None,
actor=actor or None,
entity_type=entity_type or None,
entity_id=entity_id or None,
since=since,
until=until,
message_like=q or None,
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log — list
# ---------------------------------------------------------------------------
@router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries")
def list_activity_log(
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Filters ────────────────────────────────────────────────────────────
categories: Annotated[
list[str] | None,
Query(
description=(
"Filter by category (repeatable or comma-separated). "
"Values: auth, device, entity, capture, system"
)
),
] = None,
severities: Annotated[
list[str] | None,
Query(description="Filter by severity (repeatable). Values: info, warning, error"),
] = None,
actor: Annotated[
str | None,
Query(max_length=_MAX_ID_FILTER, description="Filter by actor label (exact match)"),
] = None,
entity_type: Annotated[
str | None,
Query(max_length=_MAX_ID_FILTER, description="Filter by entity type (exact match)"),
] = None,
entity_id: Annotated[
str | None,
Query(max_length=_MAX_ID_FILTER, description="Filter by entity id (exact match)"),
] = None,
since: Annotated[
datetime | None,
Query(description="Return entries at or after this ISO-8601 datetime"),
] = None,
until: Annotated[
datetime | None,
Query(description="Return entries at or before this ISO-8601 datetime"),
] = None,
q: Annotated[
str | None,
Query(
max_length=_MAX_TEXT_FILTER,
description="Free-text search in the message field (substring)",
),
] = None,
# ── Pagination ─────────────────────────────────────────────────────────
before_seq: Annotated[
int | None,
Query(
description=(
"Keyset cursor: pass the 'next_before_seq' from the previous page "
"to get the following (older) page. Omit for the first (newest) page."
)
),
] = None,
limit: Annotated[
int,
Query(
ge=1,
le=_MAX_LIMIT,
description=f"Max entries per page (default {_DEFAULT_LIMIT}, max {_MAX_LIMIT})",
),
] = _DEFAULT_LIMIT,
) -> ActivityLogPageResponse:
"""Return the newest matching entries, oldest-first within the page.
Keyset pagination: the response includes ``next_before_seq`` — pass it
as ``before_seq`` in the next request to get the next (older) page.
The ``total`` field is the count of all entries matching the current
filters across all pages.
"""
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
# Fetch limit+1 rows to detect whether an older page exists.
#
# query() fetches DESC internally (newest-first) then reverses to ascending.
# With limit+1, the result is ascending: [oldest_probe, ..., newest].
# When we got exactly limit+1 rows, has_more is True and the probe row
# (index 0 — the oldest) is the extra one. We keep the newest `limit` rows
# by slicing [1:], which is the actual page content for the client.
# When we got <= limit rows, this is the last page and all rows are included.
effective_limit = min(limit, _MAX_LIMIT)
# query_with_seq returns (seq, entry) ascending (oldest-first within page),
# so the seq is already in hand — no extra get_seq_for_id round-trip.
rows_plus = repo.query_with_seq(filters, before_seq=before_seq, limit=effective_limit + 1)
has_more = len(rows_plus) > effective_limit
# When over-fetched, drop the oldest probe row (index 0) and keep the newest.
rows = rows_plus[1:] if has_more else rows_plus
total = repo.count(filters)
# next_before_seq: the seq of the oldest entry on this page (rows[0]).
# The next request passes before_seq=X to get entries with seq < X.
next_before_seq: int | None = rows[0][0] if (has_more and rows) else None
return ActivityLogPageResponse(
entries=[_redact_for_anon(entry_to_dict(e), auth) for _seq, e in rows], # type: ignore[arg-type]
next_before_seq=next_before_seq,
has_more=has_more,
total=total,
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log/export — streaming export (CSV or JSON)
# ---------------------------------------------------------------------------
def _export_csv_generator(
repo: ActivityLogRepository,
filters: ActivityLogFilters,
) -> Iterator[bytes]:
"""Yield UTF-8-encoded CSV chunks one row at a time.
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
lock is released even on early client disconnect (which triggers
``GeneratorExit``).
"""
gen = repo.iter_export(filters)
try:
# Header
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(_CSV_COLUMNS)
yield buf.getvalue().encode("utf-8")
for entry in gen:
d = entry_to_dict(entry)
row = []
for col in _CSV_COLUMNS:
if col == "metadata":
# json.dumps escapes control chars (<0x20) as \uXXXX, so the
# metadata cell can't carry raw NUL/CR/ANSI into the file.
cell = json.dumps(d.get(col) or {})
else:
# Defense-in-depth: strip NUL/control/ANSI from string cells
# at the export boundary so a (current or future) un-sanitized
# call site can't leak control chars into the CSV. csv.writer
# quotes embedded newlines but does not strip control chars.
cell = sanitize_display(str(d.get(col, "") or ""), maxlen=_EXPORT_CELL_MAXLEN)
row.append(_csv_safe(cell))
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(row)
yield buf.getvalue().encode("utf-8")
finally:
gen.close()
def _export_json_generator(
repo: ActivityLogRepository,
filters: ActivityLogFilters,
) -> Iterator[bytes]:
"""Yield a streamed JSON array, one entry per chunk.
Format: ``[\\n{entry},\\n{entry},\\n...]\\n``
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
lock is released even on early client disconnect.
"""
gen = repo.iter_export(filters)
try:
first = True
yield b"[\n"
for entry in gen:
d = entry_to_dict(entry)
chunk = json.dumps(d, ensure_ascii=False, default=str)
if first:
yield chunk.encode("utf-8")
first = False
else:
yield b",\n" + chunk.encode("utf-8")
yield b"\n]\n"
finally:
gen.close()
@router.get("/export", summary="Export activity-log entries (streaming CSV or JSON)")
def export_activity_log(
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Format ────────────────────────────────────────────────────────────
format: Annotated[
str,
Query(description="Export format: 'csv' or 'json'"),
] = "csv",
# ── Same filters as list ───────────────────────────────────────────────
categories: Annotated[list[str] | None, Query()] = None,
severities: Annotated[list[str] | None, Query()] = None,
actor: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
entity_type: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
entity_id: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
since: Annotated[datetime | None, Query()] = None,
until: Annotated[datetime | None, Query()] = None,
q: Annotated[str | None, Query(max_length=_MAX_TEXT_FILTER)] = None,
) -> StreamingResponse:
"""Stream all matching entries as CSV or JSON.
Requires a non-anonymous API key (loopback-anonymous access is rejected
because the log may contain IP addresses and entity names).
"""
require_authenticated(auth)
if format not in ("csv", "json"):
from fastapi import HTTPException
raise HTTPException(
status_code=422,
detail="'format' must be 'csv' or 'json'",
)
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
if format == "csv":
filename = f"activity-log-{timestamp}.csv"
media_type = "text/csv; charset=utf-8"
generator = _export_csv_generator(repo, filters)
else:
filename = f"activity-log-{timestamp}.json"
media_type = "application/json"
generator = _export_json_generator(repo, filters)
return StreamingResponse(
generator,
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log/settings
# PUT /api/v1/activity-log/settings
# ---------------------------------------------------------------------------
@router.get(
"/settings",
response_model=ActivityLogSettingsResponse,
summary="Get activity-log retention settings",
)
def get_activity_log_settings(
_: AuthRequired,
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
) -> ActivityLogSettingsResponse:
"""Return the current activity-log retention settings."""
return ActivityLogSettingsResponse(**engine.get_settings())
@router.put(
"/settings",
response_model=ActivityLogSettingsResponse,
summary="Update activity-log retention settings",
)
async def update_activity_log_settings(
auth: AuthRequired,
body: UpdateActivityLogSettingsRequest,
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
) -> ActivityLogSettingsResponse:
"""Update the activity-log retention settings (applied immediately).
Requires a non-anonymous API key (loopback-anonymous access is rejected)
because disabling the log or pruning retention is equivalent in impact to
clearing the audit trail.
Setting ``enabled=false`` records an audit entry BEFORE the flag takes
effect so the last entry in the log shows who disabled recording.
"""
require_authenticated(auth)
result = await engine.update_settings(
enabled=body.enabled,
max_days=body.max_days,
max_entries=body.max_entries,
)
return ActivityLogSettingsResponse(**result)
# ---------------------------------------------------------------------------
# DELETE /api/v1/activity-log — clear
# ---------------------------------------------------------------------------
@router.delete("", summary="Clear all activity-log entries")
def clear_activity_log(
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
recorder: ActivityRecorder = Depends(get_activity_recorder),
) -> dict:
"""Delete all activity-log entries.
Requires a non-anonymous API key (loopback-anonymous access is rejected).
The clear operation itself is audited — a ``system/activity_log_cleared``
entry is recorded AFTER the wipe, so the log shows who cleared it and how
many rows were removed.
Returns ``{"deleted": <count>}``.
"""
require_authenticated(auth)
deleted = repo.clear()
# Record the clear action (best-effort — recorder never raises).
recorder.record(
category=ActivityCategory.SYSTEM,
action="activity_log.cleared",
severity=ActivitySeverity.INFO,
actor=auth,
message=f"Activity log cleared ({deleted} entries removed)",
metadata={"deleted_count": deleted},
)
return {"deleted": deleted}
@@ -182,6 +182,12 @@ async def delete_audio_source(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete an audio source."""
_entity_name: str | None = None
try:
_entity_name = store.get_source(source_id).name
except Exception:
pass
try:
# Check if any CSS entities reference this audio source
from ledgrab.storage.color_strip_source import AudioColorStripSource
@@ -194,7 +200,7 @@ async def delete_audio_source(
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
fire_entity_event("audio_source", "deleted", source_id, entity_name=_entity_name)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
+111 -1
View File
@@ -12,28 +12,35 @@ from ledgrab.api.dependencies import (
get_scene_preset_store,
)
from ledgrab.api.schemas.automations import (
ActionSchema,
AutomationCreate,
AutomationListResponse,
AutomationResponse,
AutomationTriggerResponse,
AutomationUpdate,
RuleSchema,
)
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.storage.automation import (
Action,
ApplicationRule,
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
ManualTriggerRule,
MQTTRule,
Rule,
SolarRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
WebhookAction,
WebhookRule,
)
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger
from ledgrab.utils.safe_source import validate_polling_url
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -55,6 +62,20 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
days_of_week=s.days_of_week or [],
timezone=s.timezone or "",
),
# SolarRule.from_dict validates events, clamps offsets/coords, and
# filters weekdays — route the raw schema values through it.
"solar": lambda: SolarRule.from_dict(
{
"start_event": s.start_event,
"start_offset_minutes": s.start_offset_minutes,
"end_event": s.end_event,
"end_offset_minutes": s.end_offset_minutes,
"latitude": s.latitude,
"longitude": s.longitude,
"days_of_week": s.days_of_week or [],
"timezone": s.timezone or "",
}
),
"system_idle": lambda: SystemIdleRule(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
when_idle=s.when_idle if s.when_idle is not None else True,
@@ -72,6 +93,7 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
token=s.token or secrets.token_hex(16),
),
"startup": lambda: StartupRule(),
"manual_trigger": lambda: ManualTriggerRule(),
"home_assistant": lambda: HomeAssistantRule(
ha_source_id=s.ha_source_id or "",
entity_id=s.entity_id or "",
@@ -95,6 +117,43 @@ def _rule_to_schema(r: Rule) -> RuleSchema:
return RuleSchema(**d)
def _action_from_schema(s: ActionSchema) -> Action:
"""Build a domain Action from its request schema, validating the webhook URL.
The SSRF gate runs here (save time) AND again at fire time, closing the
DNS-rebinding window. A bad/blocked URL rejects the whole save with 400.
"""
if s.action_type != "webhook":
raise ValueError(f"Unknown action type: {s.action_type}")
url = (s.webhook_url or "").strip()
if not url:
raise ValueError("webhook action requires a webhook_url")
method = (s.method or "POST").upper()
if method not in ("POST", "PUT", "GET"):
raise ValueError(f"Invalid webhook method: {method}. Must be POST, PUT or GET.")
fire_on = s.fire_on or "activate"
if fire_on not in ("activate", "deactivate", "both"):
raise ValueError(f"Invalid fire_on: {fire_on}. Must be activate, deactivate or both.")
# content_type is emitted verbatim as the outbound Content-Type header — reject
# control chars (CR/LF) so it can't be used to inject additional HTTP headers.
content_type = (s.content_type or "application/json").strip()
if len(content_type) > 128 or any(ord(c) < 0x20 or ord(c) > 0x7E for c in content_type):
raise ValueError("Invalid content_type: control or non-ASCII characters are not allowed.")
# Raises HTTPException(400) on a blocked/loopback/metadata target.
validate_polling_url(url)
return WebhookAction(
webhook_url=url,
method=method,
body_template=s.body_template or "",
content_type=content_type,
fire_on=fire_on,
)
def _action_to_schema(a: Action) -> ActionSchema:
return ActionSchema(**a.to_dict())
def _automation_to_response(
automation, engine: AutomationEngine, request: Request = None
) -> AutomationResponse:
@@ -130,6 +189,7 @@ def _automation_to_response(
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
tags=automation.tags,
actions=[_action_to_schema(a) for a in getattr(automation, "actions", [])],
icon=getattr(automation, "icon", "") or "",
icon_color=getattr(automation, "icon_color", "") or "",
created_at=automation.created_at,
@@ -186,6 +246,7 @@ async def create_automation(
try:
rules = [_rule_from_schema(r) for r in data.rules]
actions = [_action_from_schema(a) for a in data.actions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -201,6 +262,7 @@ async def create_automation(
deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags,
actions=actions,
icon=data.icon,
icon_color=data.icon_color,
)
@@ -283,6 +345,13 @@ async def update_automation(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
actions = None
if data.actions is not None:
try:
actions = [_action_from_schema(a) for a in data.actions]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
try:
# If disabling, deactivate first
if data.enabled is False:
@@ -297,6 +366,7 @@ async def update_automation(
rules=rules,
deactivation_mode=data.deactivation_mode,
tags=data.tags,
actions=actions,
icon=data.icon,
icon_color=data.icon_color,
)
@@ -329,6 +399,12 @@ async def delete_automation(
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Delete an automation."""
_entity_name: str | None = None
try:
_entity_name = store.get_automation(automation_id).name
except Exception:
pass
# Deactivate first
await engine.deactivate_if_active(automation_id)
@@ -337,7 +413,7 @@ async def delete_automation(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("automation", "deleted", automation_id)
fire_entity_event("automation", "deleted", automation_id, entity_name=_entity_name)
# ===== Enable/Disable =====
@@ -388,3 +464,37 @@ async def disable_automation(
raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine, request)
# ===== Manual trigger =====
@router.post(
"/api/v1/automations/{automation_id}/trigger",
response_model=AutomationTriggerResponse,
tags=["Automations"],
)
async def trigger_automation(
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Manually fire an automation.
Evaluates the automation's rules with its manual trigger satisfied — so it
"still checks all of the rules" under the automation's ``rule_logic`` — and,
if it should activate, applies its scene once. Independent of the ``enabled``
flag (that gates only the background evaluation loop).
"""
try:
automation = store.get_automation(automation_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
try:
status, errors = await engine.fire_manual_trigger(automation)
except Exception as e: # noqa: BLE001 — surface a structured error, never a bare 500
logger.error("Manual trigger failed for automation %s: %s", automation_id, e)
return AutomationTriggerResponse(status="error", errors=[str(e)])
return AutomationTriggerResponse(status=status, errors=errors)
+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}
@@ -36,6 +36,7 @@ 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,
@@ -81,6 +82,19 @@ async def start_calibration_session(
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())
@@ -135,6 +149,17 @@ async def stop_calibration_session(
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())
@@ -155,6 +180,17 @@ async def cancel_calibration_session(
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())
@@ -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:
+19 -1
View File
@@ -96,13 +96,16 @@ def _device_to_response(device) -> DeviceResponse:
espnow_channel=device.espnow_channel,
hue_paired=bool(device.hue_username and device.hue_client_key),
hue_entertainment_group_id=device.hue_entertainment_group_id,
hue_gradient_mode=device.hue_gradient_mode,
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
wiz_min_interval_ms=device.wiz_min_interval_ms,
lifx_min_interval_ms=device.lifx_min_interval_ms,
lifx_per_zone=device.lifx_per_zone,
govee_min_interval_ms=device.govee_min_interval_ms,
opc_channel=device.opc_channel,
nanoleaf_paired=bool(device.nanoleaf_token),
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
nanoleaf_per_panel=device.nanoleaf_per_panel,
spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type,
@@ -261,6 +264,9 @@ async def create_device(
hue_username=device_data.hue_username or "",
hue_client_key=device_data.hue_client_key or "",
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
hue_gradient_mode=(
device_data.hue_gradient_mode if device_data.hue_gradient_mode is not None else True
),
yeelight_min_interval_ms=(
device_data.yeelight_min_interval_ms
if device_data.yeelight_min_interval_ms is not None
@@ -276,6 +282,7 @@ async def create_device(
if device_data.lifx_min_interval_ms is not None
else 50
),
lifx_per_zone=bool(device_data.lifx_per_zone),
govee_min_interval_ms=(
device_data.govee_min_interval_ms
if device_data.govee_min_interval_ms is not None
@@ -288,6 +295,7 @@ async def create_device(
if device_data.nanoleaf_min_interval_ms is not None
else 100
),
nanoleaf_per_panel=bool(device_data.nanoleaf_per_panel),
spi_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink",
@@ -631,13 +639,16 @@ async def update_device(
hue_username=update_data.hue_username,
hue_client_key=update_data.hue_client_key,
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
hue_gradient_mode=update_data.hue_gradient_mode,
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
lifx_per_zone=update_data.lifx_per_zone,
govee_min_interval_ms=update_data.govee_min_interval_ms,
opc_channel=update_data.opc_channel,
nanoleaf_token=update_data.nanoleaf_token,
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
nanoleaf_per_panel=update_data.nanoleaf_per_panel,
spi_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type,
@@ -701,6 +712,13 @@ async def delete_device(
):
"""Delete/detach a device. Returns 409 if referenced by a target."""
try:
# Resolve name before deletion for the audit record.
_entity_name: str | None = None
try:
_entity_name = store.get_device(device_id).name
except Exception:
pass
# Check if any target references this device
refs = target_store.get_targets_for_device(device_id)
if refs:
@@ -728,7 +746,7 @@ async def delete_device(
# Delete from storage
store.delete_device(device_id)
fire_entity_event("device", "deleted", device_id)
fire_entity_event("device", "deleted", device_id, entity_name=_entity_name)
logger.info(f"Deleted device {device_id}")
except HTTPException:
+155 -61
View File
@@ -6,6 +6,7 @@ adapter metadata, and diagnostics.
import threading
import time
from collections import defaultdict
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -16,6 +17,7 @@ from ledgrab.api.dependencies import (
get_database,
get_game_integration_store,
get_game_event_bus,
get_lol_poll_manager,
)
from ledgrab.api.schemas.game_integration import (
AdapterInfoResponse,
@@ -36,9 +38,16 @@ from ledgrab.api.schemas.game_integration import (
)
from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.events import GameEvent
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
from ledgrab.core.game_integration.runtime_state import (
cleanup_state as _cleanup_state,
get_prev_state as _get_prev_state,
get_stats as _get_stats,
record_events as _record_events,
set_prev_state as _set_prev_state,
)
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.game_integration import EventMapping
from ledgrab.storage.game_integration import _SECRET_CONFIG_KEYS, EventMapping
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.utils import get_logger
@@ -46,15 +55,77 @@ logger = get_logger(__name__)
router = APIRouter()
# ── Per-integration runtime state (in-memory, not persisted) ──────────────
# Per-integration runtime state (prev-state + stats + payload processing) lives
# in ``core/game_integration/runtime_state.py`` and is imported above under the
# legacy ``_get_prev_state`` / ``_record_events`` / … names so both this route
# and the LoL poll manager share one set of counters.
_integration_state_lock = threading.Lock()
# integration_id -> prev_state dict for diff-based trigger detection
_prev_states: dict[str, dict[str, Any]] = {}
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
#
# The ingest route is high-frequency (games push at 16-64 Hz), so we do NOT
# rate-limit every event — that would throttle legitimate gameplay traffic.
# Instead we throttle only FAILED-auth attempts per source IP (the only thing
# an attacker without the token can produce). This mirrors the IP-based
# limiter in routes/webhooks.py (~30/min) but scopes it to failures so a
# brute-forcer is locked out after _AUTH_FAIL_LIMIT bad tokens per minute
# while authenticated high-rate ingestion is completely unaffected.
_AUTH_FAIL_LIMIT = 30
_AUTH_FAIL_WINDOW = 60.0 # seconds
_AUTH_FAIL_HITS_HARD_CAP = 1024
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
_auth_fail_hits: dict[str, list[float]] = defaultdict(list)
_auth_fail_lock = threading.Lock()
# integration_id -> runtime stats
_integration_stats: dict[str, dict[str, Any]] = {}
def _rate_limit_key(request: Request) -> str:
"""Pick a stable client identifier for rate-limiting.
When the immediate peer is loopback (assumed reverse-proxy), use the
first ``X-Forwarded-For`` entry; otherwise use the peer's IP.
"""
peer = request.client.host if request.client else "unknown"
if peer in _LOOPBACK_HOSTS:
xff = request.headers.get("x-forwarded-for", "")
if xff:
return xff.split(",", 1)[0].strip() or peer
return peer
def _check_auth_fail_rate_limit(client_ip: str) -> None:
"""Raise 429 if *client_ip* exceeded the failed-auth attempt limit."""
now = time.time()
window_start = now - _AUTH_FAIL_WINDOW
with _auth_fail_lock:
timestamps = [t for t in _auth_fail_hits[client_ip] if t > window_start]
_auth_fail_hits[client_ip] = timestamps
if len(timestamps) >= _AUTH_FAIL_LIMIT:
raise HTTPException(
status_code=429,
detail="Too many failed authentication attempts. Try again later.",
)
def _record_auth_failure(client_ip: str) -> None:
"""Record a failed-auth attempt for *client_ip* (bounded memory)."""
now = time.time()
window_start = now - _AUTH_FAIL_WINDOW
with _auth_fail_lock:
_auth_fail_hits[client_ip].append(now)
# Periodic cleanup of stale IPs to prevent unbounded growth.
if len(_auth_fail_hits) > 100:
stale = [ip for ip, ts in _auth_fail_hits.items() if not ts or ts[-1] < window_start]
for ip in stale:
del _auth_fail_hits[ip]
# Hard cap against an attacker spraying many distinct X-Forwarded-For
# values; drop the oldest-touched IPs.
if len(_auth_fail_hits) > _AUTH_FAIL_HITS_HARD_CAP:
ordered = sorted(
_auth_fail_hits.items(),
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
)
for ip, _ in ordered[: len(ordered) - _AUTH_FAIL_HITS_HARD_CAP]:
_auth_fail_hits.pop(ip, None)
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
@@ -82,59 +153,47 @@ def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
return fields
def _get_prev_state(integration_id: str) -> dict[str, Any]:
"""Get or create the prev_state dict for an integration."""
with _integration_state_lock:
if integration_id not in _prev_states:
_prev_states[integration_id] = {}
return _prev_states[integration_id]
def _set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
"""Update the prev_state dict for an integration."""
with _integration_state_lock:
_prev_states[integration_id] = state
def _record_events(integration_id: str, events: list[GameEvent]) -> None:
"""Record event stats for an integration."""
with _integration_state_lock:
if integration_id not in _integration_stats:
_integration_stats[integration_id] = {
"event_count": 0,
"event_counts_by_type": {},
"last_event_time": None,
}
stats = _integration_stats[integration_id]
for event in events:
stats["event_count"] += 1
stats["event_counts_by_type"][event.event_type] = (
stats["event_counts_by_type"].get(event.event_type, 0) + 1
)
stats["last_event_time"] = event.timestamp
def _get_stats(integration_id: str) -> dict[str, Any]:
"""Get runtime stats for an integration."""
with _integration_state_lock:
return _integration_stats.get(
integration_id,
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
)
def _cleanup_state(integration_id: str) -> None:
"""Remove runtime state for a deleted integration."""
with _integration_state_lock:
_prev_states.pop(integration_id, None)
_integration_stats.pop(integration_id, None)
# ── Helper: convert config to response ────────────────────────────────────
def _redact_secrets(adapter_config: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of *adapter_config* with secret values masked.
The adapter ``auth_token`` is a live shared secret (it authenticates the
ingest endpoint). It is encrypted at rest, but the response builder echoes
the in-memory *decrypted* config, so without masking any API caller
(loopback-anonymous by default) could read the cleartext token. We never
return the secret over the API — the edit form submits a blank value to
keep the existing secret (see ``_merge_preserved_secrets``).
"""
cfg = dict(adapter_config)
for key in _SECRET_CONFIG_KEYS:
if cfg.get(key):
cfg[key] = "" # mask — never echo the secret to the client
return cfg
def _merge_preserved_secrets(
incoming: dict[str, Any] | None, existing: Any
) -> dict[str, Any] | None:
"""Preserve a stored secret when an update submits a blank/absent one.
Because the API masks secrets in responses, the edit form re-submits a
blank value for an unchanged secret. Without this merge that blank would
overwrite (and destroy) the stored token. A non-empty incoming value is a
deliberate change and is kept as-is.
"""
if incoming is None:
return None
merged = dict(incoming)
for key in _SECRET_CONFIG_KEYS:
if not merged.get(key) and existing.adapter_config.get(key):
merged[key] = existing.adapter_config[key]
return merged
def _config_to_response(config: Any) -> GameIntegrationResponse:
"""Convert a GameIntegrationConfig to its API response."""
"""Convert a GameIntegrationConfig to its API response (secrets redacted)."""
from ledgrab.api.schemas.game_integration import EventMappingSchema
return GameIntegrationResponse(
@@ -142,7 +201,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
name=config.name,
adapter_type=config.adapter_type,
enabled=config.enabled,
adapter_config=config.adapter_config,
adapter_config=_redact_secrets(config.adapter_config),
event_mappings=[
EventMappingSchema(
event_type=m.event_type,
@@ -234,6 +293,7 @@ async def create_integration(
data: GameIntegrationCreate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Create a new game integration config."""
try:
@@ -262,6 +322,8 @@ async def create_integration(
)
fire_entity_event("game_integration", "created", config.id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
return _config_to_response(config)
except EntityNotFoundError as e:
@@ -301,6 +363,7 @@ async def update_integration(
data: GameIntegrationUpdate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Update a game integration config."""
try:
@@ -318,12 +381,20 @@ async def update_integration(
for m in data.event_mappings
]
# Preserve a stored secret when the update submits a blank token
# (the API masks secrets, so the edit form re-sends a blank value
# for an unchanged secret — see _merge_preserved_secrets).
adapter_config = data.adapter_config
if adapter_config is not None:
existing = store.get_integration(integration_id)
adapter_config = _merge_preserved_secrets(adapter_config, existing)
config = store.update_integration(
integration_id=integration_id,
name=data.name,
adapter_type=data.adapter_type,
enabled=data.enabled,
adapter_config=data.adapter_config,
adapter_config=adapter_config,
event_mappings=mappings,
description=data.description,
tags=data.tags,
@@ -332,6 +403,8 @@ async def update_integration(
)
fire_entity_event("game_integration", "updated", integration_id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
return _config_to_response(config)
except EntityNotFoundError as e:
@@ -352,11 +425,14 @@ async def delete_integration(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Delete a game integration config."""
try:
store.delete_integration(integration_id)
_cleanup_state(integration_id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
fire_entity_event("game_integration", "deleted", integration_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -387,7 +463,16 @@ async def ingest_event(
called before standard API auth.
No AuthRequired dependency — adapter-level auth is used instead.
Rate limiting is scoped to FAILED-auth attempts per source IP (see
``_check_auth_fail_rate_limit``) so legitimate high-rate ingestion is
never throttled, but a brute-forcer is locked out after the threshold.
"""
client_ip = _rate_limit_key(request)
# Block IPs that have already burned through the failed-auth budget,
# before doing any work (cheap brute-force lockout).
_check_auth_fail_rate_limit(client_ip)
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
@@ -402,9 +487,18 @@ async def ingest_event(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Adapter-level auth check
# Adapter-level auth check. Treat ANY exception from validate_auth as an
# auth failure (rate-limited + 403), never a 500 — a malformed/attacker-
# controlled token must not crash the handler nor bypass the brute-force
# lockout counter.
headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
try:
authed = adapter_cls.validate_auth(headers, payload.data, config.adapter_config)
except Exception as exc:
logger.warning("validate_auth raised for %s: %s", integration_id, exc)
authed = False
if not authed:
_record_auth_failure(client_ip)
raise HTTPException(status_code=403, detail="Adapter authentication failed")
# Parse payload through adapter
+7 -1
View File
@@ -152,13 +152,19 @@ async def delete_gradient(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete a gradient (fails if built-in or referenced by sources)."""
_entity_name: str | None = None
try:
_entity_name = store.get_gradient(gradient_id).name
except Exception:
pass
try:
# Check references
for source in css_store.get_all_sources():
if getattr(source, "gradient_id", None) == gradient_id:
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
store.delete_gradient(gradient_id)
fire_entity_event("gradient", "deleted", gradient_id)
fire_entity_event("gradient", "deleted", gradient_id, entity_name=_entity_name)
except (ValueError, EntityNotFoundError) as e:
status = 404 if "not found" in str(e).lower() else 400
raise HTTPException(status_code=status, detail=str(e))
@@ -2,6 +2,7 @@
import asyncio
import json
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -28,6 +29,7 @@ from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.home_assistant_source import HomeAssistantSource
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import validate_lan_host
logger = get_logger(__name__)
@@ -37,6 +39,23 @@ router = APIRouter()
_REDACTED_TOKEN = "***"
def _validate_ha_host(host: str | None) -> None:
"""Reject literal public/link-local/metadata IPs for a HA source host.
HA sources are LAN-by-design (loopback + private ranges allowed), so we
gate the user-supplied ``host`` with the same shared classifier the LED
device providers use (``validate_lan_host``). The HA host is stored as
``host:port`` (e.g. ``192.168.1.100:8123``), so strip the port first via
``urlparse`` — which also handles bracketed IPv6 literals. Hostnames /
mDNS labels pass through (classified UNPARSEABLE). Raises ``ValueError``
on a literal public IP, which the callers translate to HTTP 400.
"""
if not host:
return
bare_host = urlparse(f"//{host.strip()}").hostname or host.strip()
validate_lan_host(bare_host)
def _to_response(
source: HomeAssistantSource,
manager: HomeAssistantManager,
@@ -99,6 +118,7 @@ async def create_ha_source(
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
_validate_ha_host(data.host)
source = store.create_source(
name=data.name,
host=data.host,
@@ -153,6 +173,7 @@ async def update_ha_source(
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
_validate_ha_host(data.host)
source = store.update_source(
source_id,
name=data.name,
+13
View File
@@ -42,6 +42,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
password_set=bool(source.password),
client_id=source.client_id,
base_topic=source.base_topic,
publish_ha_discovery=getattr(source, "publish_ha_discovery", False),
discovery_prefix=getattr(source, "discovery_prefix", "homeassistant"),
connected=runtime.is_connected if runtime else False,
description=source.description,
tags=source.tags,
@@ -90,6 +92,8 @@ async def create_mqtt_source(
password=data.password,
client_id=data.client_id,
base_topic=data.base_topic,
publish_ha_discovery=data.publish_ha_discovery,
discovery_prefix=data.discovery_prefix,
description=data.description,
tags=data.tags,
icon=data.icon,
@@ -97,6 +101,8 @@ async def create_mqtt_source(
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Publish HA discovery if the new source opted in.
await manager.sync_discovery(source.id)
fire_entity_event("mqtt_source", "created", source.id)
return _to_response(source, manager)
@@ -141,6 +147,8 @@ async def update_mqtt_source(
password=data.password,
client_id=data.client_id,
base_topic=data.base_topic,
publish_ha_discovery=data.publish_ha_discovery,
discovery_prefix=data.discovery_prefix,
description=data.description,
tags=data.tags,
icon=data.icon,
@@ -151,6 +159,8 @@ async def update_mqtt_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await manager.update_source(source_id)
# Reconcile HA discovery (publish if enabled, clear if turned off).
await manager.sync_discovery(source_id)
fire_entity_event("mqtt_source", "updated", source.id)
return _to_response(source, manager)
@@ -162,6 +172,9 @@ async def delete_mqtt_source(
store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager),
):
# Clear any HA discovery configs (needs the source still present to build
# the exact retained topics) before deleting the row.
await manager.disable_discovery(source_id)
try:
store.delete_source(source_id)
except EntityNotFoundError:
@@ -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:
@@ -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_playlist_engine,
@@ -220,13 +221,19 @@ async def delete_scene_playlist(
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)
fire_entity_event("scene_playlist", "deleted", playlist_id, entity_name=_entity_name)
# ===== Cycling control =====
@@ -255,6 +262,28 @@ async def start_scene_playlist(
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())
@@ -265,11 +294,35 @@ async def start_scene_playlist(
)
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)
+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))
@@ -21,11 +21,29 @@ from ledgrab.api.schemas.system import (
ShutdownActionRequest,
ShutdownActionResponse,
)
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()
@@ -117,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)
@@ -246,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:
@@ -276,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)",
)
+73 -1
View File
@@ -36,7 +36,29 @@ class RuleSchema(BaseModel):
)
timezone: str | None = Field(
None,
description="IANA timezone for time_of_day rule (e.g. 'Europe/Berlin'). Empty = server local.",
description=(
"IANA timezone for time_of_day / solar rules (e.g. 'Europe/Berlin'). "
"Empty = server local."
),
)
# Solar rule fields (days_of_week / timezone above are shared with time_of_day)
start_event: str | None = Field(
None, description="'sunrise' or 'sunset' — window start anchor (for solar rule)"
)
start_offset_minutes: int | None = Field(
None, description="Minutes added to the start event, ±1439 (for solar rule)"
)
end_event: str | None = Field(
None, description="'sunrise' or 'sunset' — window end anchor (for solar rule)"
)
end_offset_minutes: int | None = Field(
None, description="Minutes added to the end event, ±1439 (for solar rule)"
)
latitude: float | None = Field(
None, description="Latitude for solar timing, -90..90 (for solar rule)"
)
longitude: float | None = Field(
None, description="Longitude for solar timing, -180..180 (for solar rule)"
)
# System idle rule fields
idle_minutes: int | None = Field(
@@ -86,6 +108,37 @@ class RuleSchema(BaseModel):
ConditionSchema = RuleSchema
class ActionSchema(BaseModel):
"""A single outbound action fired alongside scene activation/deactivation."""
action_type: str = Field(
max_length=32, description="Action type discriminator (e.g. 'webhook')"
)
# Webhook action fields
webhook_url: str | None = Field(
None, max_length=2048, description="Target URL for the webhook action"
)
method: str | None = Field(
None, max_length=8, description="'POST', 'PUT', or 'GET' (for webhook action)"
)
body_template: str | None = Field(
None,
max_length=8192,
description=(
"Request body template (for webhook action). Tokens: {{automation_name}}, "
"{{automation_id}}, {{event}}, {{timestamp}}."
),
)
content_type: str | None = Field(
None,
max_length=128,
description="Content-Type header for the webhook body (default application/json)",
)
fire_on: str | None = Field(
None, max_length=16, description="'activate', 'deactivate', or 'both' (for webhook action)"
)
class AutomationCreate(BaseModel):
"""Request to create an automation."""
@@ -101,6 +154,9 @@ class AutomationCreate(BaseModel):
None, description="Scene preset for fallback deactivation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
actions: List[ActionSchema] = Field(
default_factory=list, description="Outbound actions (e.g. webhooks)"
)
icon: str | None = Field(
None,
max_length=64,
@@ -126,6 +182,7 @@ class AutomationUpdate(BaseModel):
None, description="Scene preset for fallback deactivation"
)
tags: List[str] | None = None
actions: List[ActionSchema] | None = Field(None, description="Outbound actions (e.g. webhooks)")
icon: str | None = Field(
None,
max_length=64,
@@ -150,6 +207,9 @@ class AutomationResponse(BaseModel):
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
actions: List[ActionSchema] = Field(
default_factory=list, description="Outbound actions (e.g. webhooks)"
)
webhook_url: str | None = Field(
None, description="Webhook URL for the first webhook rule (if any)"
)
@@ -179,3 +239,15 @@ class AutomationListResponse(BaseModel):
automations: List[AutomationResponse] = Field(description="List of automations")
count: int = Field(description="Number of automations")
class AutomationTriggerResponse(BaseModel):
"""Result of manually triggering an automation."""
status: str = Field(
description="'triggered' (scene applied / nothing to apply), 'partial' "
"(applied with errors), 'skipped' (rules not satisfied), or 'error'."
)
errors: List[str] = Field(
default_factory=list, description="Per-target error messages, if any."
)
@@ -145,6 +145,10 @@ class EffectCSSResponse(_CSSResponseBase):
scale: Any = Field(description="Spatial scale")
mirror: bool = Field(description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool = Field(False, description="Modulate output by live audio loudness")
reactive_audio_source_id: str = Field("", description="AudioSource id driving reactivity")
reactive_mode: str = Field("brightness", description="brightness | saturation | both")
reactive_intensity: Any = Field(None, description="Reactive modulation strength (0-1)")
class CompositeCSSResponse(_CSSResponseBase):
@@ -332,6 +336,12 @@ class EffectCSSCreate(_CSSCreateBase):
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
reactive_mode: Literal["brightness", "saturation", "both"] | None = Field(
None, description="brightness | saturation | both"
)
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
class CompositeCSSCreate(_CSSCreateBase):
@@ -532,6 +542,12 @@ class EffectCSSUpdate(_CSSUpdateBase):
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
reactive_mode: Literal["brightness", "saturation", "both"] | None = Field(
None, description="brightness | saturation | both"
)
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
class CompositeCSSUpdate(_CSSUpdateBase):
+40
View File
@@ -59,6 +59,10 @@ class DeviceCreate(BaseModel):
hue_entertainment_group_id: str | None = Field(
None, description="Hue entertainment group/zone ID"
)
hue_gradient_mode: bool | None = Field(
None,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
# Yeelight fields
yeelight_min_interval_ms: int | None = Field(
None,
@@ -80,6 +84,10 @@ class DeviceCreate(BaseModel):
le=10000,
description="LIFX client-side rate limit between commands in ms (default 50)",
)
lifx_per_zone: bool | None = Field(
None,
description="Stream individual zones/tiles (multizone Z/Beam, Tile/Canvas) vs single colour",
)
# Govee fields
govee_min_interval_ms: int | None = Field(
None,
@@ -106,6 +114,9 @@ class DeviceCreate(BaseModel):
le=10000,
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
)
nanoleaf_per_panel: bool | None = Field(
None, description="Stream each panel individually via extControl UDP (vs single colour)"
)
# SPI Direct fields
spi_speed_hz: int | None = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
@@ -195,6 +206,10 @@ class DeviceUpdate(BaseModel):
hue_username: str | None = Field(None, description="Hue bridge username")
hue_client_key: str | None = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
hue_gradient_mode: bool | None = Field(
None,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
yeelight_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
)
@@ -204,6 +219,9 @@ class DeviceUpdate(BaseModel):
lifx_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
)
lifx_per_zone: bool | None = Field(
None, description="Stream individual zones/tiles (multizone/matrix) vs single colour"
)
govee_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
)
@@ -212,6 +230,9 @@ class DeviceUpdate(BaseModel):
nanoleaf_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
)
nanoleaf_per_panel: bool | None = Field(
None, description="Stream each panel individually via extControl UDP"
)
spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: str | None = Field(None, description="LED chipset type")
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
@@ -356,6 +377,14 @@ class Calibration(BaseModel):
roi_height: float = Field(
default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)"
)
linear_blend: bool = Field(
default=False,
description="Blend border pixels in linear light instead of sRGB (perceptually correct)",
)
dither: bool = Field(
default=False,
description="Spatio-temporally dither the final 8-bit output to reduce gradient banding",
)
class CalibrationTestModeRequest(BaseModel):
@@ -428,11 +457,19 @@ class DeviceResponse(BaseModel):
),
)
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
hue_gradient_mode: bool = Field(
default=True,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
yeelight_min_interval_ms: int = Field(
default=500, description="Yeelight client-side rate limit in ms"
)
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
lifx_per_zone: bool = Field(
default=False,
description="Stream individual zones/tiles (multizone/matrix) vs single colour",
)
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
nanoleaf_paired: bool = Field(
@@ -446,6 +483,9 @@ class DeviceResponse(BaseModel):
nanoleaf_min_interval_ms: int = Field(
default=100, description="Nanoleaf client-side rate limit in ms"
)
nanoleaf_per_panel: bool = Field(
default=False, description="Stream each panel individually via extControl UDP"
)
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
+20
View File
@@ -16,6 +16,15 @@ class MQTTSourceCreate(BaseModel):
password: str = Field(default="", description="Broker password (optional)")
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
publish_ha_discovery: bool = Field(
default=False, description="Publish Home Assistant MQTT auto-discovery configs"
)
discovery_prefix: str = Field(
default="homeassistant",
max_length=64,
pattern=r"^[A-Za-z0-9_\-/]+$",
description="HA MQTT discovery prefix (default 'homeassistant')",
)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: str | None = Field(
@@ -40,6 +49,15 @@ class MQTTSourceUpdate(BaseModel):
password: str | None = Field(None, description="Broker password")
client_id: str | None = Field(None, description="MQTT client ID")
base_topic: str | None = Field(None, description="Base topic prefix")
publish_ha_discovery: bool | None = Field(
None, description="Publish Home Assistant MQTT auto-discovery configs"
)
discovery_prefix: str | None = Field(
None,
max_length=64,
pattern=r"^[A-Za-z0-9_\-/]+$",
description="HA MQTT discovery prefix",
)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
@@ -65,6 +83,8 @@ class MQTTSourceResponse(BaseModel):
password_set: bool = Field(default=False, description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
publish_ha_discovery: bool = Field(default=False, description="HA MQTT discovery enabled")
discovery_prefix: str = Field(default="homeassistant", description="HA MQTT discovery prefix")
connected: bool = Field(default=False, description="Whether the broker connection is active")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
+6
View File
@@ -68,6 +68,12 @@ class AuthConfig(BaseSettings):
"""Authentication configuration."""
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
# When True, the OpenAPI docs routes (/docs, /redoc, /openapi.json) load
# WITHOUT a Bearer token from any client (loopback and LAN). This exposes
# the API *surface* (route paths + parameter schemas), not data — actually
# invoking an endpoint from Swagger still requires the token via its
# "Authorize" button. All other endpoints stay protected. Default off.
expose_docs: bool = False
class AssetsConfig(BaseSettings):
@@ -0,0 +1,25 @@
"""Activity / audit log core — recorder, retention engine, and actor context.
Public surface
--------------
``ActivityRecorder`` — thread-safe facade; persists entries and fires live events.
``ActivityLogRetentionEngine`` — background pruning engine (mirrors AutoBackupEngine).
``current_actor`` — ``ContextVar[str]`` set by the auth layer per request.
Quick start
-----------
Wired in ``main.py`` lifespan; injected via ``api/dependencies.py`` getters.
Phase 3 adds the instrumentation call sites.
"""
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
__all__ = [
"ActivityRecorder",
"ActivityLogRetentionEngine",
"current_actor",
"sanitize_display",
]
@@ -0,0 +1,34 @@
"""Actor context variable for the activity log.
``current_actor`` is set by ``api/auth.py:verify_api_key`` so that
``ActivityRecorder.record(...)`` can resolve the actor without requiring every
call site to pass it explicitly.
Default value is ``"system"`` — used by background engines and any code path
that runs outside a request context (e.g. lifespan startup/shutdown, zeroconf
discovery thread).
Per-request isolation is provided by ASGI/anyio ContextVar copy semantics:
Starlette dispatches each request in its own task whose context is a copy of
the parent, so a ``current_actor.set(...)`` in one request is never visible to
another request, and each request starts from the ``"system"`` default.
The auth layer only *sets* (never resets) the actor: ``verify_api_key`` calls
``current_actor.set(...)`` on the authenticated path and on the loopback-
anonymous path. It is an ``async`` dependency on purpose — an async dependency
runs in the same task/context as the route handler, so the ``set`` is visible
to ``record(...)`` (a sync dependency would set it in a throwaway threadpool
context that the handler never sees). Routes without the ``verify_api_key``
dependency (e.g. the unauthenticated ``POST /api/v1/webhooks/{token}``) never
set it and therefore record as ``"system"``.
There is intentionally no explicit per-request reset — do not rely on one. If
you run a recorder call in a worker thread that inherited a parent request's
context, pass an explicit ``actor=`` to ``record(...)`` rather than trusting
the ContextVar default.
"""
from contextvars import ContextVar
#: The actor label for the current request — API-key label or ``"system"``.
current_actor: ContextVar[str] = ContextVar("current_actor", default="system")
@@ -0,0 +1,273 @@
"""Thread-safe ActivityRecorder facade.
Responsibilities
----------------
1. Build an ``ActivityLogEntry`` from the caller-supplied fields.
2. Resolve the ``actor`` from the ``current_actor`` ContextVar when not given.
3. Persist the entry via ``ActivityLogRepository.record()`` on the event-loop
thread — inline if already on that thread, via
``loop.call_soon_threadsafe`` if called from another thread (e.g. the
zeroconf discovery thread that fires ``device_discovered/lost`` events).
4. Push a live ``activity_logged`` event via
``ProcessorManager.fire_event({"type": "activity_logged", "entry": {...}})``.
5. Never raise into the caller — audit recording is best-effort. Failures are
logged at ``WARNING`` level so operators can diagnose without breaking the
audited action.
Thread-marshal pattern mirrors ``utils/log_broadcaster.py`` (``ensure_loop`` /
``call_soon_threadsafe``).
Module accessor
---------------
A module-level singleton ``_recorder`` is populated by
``set_module_recorder()`` during ``main.py`` lifespan startup and exposed via
``get_module_recorder()``. Background engines and other non-DI sites that need
to call ``record()`` without FastAPI DI can use this accessor. Phase 3
instrumentation uses it at the ``fire_entity_event`` choke-point.
"""
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from ledgrab.core.activity_log.context import current_actor
from ledgrab.storage.activity_log import ActivityLogEntry, ActivitySeverity
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage.activity_log_repository import ActivityLogRepository
logger = get_logger(__name__)
def _new_id() -> str:
"""Generate an activity-log entry id: ``al_<32-hex-chars>``.
Uses the full 128-bit uuid4 hex. The ``id`` column is ``UNIQUE`` and a
collision is silently dropped (best-effort recorder), so the entropy must
be high enough that a collision is astronomically unlikely even against the
full retention window (default 20k live rows).
"""
return "al_" + uuid.uuid4().hex
def entry_to_dict(entry: ActivityLogEntry) -> dict:
"""Serialise an ``ActivityLogEntry`` to the canonical API/event payload dict.
Reused by Phase 4 (API response serialisation) and Phase 5 (frontend).
The shape is identical to the flat row codec minus the DB-only ``seq``
field, but with ``ts`` kept as an ISO-8601 string and ``metadata`` as a
real ``dict`` (not a JSON string).
"""
return {
"id": entry.id,
"ts": entry.ts.isoformat(),
"category": entry.category,
"action": entry.action,
"severity": entry.severity,
"actor": entry.actor,
"entity_type": entry.entity_type,
"entity_id": entry.entity_id,
"entity_name": entry.entity_name,
"message": entry.message,
"metadata": entry.metadata,
}
class ActivityRecorder:
"""Thread-safe facade for persisting audit log entries.
Parameters
----------
repo:
``ActivityLogRepository`` used to persist entries.
processor_manager:
``ProcessorManager`` whose ``fire_event`` dispatches the live
``activity_logged`` event to WebSocket subscribers.
loop:
Optional: the running asyncio event loop. If ``None``, it is
captured lazily on the first call that originates from an async
context (mirroring ``LogBroadcaster.ensure_loop``). Pass it
explicitly in tests to avoid depending on a real running loop.
"""
def __init__(
self,
repo: "ActivityLogRepository",
processor_manager: "ProcessorManager",
*,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
self._repo = repo
self._pm = processor_manager
self._loop: asyncio.AbstractEventLoop | None = loop
self._enabled: bool = True
# ── Loop capture (mirrors LogBroadcaster.ensure_loop) ──────────────────
def ensure_loop(self) -> None:
"""Capture the running event loop if not already stored.
Call from an async context (e.g. lifespan startup) so that
``call_soon_threadsafe`` works when ``record()`` is later called from
non-async threads.
"""
if self._loop is None:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
pass
# ── Public API ──────────────────────────────────────────────────────────
@property
def enabled(self) -> bool:
"""Whether recording is currently active."""
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
self._enabled = value
def record(
self,
category: str,
action: str,
*,
severity: str = ActivitySeverity.INFO,
actor: str | None = None,
entity_type: str | None = None,
entity_id: str | None = None,
entity_name: str | None = None,
message: str,
metadata: dict | None = None,
_bypass_enabled: bool = False,
) -> None:
"""Append one audit entry — best-effort, never raises.
Parameters
----------
category:
Broad bucket — one of :class:`~ledgrab.storage.activity_log.ActivityCategory`.
action:
Verb-object label, e.g. ``"entity.created"`` or ``"server.shutting_down"``.
severity:
One of :class:`~ledgrab.storage.activity_log.ActivitySeverity`. Defaults
to ``"info"``.
actor:
Who triggered the action. When ``None`` (the common case), the
value is resolved from :data:`~ledgrab.core.activity_log.context.current_actor`
with a default of ``"system"``.
entity_type / entity_id / entity_name:
Optional entity context for entity-domain events.
message:
Human-readable description suitable for display.
metadata:
Small JSON-serialisable dict with extra context. Defaults to ``{}``.
_bypass_enabled:
Internal flag used by the retention engine to record the
"audit log disabled" event even when ``enabled`` is ``False``.
"""
if not self._enabled and not _bypass_enabled:
return
# Resolve actor from ContextVar when not explicitly supplied.
resolved_actor = actor if actor is not None else current_actor.get()
entry = ActivityLogEntry(
id=_new_id(),
ts=datetime.now(timezone.utc),
category=category,
action=action,
severity=severity,
actor=resolved_actor,
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
message=message,
metadata=metadata or {},
)
# Determine whether we are on the event-loop thread or not.
loop = self._loop
if loop is None:
# Lazy capture — may fail if called before the loop is running.
try:
loop = asyncio.get_running_loop()
self._loop = loop
except RuntimeError:
pass
if loop is not None and loop.is_running():
try:
current = asyncio.get_event_loop()
except RuntimeError:
current = None
# If the current thread IS the event-loop thread, write inline.
if current is loop:
self._write_and_emit(entry)
else:
# Called from a non-loop thread (e.g. zeroconf discovery) —
# marshal onto the event-loop thread.
try:
loop.call_soon_threadsafe(self._write_and_emit, entry)
except RuntimeError:
# Loop has been closed (rare; happens during tests)
logger.warning(
"ActivityRecorder: event loop closed, dropping entry %s", entry.id
)
else:
# No running loop — fall back to a direct synchronous write.
# This path hits in synchronous unit tests that do not start a loop.
self._write_and_emit(entry)
def _write_and_emit(self, entry: ActivityLogEntry) -> None:
"""Persist *entry* and fire the live event — called on the loop thread."""
try:
self._repo.record(entry)
except Exception as exc:
logger.warning("ActivityRecorder: failed to persist entry %s: %s", entry.id, exc)
return # don't emit an event for an entry that failed to persist
try:
self._pm.fire_event(
{
"type": "activity_logged",
"entry": entry_to_dict(entry),
}
)
except Exception as exc:
logger.warning("ActivityRecorder: failed to fire live event for %s: %s", entry.id, exc)
# ── Module-level singleton accessor ────────────────────────────────────────
#
# Background engines and non-DI call sites (Phase 3's fire_entity_event hook,
# device discovery thread) need ``record()`` without going through FastAPI DI.
# ``set_module_recorder`` is called from ``main.py`` lifespan immediately after
# the recorder is wired into ``init_dependencies``.
_recorder: ActivityRecorder | None = None
def set_module_recorder(recorder: ActivityRecorder) -> None:
"""Store the application-level recorder in the module singleton.
Called once from ``main.py`` lifespan startup.
"""
global _recorder
_recorder = recorder
def get_module_recorder() -> ActivityRecorder | None:
"""Return the module-level recorder, or ``None`` if not yet initialised.
Callers must guard against ``None`` — this returns ``None`` during module
import and early startup before ``main.py`` lifespan has run.
"""
return _recorder
@@ -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,90 @@
"""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. Guard the degenerate maxlen cases: ``cleaned[: maxlen - 1]``
# with maxlen <= 0 would slice from the END (keeping all-but-last char or
# a negative-index tail), violating the bounded-length contract.
if maxlen <= 0:
return ""
if len(cleaned) > maxlen:
if maxlen == 1:
# No room for content + ellipsis; emit the ellipsis alone.
cleaned = ""
else:
# Reserve one character for the ellipsis so total length == maxlen.
cleaned = cleaned[: maxlen - 1] + ""
return cleaned
@@ -13,8 +13,10 @@ from ledgrab.storage.automation import (
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
ManualTriggerRule,
MQTTRule,
Rule,
SolarRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
@@ -23,6 +25,7 @@ from ledgrab.storage.automation import (
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset import ScenePreset
from ledgrab.utils import get_logger
from ledgrab.utils.solar import compute_solar_times, utc_offset_hours_for
logger = get_logger(__name__)
@@ -141,6 +144,11 @@ class AutomationEngine:
self._last_deactivated: Dict[str, datetime] = {}
# webhook_token → bool (volatile state set by webhook calls)
self._webhook_states: Dict[str, bool] = {}
# True only while a single automation is being manually fired
# (fire_manual_trigger). The background tick never sets it, so a
# ManualTriggerRule reads False during normal evaluation and a
# manual-trigger automation never activates on its own.
self._manual_fire_active: bool = False
# HA source IDs currently acquired by the engine
self._ha_acquired: Set[str] = set()
# MQTT source IDs currently acquired by the engine
@@ -369,6 +377,32 @@ class AutomationEngine:
display_state,
)
@staticmethod
def _detection_needs(rules) -> tuple[bool, bool, bool, bool, bool]:
"""Which platform-detection probes a set of rules requires.
Returns ``(needs_running, needs_topmost, needs_fullscreen, needs_idle,
needs_display_state)``. Shared by the background evaluation tick and the
one-shot manual-trigger path so both request the same detection set.
"""
match_types_used: set = set()
needs_idle = False
needs_display_state = False
for r in rules:
if isinstance(r, ApplicationRule):
match_types_used.add(r.match_type)
elif isinstance(r, SystemIdleRule):
needs_idle = True
elif isinstance(r, DisplayStateRule):
needs_display_state = True
return (
"running" in match_types_used,
bool(match_types_used & {"topmost", "topmost_fullscreen"}),
"fullscreen" in match_types_used,
needs_idle,
needs_display_state,
)
async def _evaluate_all_locked(self) -> None:
automations = self._store.get_all_automations()
if not automations:
@@ -377,23 +411,15 @@ class AutomationEngine:
await self._deactivate_automation(aid)
return
# Determine which detection methods are actually needed
match_types_used: set = set()
needs_idle = False
needs_display_state = False
for a in automations:
if a.enabled:
for r in a.rules:
if isinstance(r, ApplicationRule):
match_types_used.add(r.match_type)
elif isinstance(r, SystemIdleRule):
needs_idle = True
elif isinstance(r, DisplayStateRule):
needs_display_state = True
needs_running = "running" in match_types_used
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
needs_fullscreen = "fullscreen" in match_types_used
# Determine which detection methods are actually needed (across the
# rules of every *enabled* automation — disabled ones are skipped below).
(
needs_running,
needs_topmost,
needs_fullscreen,
needs_idle,
needs_display_state,
) = self._detection_needs([r for a in automations if a.enabled for r in a.rules])
# Single executor call for all platform detection
(
@@ -526,6 +552,9 @@ class AutomationEngine:
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_time_of_day(rule)
def _handle_solar(self, rule: SolarRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_solar(rule)
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_idle(rule, ctx.idle_seconds)
@@ -538,12 +567,40 @@ class AutomationEngine:
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
return self._webhook_states.get(rule.token, False)
def _handle_manual(self, rule: ManualTriggerRule, ctx: _RuleEvalContext) -> bool:
# True only while fire_manual_trigger is evaluating this one automation
# under the eval lock; always False during the background tick.
return self._manual_fire_active
def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_home_assistant(rule)
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_http_poll(rule)
@staticmethod
def _weekday_window_active(
current: int, start: int, end: int, weekday: int, days: list
) -> bool:
"""Is ``current`` (minutes-of-day) inside the [start, end] window?
Handles the overnight wrap (start > end): the after-midnight tail
belongs to the window's START day, so it's matched against the
previous weekday. ``days`` empty = every day of the week.
"""
if start <= end:
if not (start <= current <= end):
return False
return not days or 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 weekday in days
if current <= end: # early-morning portion — yesterday's window
return not days or ((weekday - 1) % 7) in days
return False
@staticmethod
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
now = _now_in_tz(rule.timezone)
@@ -552,20 +609,34 @@ class AutomationEngine:
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
return AutomationEngine._weekday_window_active(
current, start, end, now.weekday(), rule.days_of_week
)
if start <= end:
if not (start <= current <= end):
return False
return not days or now.weekday() in days
@staticmethod
def _evaluate_solar(rule: SolarRule) -> bool:
# One ``now`` drives every read: day-of-year, the UTC offset for the
# solar math, the current-minute compare, and the weekday.
now = _now_in_tz(rule.timezone)
day_of_year = now.timetuple().tm_yday
utc_offset = utc_offset_hours_for(rule.timezone, now)
sunrise_h, sunset_h = compute_solar_times(
rule.latitude, rule.longitude, day_of_year, utc_offset
)
# 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
def _event_minutes(event: str) -> int:
hour = sunset_h if event == "sunset" else sunrise_h
return int(round(hour * 60))
# compute_solar_times clamps sunrise < sunset, so the only way to wrap
# past midnight is via the offsets — which ``_weekday_window_active``
# handles the same way it does an overnight time-of-day window.
start = (_event_minutes(rule.start_event) + rule.start_offset_minutes) % 1440
end = (_event_minutes(rule.end_event) + rule.end_offset_minutes) % 1440
current = now.hour * 60 + now.minute
return AutomationEngine._weekday_window_active(
current, start, end, now.weekday(), rule.days_of_week
)
@staticmethod
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
@@ -675,6 +746,62 @@ class AutomationEngine:
# Default: "running"
return any(app in running_procs for app in apps_lower)
def _audit_activation(self, automation: Automation) -> None:
"""Best-effort audit record for any successful automation activation.
Shared by both the normal scene path and the no-scene branch so an
activation is recorded uniformly regardless of whether a scene was
applied (mirrors the uniform recording on the deactivation side).
"""
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
def _audit_manual_trigger(self, automation: Automation) -> None:
"""Best-effort audit record for a manual trigger.
Unlike :meth:`_audit_activation` this does NOT force ``actor='system'``
— the recorder resolves ``actor`` from the ``current_actor`` ContextVar
(set in ``verify_api_key``), so the run is attributed to the user who
pressed the button.
"""
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.triggered",
severity=ActivitySeverity.INFO,
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' manually triggered",
)
except Exception:
pass
async def _activate_automation(self, automation: Automation) -> None:
if not automation.scene_preset_id:
# No scene configured — just mark active (rules matched but nothing to do)
@@ -682,6 +809,11 @@ class AutomationEngine:
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "activated")
logger.info(f"Automation '{automation.name}' activated (no scene configured)")
# Record the activation too — a no-scene activation is still a
# successful activation and must appear in the audit log.
self._audit_activation(automation)
await self._fire_actions(automation, "activate")
await self._publish_mqtt_state(automation.id, True)
return
if not self._scene_preset_store or not self._target_store or not self._device_store:
@@ -726,6 +858,141 @@ class AutomationEngine:
else:
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
# Audit record — best-effort (shared helper, also used by no-scene path).
self._audit_activation(automation)
await self._fire_actions(automation, "activate")
await self._publish_mqtt_state(automation.id, True)
async def _fire_actions(self, automation: Automation, event: str) -> None:
"""Fire any outbound actions (e.g. webhooks) for this transition.
Best-effort and never raises into the activation path: a hung or
failing endpoint is logged/audited but must not stall the evaluation
loop or abort scene activation.
"""
actions = getattr(automation, "actions", None)
if not actions:
return
from ledgrab.storage.automation import WebhookAction
from ledgrab.core.automations.webhook_action import fire_webhook_action, should_fire
for action in actions:
if not isinstance(action, WebhookAction) or not should_fire(action, event):
continue
try:
ok, err = await fire_webhook_action(action, automation, event)
except Exception as exc: # noqa: BLE001 — defensive; fire is already best-effort
logger.warning(
"Action fire raised for '%s': %s", automation.name, type(exc).__name__
)
ok, err = False, type(exc).__name__
self._audit_webhook(automation, event, ok, err)
def _audit_webhook(self, automation: Automation, event: str, ok: bool, err: str | None) -> None:
"""Best-effort audit entry for a webhook fire (success or failure)."""
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 None:
return
safe_name = sanitize_display(automation.name) if automation.name else automation.id
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.webhook_fired",
severity=ActivitySeverity.INFO if ok else ActivitySeverity.WARNING,
actor="system",
entity_type="automation",
entity_id=automation.id,
entity_name=safe_name,
message=(
f"Webhook for '{safe_name}' {'fired' if ok else 'failed'} on {event}"
+ ("" if ok else f" ({sanitize_display(err) if err else 'error'})")
),
)
except Exception:
pass
async def _apply_manual_scene(self, automation: Automation) -> tuple[str, list[str]]:
"""Apply the automation's scene once for a manual trigger.
Mirrors the scene-application core of :meth:`_activate_automation` but
does NOT enter the sticky ``_active_automations`` state or capture a
revert snapshot — a manual trigger is a one-shot apply, so the
background tick has nothing to reconcile away. Returns
``(status, errors)`` where ``status`` is ``"triggered"`` (applied, or no
scene configured), ``"partial"`` (applied with errors), or ``"error"``
(scene stores unavailable / preset missing).
"""
if not automation.scene_preset_id:
return ("triggered", [])
if not self._scene_preset_store or not self._target_store or not self._device_store:
logger.warning(
f"Automation '{automation.name}' triggered but scene stores not available"
)
return ("error", ["scene stores not available"])
try:
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
except ValueError:
logger.warning(
f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found"
)
return ("error", [f"scene preset {automation.scene_preset_id} not found"])
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(
f"Automation '{automation.name}' manually triggered with errors: {errors}"
)
else:
logger.info(
f"Automation '{automation.name}' manually triggered (scene '{preset.name}' applied)"
)
# apply_scene_state returns "activated"/"partial"; surface "triggered"
# for the happy path so the API status reads naturally.
return ("triggered" if status == "activated" else status, errors)
async def fire_manual_trigger(self, automation: Automation) -> tuple[str, list[str]]:
"""Manually fire an automation: evaluate its rules with the manual
trigger satisfied and, if it should activate, apply its scene once.
"Checks all of the rules": the automation's full rule set is evaluated
under its ``rule_logic`` with the ManualTriggerRule treated as True. The
``enabled`` flag is intentionally ignored — it gates only the background
tick; a manual trigger is an explicit user action. Returns
``(status, errors)``: ``"skipped"`` when the rules are not satisfied,
otherwise the result of :meth:`_apply_manual_scene`.
"""
async with self._eval_lock:
detection = await asyncio.to_thread(
self._detect_all_sync, *self._detection_needs(automation.rules)
)
# Force the manual term True for this one evaluation, then clear it
# before releasing the lock so the background tick never sees it.
self._manual_fire_active = True
try:
should_fire = (not automation.rules) or self._evaluate_rules(automation, *detection)
finally:
self._manual_fire_active = False
if not should_fire:
logger.info(
f"Automation '{automation.name}' manual trigger skipped (rules not satisfied)"
)
return ("skipped", [])
status, errors = await self._apply_manual_scene(automation)
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "triggered")
self._audit_manual_trigger(automation)
return (status, errors)
async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_automations.pop(automation_id, False)
if not was_active:
@@ -751,6 +1018,47 @@ 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:
# Reuse the automation already fetched above (no second store
# read); degrades to None if it was since-deleted (== None).
_auto_name = automation.name if automation else None
_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
# Fire any outbound deactivate actions (best-effort). Skipped when the
# automation was since-deleted (no actions to read).
if automation is not None:
await self._fire_actions(automation, "deactivate")
await self._publish_mqtt_state(automation_id, False)
async def _publish_mqtt_state(self, automation_id: str, active: bool) -> None:
"""Best-effort publish of the automation's active state to HA discovery."""
mgr = self._mqtt_manager
if mgr is None or not hasattr(mgr, "publish_automation_state_all"):
return
try:
await mgr.publish_automation_state_all(automation_id, active)
except Exception: # noqa: BLE001 — never raise into the engine
pass
async def _deactivate_revert(self, automation_id: str) -> None:
"""Revert to pre-activation snapshot."""
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
@@ -855,10 +1163,12 @@ AutomationEngine._RULE_HANDLERS = {
StartupRule: AutomationEngine._handle_startup,
ApplicationRule: AutomationEngine._handle_application,
TimeOfDayRule: AutomationEngine._handle_time_of_day,
SolarRule: AutomationEngine._handle_solar,
SystemIdleRule: AutomationEngine._handle_system_idle,
DisplayStateRule: AutomationEngine._handle_display_state,
MQTTRule: AutomationEngine._handle_mqtt,
WebhookRule: AutomationEngine._handle_webhook,
ManualTriggerRule: AutomationEngine._handle_manual,
HomeAssistantRule: AutomationEngine._handle_home_assistant,
HTTPPollRule: AutomationEngine._handle_http_poll,
}
@@ -876,10 +1186,12 @@ def _assert_rule_handler_coverage() -> None:
StartupRule,
ApplicationRule,
TimeOfDayRule,
SolarRule,
SystemIdleRule,
DisplayStateRule,
MQTTRule,
WebhookRule,
ManualTriggerRule,
HomeAssistantRule,
HTTPPollRule,
}
@@ -0,0 +1,102 @@
"""Outbound webhook action firing for the automation engine.
When an automation activates or deactivates, any attached
:class:`~ledgrab.storage.automation.WebhookAction` performs a best-effort
outbound HTTP request (Discord / IFTTT / Zapier / Node-RED / Home Assistant
webhooks). Firing is fire-and-forget: a hung or failing endpoint is logged and
audited but never raises into the activation path.
Security: the target URL is SSRF-gated via :func:`validate_polling_url` (LAN
allowed so users can hit Node-RED / HA on their own network; loopback,
link-local / cloud-metadata, multicast and reserved ranges blocked) at **both**
save time (in the route) and fire time (here) — re-validating at fire time
closes the DNS-rebinding window. Redirects are not followed.
"""
from __future__ import annotations
from datetime import datetime, timezone
import httpx
from fastapi import HTTPException
from ledgrab.storage.automation import Automation, WebhookAction
from ledgrab.utils import get_logger
from ledgrab.utils.safe_source import validate_polling_url
logger = get_logger(__name__)
# A webhook must never stall the ~1 Hz evaluation loop.
_WEBHOOK_TIMEOUT_S = 5.0
def render_template(template: str, automation: Automation, event: str) -> str:
"""Substitute the supported ``{{token}}`` placeholders in *template*.
Tokens: ``{{automation_name}}``, ``{{automation_id}}``, ``{{event}}``
(``activate``/``deactivate``), ``{{timestamp}}`` (ISO-8601 UTC). Unknown
tokens are left untouched.
"""
replacements = {
"{{automation_name}}": automation.name,
"{{automation_id}}": automation.id,
"{{event}}": event,
"{{timestamp}}": datetime.now(timezone.utc).isoformat(),
}
out = template
for token, value in replacements.items():
out = out.replace(token, value)
return out
def should_fire(action: WebhookAction, event: str) -> bool:
"""Whether *action* fires for this transition (``activate``/``deactivate``)."""
return action.fire_on == event or action.fire_on == "both"
async def fire_webhook_action(
action: WebhookAction,
automation: Automation,
event: str,
) -> tuple[bool, str | None]:
"""Fire a single webhook action. Best-effort: never raises.
Returns ``(ok, error)`` where ``ok`` is True on a 2xx response and
``error`` is a short, secret-free reason on failure.
"""
url = action.webhook_url.strip()
if not url:
return False, "no URL configured"
# Re-validate at fire time (DNS-rebinding window). HTTPException carries a
# 4xx detail; surface a short reason rather than raising into the engine.
try:
validate_polling_url(url)
except HTTPException as exc:
logger.warning("Webhook for '%s' blocked by SSRF policy: %s", automation.name, exc.detail)
return False, "blocked by SSRF policy"
body = render_template(action.body_template, automation, event)
headers = {"Content-Type": action.content_type or "application/json"}
try:
async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT_S, follow_redirects=False) as client:
kwargs: dict = {"headers": headers}
# Only attach a body for write methods with content to send.
if action.method in ("POST", "PUT") and body:
kwargs["content"] = body.encode("utf-8")
response = await client.request(action.method, url, **kwargs)
except Exception as exc: # noqa: BLE001 — never propagate into activation
# Never log the rendered body or the exception repr (may carry the URL
# with embedded secrets) — the type name is enough to diagnose.
logger.warning("Webhook for '%s' failed: %s", automation.name, type(exc).__name__)
return False, f"request failed: {type(exc).__name__}"
ok = 200 <= response.status_code < 300
if not ok:
logger.warning("Webhook for '%s' returned HTTP %d", automation.name, response.status_code)
return False, f"HTTP {response.status_code}"
logger.info(
"Webhook for '%s' fired on %s (HTTP %d)", automation.name, event, response.status_code
)
return True, None
+66 -2
View File
@@ -120,6 +120,11 @@ class CalibrationConfig:
roi_y: float = 0.0
roi_width: float = 1.0
roi_height: float = 1.0
# Blend border pixels in linear light (perceptually correct averaging)
# instead of gamma-encoded sRGB. Off by default = unchanged behaviour.
linear_blend: bool = False
# Spatio-temporal dither the final 8-bit quantization to reduce banding.
dither: bool = False
@property
def has_roi(self) -> bool:
@@ -349,6 +354,8 @@ class PixelMapper:
"""
self.calibration = calibration
self.interpolation_mode = interpolation_mode
# Per-frame counter driving the temporal dither phase.
self._dither_frame = 0
# Validate calibration
self.calibration.validate()
@@ -430,7 +437,16 @@ class PixelMapper:
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
the shared kernel handles all allocations on first use.
"""
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, edge_name)
return average_edge_to_leds(
edge_pixels,
edge_name,
led_count,
self._edge_cache,
edge_name,
linear=self.calibration.linear_blend,
dither=self.calibration.dither,
frame_index=self._dither_frame,
)
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors.
@@ -449,6 +465,7 @@ class PixelMapper:
"""
led_array = self._led_buf
led_array[:] = 0
self._dither_frame += 1
# Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll)
for i, segment in enumerate(self.calibration.segments):
@@ -514,6 +531,7 @@ class AdvancedPixelMapper:
):
self.calibration = calibration
self.interpolation_mode = interpolation_mode
self._dither_frame = 0
calibration.validate()
if interpolation_mode == "average":
@@ -600,7 +618,16 @@ class AdvancedPixelMapper:
``cache_key`` is an integer (e.g. line index) so multiple per-line
edges can share the same ``self._edge_cache`` dict without colliding.
"""
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, cache_key)
return average_edge_to_leds(
edge_pixels,
edge_name,
led_count,
self._edge_cache,
cache_key,
linear=self.calibration.linear_blend,
dither=self.calibration.dither,
frame_index=self._dither_frame,
)
def _map_edge_fallback(
self,
@@ -622,6 +649,7 @@ class AdvancedPixelMapper:
"""
led_array = self._led_buf
led_array[:] = 0
self._dither_frame += 1
for i, line in enumerate(self.calibration.lines):
frame = frames.get(line.picture_source_id)
@@ -824,6 +852,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",
@@ -878,6 +930,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
offset=data.get("offset", 0),
skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0),
linear_blend=bool(data.get("linear_blend", False)),
dither=bool(data.get("dither", False)),
)
config.validate()
return config
@@ -907,6 +961,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
roi_y=data.get("roi_y", 0.0),
roi_width=data.get("roi_width", 1.0),
roi_height=data.get("roi_height", 1.0),
linear_blend=bool(data.get("linear_blend", False)),
dither=bool(data.get("dither", False)),
)
config.validate()
@@ -951,6 +1007,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["skip_leds_start"] = config.skip_leds_start
if config.skip_leds_end > 0:
result["skip_leds_end"] = config.skip_leds_end
if config.linear_blend:
result["linear_blend"] = True
if config.dither:
result["dither"] = True
return result
# Simple mode
@@ -984,4 +1044,8 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["roi_y"] = config.roi_y
result["roi_width"] = config.roi_width
result["roi_height"] = config.roi_height
if config.linear_blend:
result["linear_blend"] = True
if config.dither:
result["dither"] = True
return result
@@ -233,8 +233,20 @@ class CalibrationSession:
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)
# Clear the device to black so the chase starts from a clean state.
# send_clear_pixels re-raises on a double send failure; a transient
# failure here must NOT strand the session with _active=True and no
# watchdog — log and continue so the idle-timeout watchdog still gets
# armed (mirrors the guarded clear in _teardown_locked).
try:
await manager.send_clear_pixels(device_id)
except Exception as exc:
logger.warning(
"CalibrationSession.start: failed to clear pixels on %s "
"before chase (continuing): %s",
device_id,
exc,
)
# Start idle-timeout watchdog
self._timeout_task = asyncio.ensure_future(self._idle_watchdog())
@@ -23,6 +23,9 @@ from typing import Any, Callable, Dict, Hashable, Tuple
import numpy as np
from ledgrab.utils.dither import ordered_dither_quantize
from ledgrab.utils.linear_light import linear_to_srgb_float, linear_to_srgb_uint8, srgb_to_linear
# Cache value layout — kept as a tuple for the small per-frame cost of
# tuple unpacking vs the readability of a dataclass. The first two entries
# are the (edge_len, led_count) signature used to detect a re-build.
@@ -75,6 +78,9 @@ def average_edge_to_leds(
led_count: int,
cache: Dict[Hashable, _CacheEntry],
cache_key: Hashable,
linear: bool = False,
dither: bool = False,
frame_index: int = 0,
) -> np.ndarray:
"""Vectorised average colour per LED segment.
@@ -82,6 +88,14 @@ def average_edge_to_leds(
over axis=0 (collapsing rows), then segment along the width; for
left/right edges we average over axis=1 then segment along the height.
When ``linear`` is True the pixels are decoded to linear light before
averaging and re-encoded to sRGB at the end perceptually correct
blending at a small extra cost (a LUT decode of the input + an analytic
encode of the per-LED result).
When ``dither`` is True the final 8-bit quantization is spatio-temporally
dithered (using ``frame_index``) to suppress gradient banding.
Returns a view into the caller-owned cache's ``out_uint8`` buffer —
do NOT retain the result across calls without copying.
"""
@@ -110,8 +124,13 @@ def average_edge_to_leds(
out_uint8,
) = entry
# Decode to linear light first so both the row/column collapse and the
# per-segment mean happen in physically-linear space. ``src`` is float32
# in [0, 1] (linear) or the raw uint8 sRGB pixels otherwise.
src = srgb_to_linear(edge_pixels) if linear else edge_pixels
# Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
np.mean(src, axis=axis, out=edge_1d_buf)
# Cumulative sum so each LED segment's sum is two array lookups apart.
cumsum_buf[0] = 0
@@ -122,8 +141,16 @@ def average_edge_to_leds(
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
if dither:
# sums_buf is linear [0,1] or sRGB [0,255]; quantize with dithering.
srgb_f = linear_to_srgb_float(sums_buf) if linear else sums_buf
np.copyto(out_uint8, ordered_dither_quantize(srgb_f, frame_index))
elif linear:
# sums_buf holds linear [0, 1] averages — re-encode to sRGB uint8.
np.copyto(out_uint8, linear_to_srgb_uint8(sums_buf))
else:
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
@@ -19,6 +19,17 @@ logger = get_logger(__name__)
ARDUINO_RESET_DELAY = 2.0 # seconds to wait after opening serial for Arduino bootloader
# Settle time between sending the final black frame and closing the port.
# Closing the serial port deasserts DTR, which triggers the Arduino
# auto-reset on most Adalight boards. ``flush()`` only guarantees the bytes
# left the host UART — NOT that the board received the frame, parsed it, and
# ran the LED ``show()`` before the reset wipes its RAM. Without this pause
# the reset intermittently wins the race and the strip latches its last lit
# frame instead of going dark (observed as "the strip sometimes stays on
# after the target/automation stops"). 150 ms is far more than the few ms a
# board needs to paint one frame, and it's a one-shot cost on teardown.
BLACK_FRAME_SETTLE_DELAY = 0.15
def parse_adalight_url(url: str) -> Tuple[str, int]:
"""Backwards-compatible alias for :func:`parse_serial_url`."""
@@ -126,6 +137,10 @@ class AdalightClient(LEDClient):
)
await loop.run_in_executor(executor, self._serial.write, frame)
await loop.run_in_executor(executor, self._serial.flush)
# Let the board parse the frame and run show() before close()
# toggles DTR and resets it — otherwise the strip can latch its
# last lit frame instead of going dark. See BLACK_FRAME_SETTLE_DELAY.
await asyncio.sleep(BLACK_FRAME_SETTLE_DELAY)
logger.info(f"Adalight black frame sent and flushed: {self._port}")
except Exception as e:
logger.warning(f"Failed to send black frame on close: {e}")
@@ -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
@@ -79,6 +79,9 @@ class HueConfig(BaseDeviceConfig):
hue_username: str = ""
hue_client_key: str = ""
hue_entertainment_group_id: str = ""
# Map the strip across the entertainment configuration's channels
# (gradient-lightstrip segments) instead of one record per light.
hue_gradient_mode: bool = True
@dataclass(frozen=True)
@@ -115,6 +118,8 @@ class LIFXConfig(BaseDeviceConfig):
device_type: Literal["lifx"] = "lifx"
lifx_min_interval_ms: int = 50
# Per-zone/tile streaming (Z/Beam multizone, Tile/Canvas matrix) vs single colour.
lifx_per_zone: bool = False
@dataclass(frozen=True)
@@ -153,6 +158,8 @@ class NanoleafConfig(BaseDeviceConfig):
device_type: Literal["nanoleaf"] = "nanoleaf"
nanoleaf_token: str = ""
nanoleaf_min_interval_ms: int = 100
# Per-panel extControl UDP streaming (addresses each panel) vs single colour.
nanoleaf_per_panel: bool = False
@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)
+91 -13
View File
@@ -9,6 +9,7 @@ from typing import List, Tuple
import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import resample_to_n
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -24,15 +25,40 @@ COLOR_SPACE_RGB = 0x00
HEADER_SIZE = 16
def parse_entertainment_channels(config_json: dict) -> List[int]:
"""Extract ordered channel ids from an entertainment_configuration GET.
Entertainment API v2 keys stream records by *channel*, not by light. A
plain bulb contributes one channel; a gradient lightstrip contributes up
to five (one per segment). We order channels left-to-right by their
spatial ``position`` (x then y) so a strip maps across the segments in
physical order. Channels without a position fall back to channel-id order.
"""
data = config_json.get("data") or []
if not data:
return []
channels = data[0].get("channels") or []
def _key(c: dict) -> tuple:
pos = c.get("position") or {}
return (pos.get("x", 0.0), pos.get("y", 0.0), c.get("channel_id", 0))
ordered = sorted(channels, key=_key)
return [int(c["channel_id"]) for c in ordered if "channel_id" in c]
def _build_entertainment_frame(
lights: List[Tuple[int, int, int]],
colors: List[Tuple[int, int, int]],
brightness: int = 255,
sequence: int = 0,
channel_ids: List[int] | None = None,
) -> bytes:
"""Build a Hue Entertainment API v2 UDP frame.
Each light gets 7 bytes: [light_id(2B)][R(2B)][G(2B)][B(2B)]
Colors are 16-bit (0-65535). We scale 8-bit RGB + brightness.
Each record is 7 bytes: [channel_id(1B)][R(2B)][G(2B)][B(2B)]. Colors are
16-bit (0-65535). Record ``i`` is keyed by ``channel_ids[i]`` when a
channel map is supplied (gradient-segment mode); otherwise it falls back
to the 0-based index (one light = one channel, the legacy behaviour).
"""
# Header
header = bytearray(HEADER_SIZE)
@@ -45,15 +71,15 @@ def _build_entertainment_frame(
header[14] = COLOR_SPACE_RGB
header[15] = 0x00 # reserved
# Light data
# Channel data
# Note: brightness already applied by processor loop (_cached_brightness)
data = bytearray()
for idx, (r, g, b) in enumerate(lights):
light_id = idx # 0-based light index in entertainment group
for idx, (r, g, b) in enumerate(colors):
channel_id = channel_ids[idx] if channel_ids and idx < len(channel_ids) else idx
r16 = int(r * 257) # scale 0-255 to 0-65535
g16 = int(g * 257)
b16 = int(b * 257)
data += struct.pack(">BHHH", light_id, r16, g16, b16)
data += struct.pack(">BHHH", channel_id & 0xFF, r16, g16, b16)
return bytes(header) + bytes(data)
@@ -62,7 +88,11 @@ class HueClient(LEDClient):
"""LED client for Philips Hue Entertainment API streaming.
Uses UDP (optionally DTLS) to stream color data at ~25 fps to a Hue
entertainment group. Each light in the group is treated as one "LED".
entertainment group. In ``gradient_mode`` (the default) the client
discovers the entertainment configuration's *channels* on connect and
maps the strip across them, so a gradient lightstrip shows a gradient
instead of a single averaged colour. With gradient mode off (or if
discovery fails) it falls back to one record per light by index.
"""
def __init__(
@@ -72,6 +102,7 @@ class HueClient(LEDClient):
hue_username: str = "",
hue_client_key: str = "",
hue_entertainment_group_id: str = "",
gradient_mode: bool = True,
**kwargs,
):
self._bridge_ip = url.replace("hue://", "").rstrip("/")
@@ -79,15 +110,55 @@ class HueClient(LEDClient):
self._username = hue_username
self._client_key = hue_client_key
self._group_id = hue_entertainment_group_id
self._gradient_mode = gradient_mode
self._channel_ids: List[int] = []
self._sock: socket.socket | None = None
self._connected = False
self._sequence = 0
self._dtls_sock = None
@property
def device_led_count(self) -> int | None:
# In gradient mode the discovered channel count is authoritative.
if self._gradient_mode and self._channel_ids:
return len(self._channel_ids)
return self._led_count or None
async def _fetch_channels(self) -> None:
"""Best-effort discovery of the entertainment configuration channels."""
import httpx
url = (
f"https://{self._bridge_ip}/clip/v2/resource/"
f"entertainment_configuration/{self._group_id}"
)
headers = {"hue-application-key": self._username}
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
self._channel_ids = parse_entertainment_channels(resp.json())
if self._channel_ids:
logger.info(
"Hue %s: mapped strip across %d channels", self._bridge_ip, len(self._channel_ids)
)
async def connect(self) -> bool:
# Activate entertainment streaming via REST API
await self._activate_streaming(True)
# Best-effort channel discovery for gradient-segment mapping. On any
# failure (old bridge, network) we degrade to legacy 1-light-1-record.
if self._gradient_mode:
try:
await self._fetch_channels()
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
logger.warning(
"Hue %s: channel discovery failed, using per-light mapping (%s)",
self._bridge_ip,
exc,
)
self._channel_ids = []
# Open UDP socket for entertainment streaming
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setblocking(False)
@@ -179,12 +250,19 @@ class HueClient(LEDClient):
if not self._connected:
return
if isinstance(pixels, np.ndarray):
light_colors = [tuple(pixels[i]) for i in range(min(len(pixels), self._led_count))]
else:
light_colors = pixels[: self._led_count]
# Resample the strip to the number of addressable elements: the
# discovered channel count in gradient mode, else the configured
# light count. ``resample_to_n`` spreads the strip spatially and
# handles both the np.ndarray and list-of-tuples inputs.
target = len(self._channel_ids) if self._channel_ids else self._led_count
colors = resample_to_n(pixels, target)
frame = _build_entertainment_frame(light_colors, brightness, self._sequence)
frame = _build_entertainment_frame(
colors,
brightness,
self._sequence,
channel_ids=self._channel_ids or None,
)
self._sequence = (self._sequence + 1) & 0xFF
try:
@@ -50,6 +50,7 @@ class HueDeviceProvider(LEDDeviceProvider):
hue_username=config.hue_username,
hue_client_key=config.hue_client_key,
hue_entertainment_group_id=config.hue_entertainment_group_id,
gradient_mode=getattr(config, "hue_gradient_mode", True),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
+238 -22
View File
@@ -29,6 +29,7 @@ import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.core.devices.pixel_reduce import resample_to_n
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -41,6 +42,21 @@ MSG_GET_SERVICE = 2
MSG_STATE_SERVICE = 3
MSG_SET_POWER = 21
MSG_SET_COLOR = 102
# Multizone (Z / Beam) and matrix (Tile / Canvas) per-pixel messages.
MSG_GET_COLOR_ZONES = 502
MSG_STATE_ZONE = 503
MSG_STATE_MULTIZONE = 506
MSG_SET_EXTENDED_COLOR_ZONES = 510
MSG_GET_DEVICE_CHAIN = 701
MSG_STATE_DEVICE_CHAIN = 702
MSG_SET_TILE_STATE_64 = 715
_EXT_ZONE_MAX = 82 # SetExtendedColorZones carries a fixed 82-slot HSBK array
_TILE_PIXELS = 64 # SetTileState64 always carries 64 HSBK
_TILE_STRUCT_SIZE = 55 # bytes per Tile struct in StateDeviceChain
_TILE_CHAIN_MAX = 16 # StateDeviceChain always lists 16 tile slots
# Apply field for zone writes: 0=NO_APPLY (buffer), 1=APPLY, 2=APPLY_ONLY
_ZONE_APPLY = 1
# Frame field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024
_FRAME_TAGGED = 0x3400
@@ -142,6 +158,105 @@ def _build_set_power_payload(on: bool, duration_ms: int = 0) -> bytes:
return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF)
def _build_get_color_zones_payload(start: int = 0, end: int = 255) -> bytes:
"""GetColorZones (502) payload: start_index(1) | end_index(1)."""
return struct.pack("<BB", start & 0xFF, end & 0xFF)
def _pack_hsbk_array(hsbk_list: List[Tuple[int, int, int, int]], slots: int) -> bytes:
"""Pack a fixed-length HSBK array, zero-padding unused slots.
Both SetExtendedColorZones (82 slots) and SetTileState64 (64 slots)
carry a fixed-size color array regardless of how many are in use.
"""
body = bytearray()
for h, s, b, k in hsbk_list[:slots]:
body += struct.pack("<HHHH", h & 0xFFFF, s & 0xFFFF, b & 0xFFFF, k & 0xFFFF)
used = min(len(hsbk_list), slots)
body += b"\x00" * (8 * (slots - used))
return bytes(body)
def _build_set_extended_color_zones_payload(
hsbk_list: List[Tuple[int, int, int, int]],
*,
duration_ms: int = 0,
apply: int = _ZONE_APPLY,
zone_index: int = 0,
) -> bytes:
"""SetExtendedColorZones (510): duration(4)|apply(1)|zone_index(2)|count(1)|82×HSBK."""
count = min(len(hsbk_list), _EXT_ZONE_MAX)
header = struct.pack(
"<IBHB", duration_ms & 0xFFFFFFFF, apply & 0xFF, zone_index & 0xFFFF, count & 0xFF
)
return header + _pack_hsbk_array(hsbk_list, _EXT_ZONE_MAX)
def _build_set_tile_state64_payload(
hsbk_list: List[Tuple[int, int, int, int]],
*,
tile_index: int = 0,
x: int = 0,
y: int = 0,
width: int = 8,
duration_ms: int = 0,
) -> bytes:
"""SetTileState64 (715): tile_index|length|reserved|x|y|width|duration(4)|64×HSBK."""
header = struct.pack(
"<BBBBBBI",
tile_index & 0xFF,
1, # length: number of tiles this packet addresses
0, # reserved
x & 0xFF,
y & 0xFF,
width & 0xFF,
duration_ms & 0xFFFFFFFF,
)
return header + _pack_hsbk_array(hsbk_list, _TILE_PIXELS)
def _parse_multizone_reply(raw: bytes) -> dict | None:
"""Parse StateZone (503) / StateMultiZone (506) → ``{"count", "index"}``.
``count`` is the device's *total* zone count; ``index`` is where this
packet's run starts. Returns ``None`` for any other message type.
"""
if len(raw) < 36 + 2:
return None
msg_type = struct.unpack_from("<H", raw, 32)[0]
if msg_type not in (MSG_STATE_ZONE, MSG_STATE_MULTIZONE):
return None
count, index = struct.unpack_from("<BB", raw, 36)
return {"count": int(count), "index": int(index)}
def _parse_state_device_chain(raw: bytes) -> dict | None:
"""Parse StateDeviceChain (702) → ``{"start_index", "tiles": [(w, h), ...]}``.
Payload: start_index(1) | 16 × Tile(55 bytes) | tile_devices_count(1).
Within each 55-byte Tile struct, ``width`` is byte 16 and ``height``
byte 17 (after accel int16×4 + user_x/user_y float32×2). Returns ``None``
for any other message type.
"""
min_len = 36 + 1 + _TILE_STRUCT_SIZE * _TILE_CHAIN_MAX + 1
if len(raw) < min_len:
return None
msg_type = struct.unpack_from("<H", raw, 32)[0]
if msg_type != MSG_STATE_DEVICE_CHAIN:
return None
payload_off = 36
start_index = raw[payload_off]
count_off = payload_off + 1 + _TILE_STRUCT_SIZE * _TILE_CHAIN_MAX
total = min(raw[count_off], _TILE_CHAIN_MAX)
tiles: list[tuple[int, int]] = []
for i in range(total):
base = payload_off + 1 + i * _TILE_STRUCT_SIZE
width = raw[base + 16]
height = raw[base + 17]
tiles.append((int(width), int(height)))
return {"start_index": int(start_index), "tiles": tiles}
def _parse_state_service_reply(raw: bytes) -> dict | None:
"""Parse a LIFX StateService (discovery) reply.
@@ -163,15 +278,25 @@ def _parse_state_service_reply(raw: bytes) -> dict | None:
class _LIFXProtocol(asyncio.DatagramProtocol):
"""Write-only datagram protocol. Inbound replies dropped silently."""
"""Datagram protocol that buffers inbound replies for zone/tile discovery.
The streaming hot path never reads replies, but per-zone setup needs the
StateMultiZone / StateDeviceChain answers. The buffer is bounded so a
chatty bulb can't grow it without limit during steady-state streaming.
"""
_MAX_BUFFER = 64
def __init__(self) -> None:
self.transport: asyncio.DatagramTransport | None = None
self.received: list[bytes] = []
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
# LIFX bulbs sometimes echo back state on broadcast. We don't need it
# for streaming ambilight — discard.
pass
if len(self.received) < self._MAX_BUFFER:
self.received.append(bytes(data))
def error_received(self, exc):
logger.debug("LIFX UDP error: %s", exc)
@@ -186,6 +311,7 @@ class LIFXClient(LEDClient):
led_count: int = 1,
*,
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
per_zone: bool = False,
):
host, port = parse_lifx_url(url)
self._host = host
@@ -197,6 +323,12 @@ class LIFXClient(LEDClient):
self._connected = False
self._next_tx_at: float = 0.0
self._sequence: int = 0
# Per-pixel state. ``_mode`` is "single" until connect() probes the
# device and finds multizone (Z/Beam) or tile (matrix) support.
self._per_zone = per_zone
self._mode = "single" # "single" | "multizone" | "tile"
self._zone_count = 0
self._tiles: list[tuple[int, int]] = []
@property
def host(self) -> str:
@@ -212,6 +344,12 @@ class LIFXClient(LEDClient):
@property
def device_led_count(self) -> int | None:
# In per-zone streaming the device's addressable element count is
# authoritative (zones for multizone, total pixels for tiles).
if self._mode == "multizone" and self._zone_count:
return self._zone_count
if self._mode == "tile" and self._tiles:
return sum(w * h for w, h in self._tiles)
return self._led_count or None
async def connect(self) -> bool:
@@ -227,9 +365,56 @@ class LIFXClient(LEDClient):
self._transport = transport
self._protocol = protocol # type: ignore[assignment]
self._connected = True
logger.info("LIFXClient connected to %s:%d", self._host, self._port)
if self._per_zone:
# Best-effort: probe for multizone / tile support. On timeout or
# an old single-colour bulb we silently stay in single-colour mode.
try:
await self._setup_zones()
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
logger.warning(
"LIFX %s: per-zone setup failed, using single colour (%s)", self._host, exc
)
self._mode = "single"
logger.info(
"LIFXClient connected to %s:%d (per_zone=%s, mode=%s)",
self._host,
self._port,
self._per_zone,
self._mode,
)
return True
async def _setup_zones(self) -> None:
"""Probe the device for multizone / tile support and pick a mode.
Sends GetColorZones + GetDeviceChain and waits briefly for whichever
reply the device returns (a strip answers StateMultiZone; a tile chain
answers StateDeviceChain). Leaves ``_mode`` at "single" if neither.
"""
if self._protocol is None:
return
self._protocol.received.clear()
self._send(MSG_GET_DEVICE_CHAIN, b"")
self._send(MSG_GET_COLOR_ZONES, _build_get_color_zones_payload())
loop = asyncio.get_running_loop()
deadline = loop.time() + 0.6
while loop.time() < deadline:
await asyncio.sleep(0.05)
for raw in list(self._protocol.received):
chain = _parse_state_device_chain(raw)
if chain and chain["tiles"]:
self._tiles = chain["tiles"]
self._mode = "tile"
logger.info("LIFX %s: tile chain (%d tiles)", self._host, len(self._tiles))
return
zones = _parse_multizone_reply(raw)
if zones and zones["count"] > 1:
self._zone_count = min(zones["count"], _EXT_ZONE_MAX)
self._mode = "multizone"
logger.info("LIFX %s: multizone (%d zones)", self._host, self._zone_count)
return
self._mode = "single"
async def close(self) -> None:
if self._transport is not None:
try:
@@ -255,25 +440,63 @@ class LIFXClient(LEDClient):
)
self._transport.sendto(packet)
def _hsbk_list(
self,
pixels: List[Tuple[int, int, int]] | np.ndarray,
n: int,
scale: float,
) -> List[Tuple[int, int, int, int]]:
"""Resample the strip to ``n`` pixels and convert each to HSBK."""
out: List[Tuple[int, int, int, int]] = []
for r, g, b in resample_to_n(pixels, n):
if scale != 1.0:
r, g, b = int(r * scale), int(g * scale), int(b * scale)
out.append(rgb_to_hsbk(r, g, b))
return out
def _emit_pixels(
self,
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int,
) -> None:
"""Build and send the packet(s) for the current mode (single/zone/tile)."""
scale = max(0, min(255, brightness)) / 255.0 if brightness < 255 else 1.0
if self._mode == "multizone" and self._zone_count > 0:
hsbk = self._hsbk_list(pixels, self._zone_count, scale)
self._send(MSG_SET_EXTENDED_COLOR_ZONES, _build_set_extended_color_zones_payload(hsbk))
return
if self._mode == "tile" and self._tiles:
total = sum(w * h for w, h in self._tiles)
full = self._hsbk_list(pixels, total, scale)
offset = 0
for ti, (w, h) in enumerate(self._tiles):
n = w * h
chunk = full[offset : offset + n]
offset += n
self._send(
MSG_SET_TILE_STATE_64,
_build_set_tile_state64_payload(chunk, tile_index=ti, width=w),
)
return
# Single-colour fallback (every non-multizone/tile bulb).
r, g, b = _average_color(pixels)
if scale != 1.0:
r, g, b = int(r * scale), int(g * scale), int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
async def send_pixels(
self,
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip → HSBK → SetColor."""
"""Stream per-zone/tile when detected, else average the strip → SetColor."""
if not self.is_connected:
raise RuntimeError("LIFXClient not connected")
now = time.monotonic()
if now < self._next_tx_at:
return True
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
self._emit_pixels(pixels, brightness)
self._next_tx_at = now + self._min_interval_s
return True
@@ -288,14 +511,7 @@ class LIFXClient(LEDClient):
now = time.monotonic()
if now < self._next_tx_at:
return
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
self._emit_pixels(pixels, brightness)
self._next_tx_at = now + self._min_interval_s
@property
@@ -51,6 +51,7 @@ class LIFXDeviceProvider(LEDDeviceProvider):
config.device_url,
led_count=config.led_count,
min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0),
per_zone=getattr(config, "lifx_per_zone", False),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
@@ -6,12 +6,13 @@ handshake: the user holds the controller's power button for 5 seconds to
open a 30-second pairing window, then we POST to ``/api/v1/new`` to claim
an auth token. The token is long-lived and gets stored on the device.
Once paired, color control is a simple ``PUT /api/v1/{token}/state`` with
HSBT (hue / saturation / brightness; kelvin only matters when sat=0).
LedGrab averages the incoming strip to one HSB triple. Per-panel streaming
mode (``extControl`` UDP, ~60 Hz, addresses each panel individually) is
documented but not implemented here the MVP keeps the device acting as
a single-pixel target like Yeelight / Hue.
Two output modes:
* **Single-colour** (default): average the strip to one HSB triple and
``PUT /api/v1/{token}/state`` matches every other consumer-bulb driver.
* **Per-panel** (``per_panel=True``): enable ``extControl`` v2 and stream a
UDP packet per frame to port 60222, addressing each panel individually
(resampled from the strip). Enabled when the device config opts in; falls
back to single-colour if the controller/firmware can't stream.
URL scheme: ``nanoleaf://<host>``. Port is fixed at 16021 on the protocol
side. The auth token is stored separately on the device config, not in
@@ -23,6 +24,8 @@ Reference: https://forum.nanoleaf.me/docs/openapi
from __future__ import annotations
import asyncio
import socket
import struct
from datetime import datetime, timezone
from typing import List, Tuple
from urllib.parse import urlparse
@@ -37,9 +40,62 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__)
NANOLEAF_PORT = 16021
# extControl v2 UDP streaming target port (fixed by the protocol).
NANOLEAF_STREAM_PORT = 60222
DEFAULT_MIN_INTERVAL_S = 0.1 # 10 Hz; HTTP per frame, plenty for averaged ambilight
def order_panels(position_data: List[dict]) -> List[int]:
"""Order panel IDs left-to-right, top-to-bottom from ``panelLayout`` data.
Each ``positionData`` entry has ``panelId``/``x``/``y``. We sort by (x, y)
so an ambient strip maps across the panels spatially. Entries without an
integer ``panelId`` (or the controller/rhythm panel, panelId 0) are dropped.
"""
panels = [
p for p in position_data if isinstance(p.get("panelId"), int) and p.get("panelId", 0) != 0
]
panels.sort(key=lambda p: (p.get("x", 0), p.get("y", 0)))
return [int(p["panelId"]) for p in panels]
def map_pixels_to_panels(
pixels: List[Tuple[int, int, int]] | np.ndarray,
panel_ids: List[int],
) -> List[Tuple[int, int, int, int]]:
"""Resample an N-pixel strip to one ``(panel_id, r, g, b)`` per panel.
Nearest-neighbour resample: panel ``i`` of ``M`` samples strip pixel
``floor(i * N / M)``. Returns black for an empty strip.
"""
arr = np.asarray(pixels, dtype=np.uint8).reshape(-1, 3)
n_pix = len(arr)
n_panels = max(1, len(panel_ids))
out: List[Tuple[int, int, int, int]] = []
for i, pid in enumerate(panel_ids):
if n_pix == 0:
out.append((pid, 0, 0, 0))
continue
idx = min(n_pix - 1, (i * n_pix) // n_panels)
px = arr[idx]
out.append((pid, int(px[0]), int(px[1]), int(px[2])))
return out
def build_extcontrol_v2_packet(panels: List[Tuple[int, int, int, int]]) -> bytes:
"""Build a Nanoleaf extControl **v2** UDP streaming packet.
Wire format (all multi-byte fields big-endian):
``uint16 nPanels`` then per panel
``uint16 panelId, uint8 R, uint8 G, uint8 B, uint8 W, uint16 transitionTime``
``transitionTime`` is in 100 ms units; 1 = a 100 ms ease for smooth motion.
"""
parts = [struct.pack(">H", len(panels))]
for pid, r, g, b in panels:
parts.append(struct.pack(">HBBBBH", pid & 0xFFFF, r & 0xFF, g & 0xFF, b & 0xFF, 0, 1))
return b"".join(parts)
def parse_nanoleaf_url(url: str) -> str:
"""Pull the host out of ``nanoleaf://host`` or accept a bare host.
@@ -131,6 +187,7 @@ class NanoleafClient(LEDClient):
auth_token: str = "",
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
request_timeout_s: float = 3.0,
per_panel: bool = False,
):
self._host = parse_nanoleaf_url(url)
self._token = auth_token
@@ -140,6 +197,11 @@ class NanoleafClient(LEDClient):
self._http: httpx.AsyncClient | None = None
self._connected = False
self._next_tx_at: float = 0.0
# Per-panel extControl streaming state.
self._per_panel = per_panel
self._streaming = False
self._panel_ids: List[int] = []
self._udp: socket.socket | None = None
@property
def host(self) -> str:
@@ -157,6 +219,9 @@ class NanoleafClient(LEDClient):
@property
def device_led_count(self) -> int | None:
# In per-panel streaming mode the panel count is authoritative.
if self._streaming and self._panel_ids:
return len(self._panel_ids)
return self._led_count or None
def _state_url(self) -> str:
@@ -169,9 +234,63 @@ class NanoleafClient(LEDClient):
raise RuntimeError("NanoleafClient requires an auth_token; pair the device first")
self._http = httpx.AsyncClient(timeout=self._request_timeout_s)
self._connected = True
logger.info("NanoleafClient connected to %s:%d", self._host, NANOLEAF_PORT)
if self._per_panel:
# Best-effort: if streaming can't be enabled (old firmware, network)
# we silently fall back to the single-colour HTTP path.
try:
await self._setup_streaming()
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
logger.warning(
"Nanoleaf %s: per-panel streaming unavailable, using single-colour (%s)",
self._host,
exc,
)
self._streaming = False
logger.info(
"NanoleafClient connected to %s:%d (per_panel=%s, streaming=%s)",
self._host,
NANOLEAF_PORT,
self._per_panel,
self._streaming,
)
return True
async def _setup_streaming(self) -> None:
"""Fetch the panel layout and enable extControl v2 UDP streaming."""
if self._http is None:
raise RuntimeError("NanoleafClient not connected")
base = f"http://{self._host}:{NANOLEAF_PORT}/api/v1/{self._token}"
# 1) Panel layout → ordered panel IDs.
resp = await self._http.get(f"{base}/panelLayout/layout")
resp.raise_for_status()
position_data = resp.json().get("positionData", [])
panel_ids = order_panels(position_data)
if not panel_ids:
raise RuntimeError("Nanoleaf reported no addressable panels")
# 2) Switch the controller into external (UDP) control, v2.
enable = await self._http.put(
f"{base}/effects",
json={
"write": {
"command": "display",
"animType": "extControl",
"extControlVersion": "v2",
}
},
)
if enable.status_code not in (200, 204):
raise RuntimeError(
f"Nanoleaf refused extControl enable ({enable.status_code}): {enable.text[:200]}"
)
# 3) Fire-and-forget UDP socket for the per-frame stream.
self._udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._udp.setblocking(False)
self._panel_ids = panel_ids
self._streaming = True
logger.info(
"Nanoleaf %s: extControl v2 streaming over %d panels", self._host, len(panel_ids)
)
async def close(self) -> None:
if self._http is not None:
try:
@@ -179,6 +298,13 @@ class NanoleafClient(LEDClient):
except (httpx.HTTPError, RuntimeError):
pass
self._http = None
if self._udp is not None:
try:
self._udp.close()
except OSError:
pass
self._udp = None
self._streaming = False
self._connected = False
async def _put_state(self, body: dict) -> None:
@@ -197,12 +323,28 @@ class NanoleafClient(LEDClient):
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip and PUT a single HSB state update."""
"""Stream per-panel (extControl) when enabled, else average to one HSB state."""
if not self.is_connected:
raise RuntimeError("NanoleafClient not connected")
loop_now = asyncio.get_event_loop().time()
loop_now = asyncio.get_running_loop().time()
if loop_now < self._next_tx_at:
return True
if self._streaming and self._udp is not None and self._panel_ids:
scale = max(0, min(255, brightness)) / 255.0 if brightness < 255 else 1.0
arr = np.asarray(pixels, dtype=np.float32).reshape(-1, 3)
if scale != 1.0:
arr = arr * scale
scaled = np.clip(arr, 0, 255).astype(np.uint8)
panels = map_pixels_to_panels(scaled, self._panel_ids)
packet = build_extcontrol_v2_packet(panels)
try:
self._udp.sendto(packet, (self._host, NANOLEAF_STREAM_PORT))
except OSError as exc:
logger.debug("Nanoleaf %s: UDP stream send failed (%s)", self._host, exc)
self._next_tx_at = loop_now + self._min_interval_s
return True
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
@@ -61,6 +61,7 @@ class NanoleafDeviceProvider(LEDDeviceProvider):
led_count=config.led_count,
auth_token=config.nanoleaf_token,
min_interval_s=max(0.0, config.nanoleaf_min_interval_ms / 1000.0),
per_panel=getattr(config, "nanoleaf_per_panel", False),
)
async def pair_device(self, url: str) -> dict:
@@ -40,3 +40,29 @@ def average_color(
total_b += b
n = len(pixels)
return total_r // n, total_g // n, total_b // n
def resample_to_n(
pixels: List[Tuple[int, int, int]] | np.ndarray,
n: int,
) -> List[Tuple[int, int, int]]:
"""Nearest-neighbour resample an N-pixel strip to exactly ``n`` pixels.
Output pixel ``i`` of ``n`` samples input pixel ``floor(i * N / n)``, so
the strip spreads spatially across a multi-element device (LIFX zones/
tiles, Nanoleaf panels, Hue segments). Black is returned for an empty
strip; ``n <= 0`` yields an empty list.
"""
if n <= 0:
return []
arr = np.asarray(pixels, dtype=np.uint8).reshape(-1, 3)
n_pix = len(arr)
out: List[Tuple[int, int, int]] = []
for i in range(n):
if n_pix == 0:
out.append((0, 0, 0))
continue
idx = min(n_pix - 1, (i * n_pix) // n)
px = arr[idx]
out.append((int(px[0]), int(px[1]), int(px[2])))
return out
@@ -260,7 +260,12 @@ 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. Compare UTF-8 bytes
# so an attacker-controlled non-ASCII payload token returns False rather
# than raising TypeError out of secrets.compare_digest (→ 500).
return secrets.compare_digest(actual_token.encode("utf-8"), expected_token.encode("utf-8"))
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -177,7 +177,12 @@ 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. Compare UTF-8 bytes
# so an attacker-controlled non-ASCII payload token returns False rather
# than raising TypeError out of secrets.compare_digest (→ 500).
return secrets.compare_digest(actual_token.encode("utf-8"), expected_token.encode("utf-8"))
@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,32 @@ 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.
# Compare UTF-8 byte encodings: secrets.compare_digest raises TypeError
# on non-ASCII str, and the header value is attacker-controlled
# (Starlette latin-1-decodes header bytes to a possibly-non-ASCII str).
# Byte comparison is well-defined for any input and stays constant-time,
# so a non-ASCII token cleanly returns False instead of raising a 500.
return secrets.compare_digest(actual_value.encode("utf-8"), expected_token.encode("utf-8"))
@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 +158,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"
)
@@ -0,0 +1,90 @@
"""Runtime manager that owns LoL Live-Client-Data polling threads.
Unlike GSI adapters (CS2, Dota 2) that push events into the HTTP ingest
endpoint, League of Legends only exposes a *local* poll API. The
:class:`~ledgrab.core.game_integration.adapters.lol_adapter.LoLPoller` knows how
to poll it; this manager owns the poller lifecycle, starting one daemon poller
per enabled ``lol`` integration and reconciling on config changes.
``sync()`` is the single reconciliation entry point call it at startup and
after any integration create/update/delete so runtime pollers always match the
enabled ``lol`` integrations in the store.
"""
from __future__ import annotations
import threading
from typing import Any, Iterable
from ledgrab.core.game_integration.adapters.lol_adapter import LoLAdapter, LoLPoller
from ledgrab.core.game_integration.runtime_state import process_payload
from ledgrab.utils import get_logger
logger = get_logger(__name__)
class LoLPollManager:
"""Owns one :class:`LoLPoller` per enabled League of Legends integration."""
def __init__(self, event_bus: Any) -> None:
self._event_bus = event_bus
self._lock = threading.Lock()
self._pollers: dict[str, LoLPoller] = {}
# Last adapter_config a poller was started with, so a config edit
# (e.g. poll interval) triggers a restart rather than going unnoticed.
self._configs: dict[str, dict[str, Any]] = {}
def sync(self, integrations: Iterable[Any]) -> None:
"""Reconcile running pollers against the enabled ``lol`` integrations."""
desired = {
c.id: c
for c in integrations
if getattr(c, "enabled", False)
and getattr(c, "adapter_type", None) == LoLAdapter.ADAPTER_TYPE
}
with self._lock:
# Stop pollers whose integration is gone, disabled, or reconfigured.
for integration_id in list(self._pollers):
cfg = desired.get(integration_id)
if cfg is None or self._configs.get(integration_id) != dict(cfg.adapter_config):
self._stop_locked(integration_id)
# Start pollers for anything enabled that isn't already running.
for integration_id, cfg in desired.items():
if integration_id not in self._pollers:
self._start_locked(integration_id, cfg)
def stop_all(self) -> None:
"""Stop every poller (shutdown hook)."""
with self._lock:
for integration_id in list(self._pollers):
self._stop_locked(integration_id)
@property
def active_count(self) -> int:
with self._lock:
return len(self._pollers)
# ── internals (call under self._lock) ──────────────────────────────────
def _start_locked(self, integration_id: str, cfg: Any) -> None:
adapter_config = dict(cfg.adapter_config)
poller = LoLPoller(adapter_config, self._make_callback(integration_id, adapter_config))
poller.start()
self._pollers[integration_id] = poller
self._configs[integration_id] = adapter_config
logger.info("Started LoL poller for integration %s", integration_id)
def _stop_locked(self, integration_id: str) -> None:
poller = self._pollers.pop(integration_id, None)
self._configs.pop(integration_id, None)
if poller is not None:
poller.stop()
logger.info("Stopped LoL poller for integration %s", integration_id)
def _make_callback(self, integration_id: str, adapter_config: dict[str, Any]):
event_bus = self._event_bus
def _on_poll(data: dict[str, Any]) -> None:
process_payload(integration_id, LoLAdapter, adapter_config, data, event_bus)
return _on_poll
@@ -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,14 @@ 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. Compare UTF-8
# bytes so an attacker-controlled non-ASCII header value returns
# False rather than raising TypeError out of compare_digest (→ 500).
return secrets.compare_digest(
actual_value.encode("utf-8"), expected_value.encode("utf-8")
)
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
return False
@@ -0,0 +1,120 @@
"""Shared in-memory runtime state + payload processing for game integrations.
Both the HTTP ingest route (``api/routes/game_integration.py``) and the
poll-based LoL manager (``lol_poll_manager.py``) feed adapter payloads through
the SAME per-integration prev-state / stats here, so a polled integration's
status counters look identical to a pushed one's.
Lives in ``core/`` (not the route) so the poll manager can reuse it without a
route core layering inversion.
"""
from __future__ import annotations
import threading
from typing import Any
from ledgrab.core.game_integration.events import GameEvent
from ledgrab.utils import get_logger
logger = get_logger(__name__)
_state_lock = threading.Lock()
# integration_id -> prev_state dict for diff-based trigger detection
_prev_states: dict[str, dict[str, Any]] = {}
# integration_id -> runtime stats
_integration_stats: dict[str, dict[str, Any]] = {}
def get_prev_state(integration_id: str) -> dict[str, Any]:
"""Get or create the prev_state dict for an integration."""
with _state_lock:
if integration_id not in _prev_states:
_prev_states[integration_id] = {}
return _prev_states[integration_id]
def set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
"""Update the prev_state dict for an integration."""
with _state_lock:
_prev_states[integration_id] = state
def record_events(integration_id: str, events: list[GameEvent]) -> None:
"""Record event stats for an integration."""
with _state_lock:
if integration_id not in _integration_stats:
_integration_stats[integration_id] = {
"event_count": 0,
"event_counts_by_type": {},
"last_event_time": None,
}
stats = _integration_stats[integration_id]
for event in events:
stats["event_count"] += 1
stats["event_counts_by_type"][event.event_type] = (
stats["event_counts_by_type"].get(event.event_type, 0) + 1
)
stats["last_event_time"] = event.timestamp
def get_stats(integration_id: str) -> dict[str, Any]:
"""Get a snapshot of runtime stats for an integration.
Returns a fresh copy (incl. a copied ``event_counts_by_type``) so the caller
can read/iterate it on the event loop while the poll thread keeps mutating
the live dict under the lock otherwise a concurrent insert raises
``RuntimeError: dictionary changed size during iteration``.
"""
with _state_lock:
stats = _integration_stats.get(integration_id)
if stats is None:
return {"event_count": 0, "event_counts_by_type": {}, "last_event_time": None}
return {
"event_count": stats["event_count"],
"event_counts_by_type": dict(stats["event_counts_by_type"]),
"last_event_time": stats["last_event_time"],
}
def cleanup_state(integration_id: str) -> None:
"""Remove runtime state for a deleted integration."""
with _state_lock:
_prev_states.pop(integration_id, None)
_integration_stats.pop(integration_id, None)
def process_payload(
integration_id: str,
adapter_cls: Any,
adapter_config: dict[str, Any],
data: dict[str, Any],
event_bus: Any,
) -> list[GameEvent]:
"""Parse a raw adapter payload, publish the events, and record stats.
Mirrors the body of the HTTP ingest route so a polled payload produces
identical events. Unlike the route (which returns HTTP 400 on a bad
payload), parse failures here are logged and swallowed a poll loop must
keep running across a transient malformed frame. Returns the events
published (empty on parse failure).
"""
prev_state = get_prev_state(integration_id)
try:
events, new_state = adapter_cls.parse_payload(data, adapter_config, prev_state)
except Exception:
logger.exception(
"Adapter %s failed to parse polled payload for %s",
getattr(adapter_cls, "ADAPTER_TYPE", "?"),
integration_id,
)
return []
set_prev_state(integration_id, new_state)
for event in events:
event_bus.publish(event)
if events:
record_events(integration_id, events)
return events
@@ -0,0 +1,141 @@
"""Home Assistant MQTT auto-discovery publisher.
When an :class:`~ledgrab.storage.mqtt_source.MQTTSource` has
``publish_ha_discovery`` enabled, this publishes retained
``<discovery_prefix>/<component>/<node_id>/<object_id>/config`` topics so an
MQTT-only Home Assistant install gets LedGrab entities automatically no YAML.
Scope (read-only telemetry): a ``binary_sensor`` per automation (active /
inactive) plus a connectivity ``binary_sensor`` for LedGrab itself, all tied to
the broker's birth/will ``<base_topic>/status`` availability topic. This is
deliberately one-way (LedGrab HA): there is no inbound command surface, so it
adds no new attack surface. Controllable ``light``/``switch`` entities (HA
LedGrab) are a documented follow-up.
Cleanup: disabling discovery or deleting the source publishes an empty retained
payload to every previously-published config topic, so HA drops the entities
rather than leaving orphans behind forever.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.mqtt.mqtt_runtime import MQTTRuntime
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.mqtt_source import MQTTSource
logger = get_logger(__name__)
def _node_id(source_id: str) -> str:
"""Namespace every entity by source so multiple brokers don't collide."""
return f"ledgrab_{source_id}"
class HADiscoveryPublisher:
"""Builds and publishes HA discovery configs for one MQTT runtime."""
def __init__(
self,
runtime: "MQTTRuntime",
source: "MQTTSource",
automation_store: "AutomationStore",
version: str = "",
) -> None:
self._runtime = runtime
self._source = source
self._automation_store = automation_store
self._version = version
self._base = source.base_topic
self._prefix = source.discovery_prefix or "homeassistant"
# Config topics we have published, so remove_all() can clear exactly them.
self._published: set[str] = set()
# ── Config builders (pure — unit tested) ──────────────────────
def _device_block(self) -> dict:
return {
"identifiers": [_node_id(self._source.id)],
"name": "LedGrab",
"manufacturer": "LedGrab",
"model": "Ambient",
"sw_version": self._version or "unknown",
}
def _config_topic(self, component: str, object_id: str) -> str:
return f"{self._prefix}/{component}/{_node_id(self._source.id)}/{object_id}/config"
def build_connectivity_config(self) -> tuple[str, dict]:
"""A connectivity ``binary_sensor`` reflecting LedGrab's birth/will."""
sid = self._source.id
topic = self._config_topic("binary_sensor", "connectivity")
payload = {
"unique_id": f"ledgrab_{sid}_connectivity",
"name": "LedGrab",
"device_class": "connectivity",
"state_topic": f"{self._base}/status",
"payload_on": "online",
"payload_off": "offline",
"device": self._device_block(),
}
return topic, payload
def build_automation_config(self, automation) -> tuple[str, dict]:
"""A ``binary_sensor`` reflecting an automation's active state."""
sid = self._source.id
topic = self._config_topic("binary_sensor", f"automation_{automation.id}")
payload = {
"unique_id": f"ledgrab_{sid}_automation_{automation.id}",
"name": automation.name,
"state_topic": f"{self._base}/automation/{automation.id}/state",
"value_template": "{{ value_json.action }}",
"payload_on": "active",
"payload_off": "inactive",
"availability_topic": f"{self._base}/status",
"payload_available": "online",
"payload_not_available": "offline",
"device": self._device_block(),
}
return topic, payload
# ── Publish / remove ──────────────────────────────────────────
async def publish_all(self) -> None:
"""Publish (retained) every discovery config + an initial state snapshot."""
topic, payload = self.build_connectivity_config()
await self._runtime.publish(topic, json.dumps(payload), retain=True)
self._published.add(topic)
count = 0
for automation in self._automation_store.get_all():
topic, payload = self.build_automation_config(automation)
await self._runtime.publish(topic, json.dumps(payload), retain=True)
self._published.add(topic)
# Seed an initial "inactive" state; the engine flips it live on the
# next activate/deactivate transition.
await self._runtime.publish_automation_state(automation.id, "inactive")
count += 1
logger.info(
"HA discovery published for source %s: %d automation sensor(s) + connectivity",
self._source.id,
count,
)
async def remove_all(self) -> None:
"""Clear every previously-published config (empty retained payload)."""
for topic in list(self._published):
await self._runtime.publish(topic, "", retain=True)
# Also clear automations that may have been published in a prior run but
# since deleted — recompute the current set and clear those too.
topic, _ = self.build_connectivity_config()
await self._runtime.publish(topic, "", retain=True)
for automation in self._automation_store.get_all():
topic, _ = self.build_automation_config(automation)
await self._runtime.publish(topic, "", retain=True)
self._published.clear()
logger.info("HA discovery removed for source %s", self._source.id)
+99 -1
View File
@@ -22,11 +22,27 @@ class MQTTManager:
Multiple consumers share the same runtime via acquire/release.
"""
def __init__(self, store: MQTTSourceStore) -> None:
def __init__(
self,
store: MQTTSourceStore,
automation_store=None,
version: str = "",
) -> None:
self._store = store
# Optional deps for HA discovery publishing (injected from main.py).
self._automation_store = automation_store
self._version = version
# source_id -> (runtime, ref_count)
self._runtimes: Dict[str, tuple] = {}
# Sources for which we hold a discovery acquire() reference.
self._discovery_sources: set[str] = set()
self._lock = asyncio.Lock()
# Serializes the discovery reconcile (ensure/disable) check-then-acquire so
# two concurrent sync_discovery() calls for the same source can't both see
# "first time" and double-acquire (ref-count leak). Separate from _lock
# because ensure/disable call acquire()/release() which take _lock, and
# asyncio.Lock is not re-entrant.
self._discovery_lock = asyncio.Lock()
async def acquire(self, source_id: str) -> MQTTRuntime:
"""Get or create a runtime for the given MQTT source. Increments ref count."""
@@ -100,6 +116,88 @@ class MQTTManager:
except Exception as e:
logger.warning("Failed to update MQTT runtime %s: %s", source_id, e)
# ===== Home Assistant MQTT discovery =====
def _make_publisher(self, runtime: MQTTRuntime, source):
from ledgrab.core.mqtt.ha_discovery import HADiscoveryPublisher
return HADiscoveryPublisher(runtime, source, self._automation_store, version=self._version)
async def bootstrap_discovery(self) -> None:
"""On startup, ensure a runtime + publish discovery for every enabled source."""
if self._automation_store is None:
return
for source in self._store.get_all():
if getattr(source, "publish_ha_discovery", False):
try:
await self.ensure_discovery(source.id)
except Exception as exc: # noqa: BLE001 — best-effort bootstrap
logger.warning("HA discovery bootstrap failed for %s: %s", source.id, exc)
async def ensure_discovery(self, source_id: str) -> None:
"""Hold a runtime open for *source_id* and publish its discovery configs.
Idempotent: a second call re-publishes (configs are retained) without
leaking a second acquire reference.
"""
if self._automation_store is None:
return
try:
source = self._store.get(source_id)
except Exception:
return
if not getattr(source, "publish_ha_discovery", False):
return
async with self._discovery_lock:
first_time = source_id not in self._discovery_sources
if first_time:
runtime = await self.acquire(source_id)
self._discovery_sources.add(source_id)
else:
runtime = self.get_runtime(source_id)
if runtime is None:
return
await self._make_publisher(runtime, source).publish_all()
async def disable_discovery(self, source_id: str) -> None:
"""Clear a source's discovery configs and drop our runtime reference."""
async with self._discovery_lock:
if source_id not in self._discovery_sources:
return
self._discovery_sources.discard(source_id)
runtime = self.get_runtime(source_id)
if runtime is not None:
try:
source = self._store.get(source_id)
await self._make_publisher(runtime, source).remove_all()
except Exception as exc: # noqa: BLE001 — best-effort cleanup
logger.warning("HA discovery cleanup failed for %s: %s", source_id, exc)
await self.release(source_id)
async def sync_discovery(self, source_id: str) -> None:
"""Reconcile discovery state after a source is created/updated."""
try:
source = self._store.get(source_id)
except Exception:
return
if getattr(source, "publish_ha_discovery", False):
await self.ensure_discovery(source_id)
else:
await self.disable_discovery(source_id)
async def publish_automation_state_all(self, automation_id: str, active: bool) -> None:
"""Fan an automation's active state out to every discovery-enabled runtime."""
if not self._discovery_sources:
return
action = "active" if active else "inactive"
for source_id in list(self._discovery_sources):
runtime = self.get_runtime(source_id)
if runtime is not None:
try:
await runtime.publish_automation_state(automation_id, action)
except Exception: # noqa: BLE001 — never raise into the engine
pass
def get_connection_status(self) -> List[Dict[str, Any]]:
"""Get status of all active MQTT connections (for dashboard indicators)."""
result = []
@@ -0,0 +1,115 @@
"""AudioEnergyTap — a lightweight read-only tap on a shared audio capture.
Lets a non-audio stream (e.g. a procedural effect) react to live loudness
without owning audio capture. It resolves an ``audio_source_id`` to capture
params exactly like ``AudioColorStripStream`` does, acquires the SHARED stream
via the ``AudioCaptureManager`` (ref-counted, so it piggybacks on any audio
visualiser already capturing the same device), and exposes a smoothed 01
energy scalar.
"""
from __future__ import annotations
from typing import Any
from ledgrab.utils import get_logger
logger = get_logger(__name__)
# RMS of typical program audio sits around 0.050.3; scale so a moderately
# loud signal reaches ~1.0 before the per-effect intensity slider is applied.
_RMS_GAIN = 4.0
class AudioEnergyTap:
"""Resolve → acquire → read smoothed loudness from a shared audio capture."""
def __init__(
self,
audio_capture_manager: Any,
audio_source_store: Any = None,
audio_template_store: Any = None,
) -> None:
self._mgr = audio_capture_manager
self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store
self._stream = None
self._source_id = ""
self._device_index = -1
self._loopback = True
self._engine_type = None
self._engine_config = None
self._smoothed = 0.0
@property
def available(self) -> bool:
return self._mgr is not None
@property
def active(self) -> bool:
return self._stream is not None
def configure(self, audio_source_id: str) -> None:
"""Resolve capture params for ``audio_source_id`` (no acquire yet)."""
self._source_id = audio_source_id or ""
self._device_index = -1
self._loopback = True
self._engine_type = None
self._engine_config = None
if not self._source_id or not self._audio_source_store:
return
try:
resolved = self._audio_source_store.resolve_audio_source(self._source_id)
self._device_index = resolved.device_index
self._loopback = resolved.is_loopback
if resolved.audio_template_id and self._audio_template_store:
try:
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
self._engine_type = tpl.engine_type
self._engine_config = tpl.engine_config
except ValueError as e:
logger.warning(
"AudioEnergyTap: template %s missing: %s", resolved.audio_template_id, e
)
except ValueError as e:
logger.warning(
"AudioEnergyTap: failed to resolve audio source %s: %s", self._source_id, e
)
def start(self) -> None:
if self._mgr is None or self._stream is not None:
return
try:
self._stream = self._mgr.acquire(
self._device_index,
self._loopback,
engine_type=self._engine_type,
engine_config=self._engine_config,
)
except Exception as e: # acquisition is best-effort — never break the effect
logger.warning("AudioEnergyTap: failed to acquire audio capture: %s", e)
self._stream = None
def stop(self) -> None:
if self._mgr is not None and self._stream is not None:
try:
self._mgr.release(self._device_index, self._loopback, engine_type=self._engine_type)
except Exception: # release is best-effort
pass
self._stream = None
self._smoothed = 0.0
def energy(self, smoothing: float = 0.4) -> float:
"""Return smoothed loudness in [0, 1] (0 when no capture/analysis yet).
``smoothing`` is the inertia of the EMA: 0 = instantaneous, 1 = sluggish.
"""
if self._stream is None:
return 0.0
analysis = self._stream.get_latest_analysis()
raw = 0.0
if analysis is not None:
raw = min(1.0, max(0.0, float(getattr(analysis, "rms", 0.0)) * _RMS_GAIN))
a = max(0.0, min(0.99, smoothing))
self._smoothed = self._smoothed * a + raw * (1.0 - a)
return self._smoothed
@@ -246,6 +246,13 @@ class ColorStripStreamManager:
# Inject gradient store for palette resolution
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
css_stream.set_gradient_store(self._gradient_store)
# Inject audio capture deps for audio-reactive effects
if self._audio_capture_manager and hasattr(css_stream, "set_audio_capture"):
css_stream.set_audio_capture(
self._audio_capture_manager,
self._audio_source_store,
self._audio_template_store,
)
# Inject asset store for notification sound playback
if self._asset_store and hasattr(css_stream, "set_asset_store"):
css_stream.set_asset_store(self._asset_store)
@@ -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(
@@ -10,7 +10,6 @@ to the user's location and the current season.
"""
import datetime
import math
import threading
import time
@@ -84,71 +83,16 @@ _daylight_lut: np.ndarray | None = None
# ── Solar position helpers ──────────────────────────────────────────────
def _compute_solar_times(
latitude: float,
longitude: float,
day_of_year: int,
utc_offset_hours: float = 0.0,
) -> tuple:
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time.
Uses simplified NOAA solar equations:
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
- hour angle: cos(ha) = -tan(lat) * tan(decl)
- solar noon (UTC): 12 - longitude/15
- wall-clock sunrise/sunset: solar_noon_utc + utc_offset ha/15
Polar day and polar night are clamped to visible ranges.
"""
deg2rad = math.pi / 180.0
decl_deg = 23.45 * math.sin(2.0 * math.pi * (284 + day_of_year) / 365.0)
decl_rad = decl_deg * deg2rad
lat_rad = latitude * deg2rad
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
solar_noon_utc = 12.0 - longitude / 15.0
solar_noon_local = solar_noon_utc + utc_offset_hours
if cos_ha <= -1.0:
# Polar day — sun never sets; fake a long visible window
sunrise = solar_noon_local - 9.0
sunset = solar_noon_local + 9.0
elif cos_ha >= 1.0:
# Polar night — sun never rises; collapse to noon
sunrise = solar_noon_local
sunset = solar_noon_local
else:
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
sunrise = solar_noon_local - ha_hours
sunset = solar_noon_local + ha_hours
# Clamp to a safe range the LUT builder can render. With reasonable
# tz/longitude pairs sunrise lands in (3..10) and sunset in (14..21);
# we widen the clamp so weird tz/lon combinations still produce a
# usable curve instead of dividing by zero.
sunrise = max(0.5, min(11.5, sunrise))
sunset = max(12.5, min(23.5, sunset))
return sunrise, sunset
def _utc_offset_hours_for(tz_name: str, when: datetime.datetime | None = None) -> float:
"""Return the UTC offset (in hours) for the given IANA timezone.
Empty/unknown tz falls back to the system local offset for ``when``.
"""
when = when or datetime.datetime.now()
if tz_name and ZoneInfo is not None:
try:
offset = when.replace(tzinfo=None).astimezone(ZoneInfo(tz_name)).utcoffset()
if offset is not None:
return offset.total_seconds() / 3600.0
except ZoneInfoNotFoundError:
pass
local_offset = when.astimezone().utcoffset()
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
#
# ``compute_solar_times`` / ``utc_offset_hours_for`` moved to
# ``ledgrab.utils.solar`` (pure math, no project imports) so the automation
# engine can reuse them without importing this processing module. Imported
# under their old private names here so the rest of this file (and
# ``value_stream``, which imports them from here) is unchanged.
from ledgrab.utils.solar import ( # noqa: E402
compute_solar_times as _compute_solar_times,
utc_offset_hours_for as _utc_offset_hours_for,
)
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
@@ -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,41 @@ class DeviceHealthMixin:
"latency_ms": state.health.latency_ms,
}
)
# Audit record for device online/offline transition — best-effort.
# Wrapped so an instrumentation/import regression can never escape
# into _health_check_loop, whose top-level handler exits the loop
# permanently with no re-spawn (mirrors discovery_watcher._emit).
try:
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},
)
except Exception as e:
logger.debug("Device health: audit record failed for %s: %s", device_id, e)
# 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)
@@ -304,6 +304,13 @@ class EffectColorStripStream(ColorStripStream):
# Sparkle rain state
self._sparkle_state: np.ndarray | None = None # per-LED brightness 0..1
self._gradient_store = None # injected by stream manager
# Audio-reactive modulation (tap injected by stream manager via
# set_audio_capture; defaults keep the effect working with no audio).
self._audio_tap = None
self._audio_reactive = False
self._reactive_mode = "brightness"
self._reactive_audio_source_id = ""
self._reactive_intensity = 0.7
self._update_from_source(source)
def set_gradient_store(self, gradient_store) -> None:
@@ -344,9 +351,36 @@ class EffectColorStripStream(ColorStripStream):
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
self._scale = bfloat(getattr(source, "scale", 1.0), 1.0)
self._mirror = bool(getattr(source, "mirror", False))
# Audio-reactive params
self._audio_reactive = bool(getattr(source, "audio_reactive", False))
self._reactive_mode = getattr(source, "reactive_mode", "brightness") or "brightness"
self._reactive_intensity = bfloat(getattr(source, "reactive_intensity", 0.7), 0.7)
self._reactive_audio_source_id = getattr(source, "reactive_audio_source_id", "") or ""
if self._audio_tap is not None:
self._audio_tap.configure(self._reactive_audio_source_id)
with self._colors_lock:
self._colors: np.ndarray | None = None
def set_audio_capture(
self, audio_capture_manager, audio_source_store=None, audio_template_store=None
) -> None:
"""Inject audio capture deps so the effect can react to loudness.
Called by the stream manager after construction (mirrors
``set_gradient_store``). Builds the tap and resolves the referenced
audio source; a missing manager just leaves the effect non-reactive.
"""
if audio_capture_manager is None:
return
from ledgrab.core.processing.audio_energy_tap import AudioEnergyTap
self._audio_tap = AudioEnergyTap(
audio_capture_manager, audio_source_store, audio_template_store
)
self._audio_tap.configure(self._reactive_audio_source_id)
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
@@ -369,6 +403,8 @@ class EffectColorStripStream(ColorStripStream):
def start(self) -> None:
if self._running:
return
if self._audio_reactive and self._audio_tap is not None:
self._audio_tap.start()
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
@@ -387,6 +423,8 @@ class EffectColorStripStream(ColorStripStream):
if self._thread.is_alive():
logger.warning("EffectColorStripStream animate thread did not terminate within 5s")
self._thread = None
if self._audio_tap is not None:
self._audio_tap.stop()
self._heat = None
self._heat_n = 0
logger.info("EffectColorStripStream stopped")
@@ -399,12 +437,39 @@ class EffectColorStripStream(ColorStripStream):
from ledgrab.storage.color_strip_source import EffectColorStripSource
if isinstance(source, EffectColorStripSource):
# Release the audio capture before reconfiguring so a changed
# audio source / loopback is re-acquired cleanly.
tap_was_active = self._audio_tap is not None and self._audio_tap.active
if tap_was_active:
self._audio_tap.stop()
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
if self._running and self._audio_reactive and self._audio_tap is not None:
self._audio_tap.start()
logger.info("EffectColorStripStream params updated in-place")
def _apply_audio_modulation(self, buf: np.ndarray) -> None:
"""Scale brightness and/or saturation of the rendered frame by loudness.
Quiet audio dims/desaturates toward ``1 - intensity``; loud audio drives
full brightness (and a saturation boost up to ``1 + intensity``).
"""
k = max(0.0, min(1.0, self.resolve("reactive_intensity", self._reactive_intensity)))
if k <= 0.0:
return
e = self._audio_tap.energy()
f = buf.astype(np.float32)
if self._reactive_mode in ("brightness", "both"):
f *= (1.0 - k) + k * e
if self._reactive_mode in ("saturation", "both"):
sat = (1.0 - k) + 2.0 * k * e
lum = (f[:, 0] * 0.299 + f[:, 1] * 0.587 + f[:, 2] * 0.114)[:, None]
f = lum + (f - lum) * sat
np.clip(f, 0.0, 255.0, out=f)
buf[:] = f.astype(np.uint8)
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock
@@ -476,6 +541,9 @@ class EffectColorStripStream(ColorStripStream):
continue
render_fn(self, buf, n, anim_time)
if self._audio_reactive and self._audio_tap is not None:
self._apply_audio_modulation(buf)
with self._colors_lock:
self._colors = buf
except Exception as e:
@@ -973,13 +1041,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
@@ -739,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
@@ -138,22 +138,42 @@ class PlaylistEngine:
"""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.
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.
"""
if self._state is not None and self._state.playlist_id == playlist_id:
await self.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:
return self._task is not None and not self._task.done()
# 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:
return self._state.playlist_id if self._state else None
state = self._state
return state.playlist_id if state else None
def get_state(self) -> dict:
if self._state is not None and self.is_running():
return self._state.to_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 =====
@@ -201,13 +221,25 @@ class PlaylistEngine:
break
# Natural end (non-loop or guard). Clear state without recursing
# through stop() (which would try to cancel this very task). Guard
# against a concurrent start_playlist having already replaced us:
# only clear if we are still the engine's current task.
if self._task is asyncio.current_task():
self._task = None
ended_id = self._state.playlist_id if self._state else None
self._state = None
# 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:
@@ -220,18 +252,24 @@ class PlaylistEngine:
order = self._resolve_order(playlist)
applied_any = False
for index, item in enumerate(order):
for orig_index, item in 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:
# Only advertise runtime state for a step we actually applied
# and are about to dwell on — a missing/skipped preset must not
# briefly show up in get_state(). ``current_index`` is the
# ORIGINAL persisted items[] index (not the shuffled position)
# so clients can correlate it back to the items array.
if self._state is not None:
self._state.current_index = orig_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_any = True
self._fire_event("advanced", index=index, preset_id=item.scene_preset_id)
self._fire_event("advanced", index=orig_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)
@@ -239,11 +277,16 @@ class PlaylistEngine:
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)
"""Return ``(orig_index, item)`` pairs, optionally shuffled.
The original persisted index travels with each item so ``current_index``
in the runtime state always maps back into ``playlist.items`` even when
shuffle reorders the cycle.
"""
indexed = list(enumerate(playlist.items))
if playlist.shuffle and len(indexed) > 1:
random.shuffle(indexed) # noqa: S311 - cosmetic ordering, not security
return indexed
async def _apply_item(self, preset_id: str) -> bool:
"""Apply one scene preset. Returns False if it could not be applied."""
+89 -8
View File
@@ -51,6 +51,7 @@ 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
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
from ledgrab.core.game_integration.community_loader import register_community_adapters
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
@@ -60,6 +61,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
@@ -176,14 +180,22 @@ weather_manager = WeatherManager(weather_source_store)
ha_store = HomeAssistantStore(db)
ha_manager = HomeAssistantManager(ha_store)
mqtt_source_store = MQTTSourceStore(db)
mqtt_manager = MQTTManager(mqtt_source_store)
mqtt_manager = MQTTManager(
mqtt_source_store, automation_store=automation_store, version=__version__
)
http_endpoint_store = HTTPEndpointStore(db)
audio_processing_template_store = AudioProcessingTemplateStore(db)
game_integration_store = GameIntegrationStore(db)
pattern_template_store = PatternTemplateStore(db)
game_event_bus = GameEventBus()
# Owns LoL Live-Client-Data poll threads (League is poll-only, not GSI-push).
lol_poll_manager = LoLPollManager(game_event_bus)
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,
@@ -257,6 +269,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:
@@ -290,6 +311,17 @@ async def lifespan(app: FastAPI):
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
@@ -342,12 +374,19 @@ async def lifespan(app: FastAPI):
ha_manager=ha_manager,
game_integration_store=game_integration_store,
game_event_bus=game_event_bus,
lol_poll_manager=lol_poll_manager,
mqtt_store=mqtt_source_store,
mqtt_manager=mqtt_manager,
http_endpoint_store=http_endpoint_store,
audio_processing_template_store=audio_processing_template_store,
pattern_template_store=pattern_template_store,
activity_recorder=activity_recorder,
activity_log_repo=activity_log_repo,
activity_log_retention_engine=activity_log_retention_engine,
)
# 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()
@@ -387,12 +426,25 @@ async def lifespan(app: FastAPI):
# Start automation engine (evaluates conditions and activates scenes)
await automation_engine.start()
# Publish Home Assistant MQTT discovery for any source that opted in.
await mqtt_manager.bootstrap_discovery()
# 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()
# Start LoL Live-Client-Data pollers for any enabled League integrations
# (League is poll-only; GSI games push into the ingest endpoint instead).
try:
lol_poll_manager.sync(game_integration_store.get_all_integrations())
except Exception as e:
logger.error(f"Failed to start LoL pollers: {e}")
# Start OS notification listener (Windows toast → notification CSS streams)
os_notif_listener = OsNotificationListener(
color_strip_store=color_strip_store,
@@ -438,6 +490,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.
@@ -453,6 +518,19 @@ async def lifespan(app: FastAPI):
# 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)
# Stop LoL poller threads so they stop publishing game events.
try:
lol_poll_manager.stop_all()
except Exception as e:
logger.error(f"Error stopping LoL pollers: {e}")
# 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:
@@ -503,6 +581,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
@@ -623,25 +702,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';
@@ -196,6 +196,37 @@
width: 100%;
}
/* ── Solar (sunrise/sunset) rule ───────────────────────────── */
.solar-event-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.solar-event-row select {
flex: 1 1 8rem;
min-width: 8rem;
}
.solar-event-row input[type="number"] {
width: 5rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.solar-offset-unit {
font-size: 0.8rem;
color: var(--text-muted);
}
.solar-coord-row {
display: flex;
gap: 10px;
}
.solar-coord-row .rule-field {
flex: 1;
}
.solar-coord-row input[type="number"] {
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;
}
+37 -27
View File
@@ -328,7 +328,7 @@ select.field-invalid {
display: block;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--surface-2, color-mix(in srgb, var(--text-color) 6%, var(--bg-color)));
background: var(--lux-bg-2);
overflow: hidden;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
@@ -396,10 +396,10 @@ select.field-invalid {
/* Token palette — restrained, three accents plus muted operators. */
.jinja-hl .tok-str { color: var(--success-color); }
.jinja-hl .tok-num { color: #d19a66; }
.jinja-hl .tok-num { color: var(--ch-amber); }
.jinja-hl .tok-fn { color: var(--primary-color); font-weight: 600; }
.jinja-hl .tok-raw { color: #c678dd; font-style: italic; }
.jinja-hl .tok-var { color: #61afef; }
.jinja-hl .tok-raw { color: var(--ch-violet); font-style: italic; }
.jinja-hl .tok-var { color: var(--ch-cyan); }
.jinja-hl .tok-op { color: var(--text-muted); }
/* ── Template-input rows ─────────────────────────────────────── */
@@ -496,8 +496,8 @@ select.field-invalid {
font-size: 0.76rem;
padding: 1px 6px;
border-radius: var(--radius-pill);
background: color-mix(in srgb, #61afef 16%, transparent);
color: #61afef;
background: color-mix(in srgb, var(--ch-cyan) 16%, transparent);
color: var(--ch-cyan);
}
.jinja-hints-examples { margin: 4px 0 0; padding-left: 0; list-style: none; }
@@ -918,7 +918,12 @@ input:-webkit-autofill:focus {
.discovery-item:focus-visible,
.tag-chip-remove:focus-visible,
.cs-filter-reset:focus-visible,
.graph-filter-clear:focus-visible {
.graph-filter-clear:focus-visible,
.wizard-discovery-item:focus-visible,
.wizard-display-item:focus-visible,
.autocal-corner-btn:focus-visible,
.autocal-direction-btn:focus-visible,
.scene-target-add-slot:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
@@ -1118,7 +1123,8 @@ textarea:focus-visible {
font-weight: 700;
line-height: 1;
}
.scene-target-add-slot:hover:not(:disabled) {
.scene-target-add-slot:hover:not(:disabled),
.scene-target-add-slot:focus-visible:not(:disabled) {
background:
repeating-linear-gradient(135deg,
color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px,
@@ -1625,14 +1631,14 @@ textarea:focus-visible {
padding: 6px 12px;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--surface-2, #1e1e2e);
background: var(--lux-bg-2);
color: var(--text-secondary, #999);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.type-picker-tab:hover {
background: var(--surface-3, #2a2a3e);
background: var(--lux-bg-3);
color: var(--text-primary, #e0e0e0);
}
.type-picker-tab.active {
@@ -2326,7 +2332,7 @@ textarea:focus-visible {
.autocal-step-desc {
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
@@ -2355,7 +2361,8 @@ textarea:focus-visible {
font-weight: 500;
text-align: center;
}
.autocal-corner-btn:hover {
.autocal-corner-btn:hover,
.autocal-corner-btn:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color);
@@ -2424,7 +2431,8 @@ textarea:focus-visible {
font-weight: 500;
text-align: center;
}
.autocal-direction-btn:hover {
.autocal-direction-btn:hover,
.autocal-direction-btn:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color);
@@ -2442,7 +2450,7 @@ textarea:focus-visible {
border: 1px solid color-mix(in srgb, var(--primary-color) 20%, var(--border-color));
border-radius: var(--radius-sm, 6px);
font-size: 0.8rem;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
}
.autocal-led-dot {
@@ -2489,7 +2497,7 @@ textarea:focus-visible {
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
transition: border-color 0.2s, background 0.2s, color 0.2s;
flex-shrink: 0;
}
@@ -2618,7 +2626,7 @@ textarea:focus-visible {
}
.autocal-solved-key {
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
flex-shrink: 0;
min-width: 68px;
}
@@ -2720,7 +2728,7 @@ textarea:focus-visible {
font-size: 0.7rem;
font-weight: 700;
background: var(--bg-secondary, var(--bg-2, #2a2a2a));
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
border: 1.5px solid var(--border-color);
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
@@ -2775,7 +2783,7 @@ textarea:focus-visible {
}
.wizard-step-desc {
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
@@ -2829,7 +2837,7 @@ textarea:focus-visible {
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
padding-bottom: 4px;
}
.wizard-section-label--scan {
@@ -2860,7 +2868,7 @@ textarea:focus-visible {
gap: 10px;
padding: 14px 12px;
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
@@ -2868,7 +2876,7 @@ textarea:focus-visible {
.wizard-discovery-empty {
padding: 14px 12px;
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
@@ -2891,7 +2899,8 @@ textarea:focus-visible {
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.wizard-discovery-item:hover {
.wizard-discovery-item:hover,
.wizard-discovery-item:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
}
@@ -2899,7 +2908,7 @@ textarea:focus-visible {
.wizard-discovery-icon .icon { width: 20px; height: 20px; }
.wizard-discovery-details { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.wizard-discovery-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-badge {
font-size: 0.68rem;
font-weight: 700;
@@ -2926,7 +2935,8 @@ textarea:focus-visible {
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.wizard-display-item:hover {
.wizard-display-item:hover,
.wizard-display-item:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
}
@@ -2938,7 +2948,7 @@ textarea:focus-visible {
.wizard-display-icon .icon { width: 20px; height: 20px; }
.wizard-display-details { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.wizard-display-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); }
.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); }
.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted); }
.wizard-display-check { color: var(--primary-color); }
.wizard-display-check .icon { width: 16px; height: 16px; }
.wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; }
@@ -2953,7 +2963,7 @@ textarea:focus-visible {
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
}
.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted, var(--secondary-text-color)); }
.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted); }
/* Calibrate container */
.wizard-calibrate-container {
@@ -2990,7 +3000,7 @@ textarea:focus-visible {
padding: 12px 16px;
}
.wizard-done-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; gap: 12px; }
.wizard-done-label { color: var(--text-muted, var(--secondary-text-color)); }
.wizard-done-label { color: var(--text-muted); }
.wizard-done-value { font-weight: 600; color: var(--text-color); text-align: right; }
/* Wizard form rows */
+60 -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);
@@ -3257,6 +3261,60 @@
user-select: none;
}
/* Color-harmony generator (gradient editor) */
.gradient-harmony-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.gradient-harmony-row input[type="color"] {
width: 38px;
height: 30px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 6px;
background: none;
cursor: pointer;
flex-shrink: 0;
transition: box-shadow var(--duration-fast, 120ms) ease,
border-color var(--duration-fast, 120ms) ease;
}
.gradient-harmony-row input[type="color"]:hover,
.gradient-harmony-row input[type="color"]:focus-visible {
outline: none;
border-color: var(--primary-color, var(--border-color));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color, #4c8dff) 28%, transparent);
}
.gradient-harmony-types {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.gradient-harmony-btn {
/* padding inherited from .btn-sm; only the slightly smaller harmony size differs */
font-size: 0.75rem;
}
/* Linear-light toggle row (calibration editor) */
.calibration-linear-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
/* The toggle's text label neutralize the global `label` bottom margin so it
stays vertically centered against the switch in this flex row. */
.calibration-linear-row label[for] {
margin: 0;
cursor: pointer;
}
.calibration-linear-row .input-hint {
flex-basis: 100%;
margin: 0;
}
#gradient-canvas,
#ge-gradient-canvas {
width: 100%;
@@ -4751,6 +4809,7 @@ body.composite-layer-dragging .composite-layer-drag-handle {
.ds-section[data-ds-key="filters"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="routing"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="output"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="power"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="filtering"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="broker"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="protocol"] { animation-delay: 0.10s; }
+44 -3
View File
@@ -107,7 +107,7 @@ import {
import {
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationRule,
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
toggleAutomationEnabled, triggerAutomationNow, cloneAutomation, deleteAutomation, copyWebhookUrl,
} from './features/automations.ts';
import {
showGameIntegrationEditor, saveGameIntegration, closeGameIntegrationModal,
@@ -150,7 +150,7 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
onCSSTypeChange, onEffectTypeChange, onEffectReactiveToggle, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
onAudioVizChange,
@@ -228,6 +228,24 @@ import {
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 {
loadGraphEditor,
@@ -257,6 +275,7 @@ import {
loadDaylightTimezone, saveDaylightTimezone,
requestNotifPermissionFromSettings, testNotifFromSettings,
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
loadActivityLogSettings, saveActivityLogSettings, activityLogSettingsExport, clearActivityLog,
} from './features/settings.ts';
import {
loadUpdateStatus, initUpdateListener, checkForUpdates,
@@ -486,6 +505,7 @@ Object.assign(window, {
saveAutomationEditor,
addAutomationRule,
toggleAutomationEnabled,
triggerAutomationNow,
cloneAutomation,
deleteAutomation,
copyWebhookUrl,
@@ -567,6 +587,7 @@ Object.assign(window, {
deleteColorStrip,
onCSSTypeChange,
onEffectTypeChange,
onEffectReactiveToggle,
onEffectPaletteChange,
onCSSClockChange,
onAnimationTypeChange,
@@ -741,6 +762,10 @@ Object.assign(window, {
saveExternalUrl,
revertExternalUrl,
getBaseOrigin,
loadActivityLogSettings,
saveActivityLogSettings,
activityLogSettingsExport,
clearActivityLog,
// update
checkForUpdates,
@@ -762,6 +787,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 ───
@@ -779,7 +820,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();
@@ -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.
@@ -47,6 +48,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
'update_download_progress',
'device_discovered',
'device_lost',
'activity_logged', // source: core/activity_log/recorder.py
]);
interface ServerEventEnvelope {
+15 -2
View File
@@ -7,6 +7,9 @@ import { IconSelect } from './icon-select.ts';
let currentLocale = 'en';
let _localeIconSelect: IconSelect | null = null;
let translations = {};
// English baseline kept in memory so a key missing from the active locale
// degrades to readable English rather than a raw dotted identifier.
let baseTranslations = {};
let _initialized = false;
const supportedLocales = {
@@ -37,9 +40,12 @@ export function t(key: string, params: Record<string, any> = {}) {
let text;
if ('count' in params) {
const form = getPluralForm(currentLocale, params.count);
text = translations[`${key}.${form}`] || translations[key] || fallbackTranslations[key] || key;
const enForm = getPluralForm('en', params.count);
text = translations[`${key}.${form}`] || translations[key]
|| baseTranslations[`${key}.${enForm}`] || baseTranslations[`${key}.${form}`]
|| baseTranslations[key] || fallbackTranslations[key] || key;
} else {
text = translations[key] || fallbackTranslations[key] || key;
text = translations[key] || baseTranslations[key] || fallbackTranslations[key] || key;
}
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
@@ -80,6 +86,13 @@ export async function setLocale(locale: string) {
}
translations = await loadTranslations(locale);
// Keep an English baseline so any key missing from a non-English locale
// resolves to English instead of a raw dotted key (see t()).
if (locale === 'en') {
baseTranslations = translations;
} else if (Object.keys(baseTranslations).length === 0) {
baseTranslations = await loadTranslations('en');
}
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
@@ -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);
@@ -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);

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