Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 838c95484d | |||
| 14822fb6a0 | |||
| 0c096db639 | |||
| c1eeefcf06 | |||
| 39b0554444 | |||
| 6745e25b20 | |||
| 126d8f2449 | |||
| e584235676 | |||
| b43f821046 | |||
| 077c99c7d1 | |||
| ae74cca132 | |||
| 77284e8e7b | |||
| ff1ff06cb5 | |||
| 3dd1ac3f0d | |||
| 6e1dd2111d | |||
| 9a0137fa4c | |||
| 4a0927521a | |||
| 25c613c5cb | |||
| 726f39e2ba | |||
| 1ac4a0f66d | |||
| 1afe7d6fcc | |||
| 17dd2e02ba |
@@ -354,6 +354,58 @@ jobs:
|
|||||||
docker push "$REGISTRY:latest"
|
docker push "$REGISTRY:latest"
|
||||||
fi
|
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) ─────────────────
|
# ── Publish the release (flip draft=false) ─────────────────
|
||||||
# Runs only after every build job succeeded so users never see a
|
# Runs only after every build job succeeded so users never see a
|
||||||
# release that's missing artifacts or sha256 sidecars (the in-app
|
# release that's missing artifacts or sha256 sidecars (the in-app
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ semantic = true
|
|||||||
# Automatically run `vex update` before search if the index is stale
|
# Automatically run `vex update` before search if the index is stale
|
||||||
auto_update = true
|
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.
|
# Changing the embedder requires a full reindex.
|
||||||
# embedder = "minilm-l6-v2"
|
embedder = "jina-code"
|
||||||
|
|||||||
@@ -2,9 +2,40 @@
|
|||||||
|
|
||||||
## Code Search
|
## 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
|
```bash
|
||||||
ast-index search "Query" # Universal search
|
ast-index search "Query" # Universal search
|
||||||
|
|||||||
@@ -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
|
- Real-time FPS, latency, and uptime charts
|
||||||
- Localized in English, Russian, and Chinese
|
- 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
|
### Home Assistant Integration
|
||||||
|
|
||||||
- HACS-compatible custom component (separate repository)
|
- HACS-compatible custom component (separate repository)
|
||||||
|
|||||||
+69
-78
@@ -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
|
#### Activity Log
|
||||||
- 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))
|
- 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-pixel smart lights
|
||||||
- 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))
|
- 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
|
#### Capture & effects
|
||||||
- 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))
|
- Linear-light blending and spatio-temporal dithering, opt-in per calibration ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||||
- Playlist + cycling state is included in the aggregated `/snapshot` response ([abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c))
|
- 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
|
#### Automations & integrations
|
||||||
- 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))
|
- Solar sunrise/sunset automation trigger (new `utils/solar.py`) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||||
- 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))
|
- 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
|
### Bug Fixes
|
||||||
- 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))
|
- Pre-release review hardening: solar timezone crash, webhook header CRLF,
|
||||||
|
MQTT topic-prefix injection, thread-safe `get_stats` copy, MQTT discovery
|
||||||
##### Built-in "look" presets
|
lock, `reactive_mode` Literal, and calibration-modal accessibility ([0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db))
|
||||||
- One-click looks: **Cinematic / Vivid / Cozy / Soft / Cool** ([e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c))
|
- Comprehensive review fixes across security, concurrency, performance,
|
||||||
|
Android, and UI ([17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0))
|
||||||
##### Weekday + timezone scheduling
|
- Activity Log polish: accessible export menu, i18n placeholders, dashboard
|
||||||
- The time-of-day automation rule now supports **weekday selection and explicit timezones** ([1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac))
|
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))
|
||||||
##### 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))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Development / Internal
|
### Development / Internal
|
||||||
|
|
||||||
#### Backend / Storage
|
#### CI/Build
|
||||||
- `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))
|
- 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
|
#### Chores
|
||||||
- In-progress dashboard customization groundwork ([6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569))
|
- 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
|
> Tests: ~180 new unit tests added across the activity log, roadmap features,
|
||||||
- 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))
|
> and integrations. Release gate green: ruff + tsc + build clean,
|
||||||
|
> **pytest 2739 passed / 2 skipped**.
|
||||||
#### 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**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits (31)</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| 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 |
|
| [0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db) | fix: address pre-release review findings (2026-06-23) | 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 |
|
| [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 |
|
||||||
| [81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808) | feat(onboarding): guided first-run setup wizard (phase 4, final) | 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 |
|
||||||
| [abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c) | feat(snapshot): include scene playlists + cycling state in snapshot | 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 |
|
||||||
| [9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688) | feat(calibration): browser-driven auto edge-calibration UI (phase 3) | 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 |
|
||||||
| [9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d) | feat(setup): one-call setup scaffold + onboarding flag (phase 2) | alexei.dolgolyov |
|
| [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c) | fix(activity-log): no spinner flash on instant filtering | alexei.dolgolyov |
|
||||||
| [0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8) | feat(calibration): auto edge-calibration backend core (phase 1) | 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 |
|
||||||
| [6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569) | wip(dashboard): in-progress dashboard customization changes | alexei.dolgolyov |
|
| [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8) | fix(activity-log): dashboard section reconciliation + activity column alignment | alexei.dolgolyov |
|
||||||
| [f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e) | feat(scenes): scene playlists with timed auto-cycling | 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 |
|
||||||
| [ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546) | feat(capture): region-of-interest (ROI) crop for screen sampling | 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 |
|
||||||
| [1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac) | feat(automations): weekday + timezone scheduling for time-of-day rule | 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 |
|
||||||
| [e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c) | feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool) | 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 |
|
||||||
| [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 |
|
| [4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275) | feat(activity-log): phase 4 - REST API (list/export/settings/clear) | 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 |
|
| [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c) | feat(activity-log): phase 3 - event instrumentation (4 categories) | 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 |
|
| [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e) | feat(activity-log): phase 2 - recorder, actor context, retention, lifecycle | alexei.dolgolyov |
|
||||||
| [fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201) | fix(api): remove broken legacy /system/mqtt/settings route | alexei.dolgolyov |
|
| [1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f) | feat(activity-log): phase 1 - storage model, migration, repository | 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 |
|
| [1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6) | chore(activity-log): scaffold feature plan and phase subplans | alexei.dolgolyov |
|
||||||
| [9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15) | docs(android): remove ANDROID-REVIEW planning/review docs | 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 |
|
||||||
| [1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2) | feat(android): foreground-app automation condition | alexei.dolgolyov |
|
|
||||||
| [4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6) | feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine) | alexei.dolgolyov |
|
|
||||||
| [0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83) | feat(android): on-device OS notification capture (NotificationListenerService) | alexei.dolgolyov |
|
|
||||||
| [4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc) | docs(android): add audio-capture design + missing-functionality review | alexei.dolgolyov |
|
|
||||||
| [fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1) | feat(audio): Android on-device system playback capture | alexei.dolgolyov |
|
|
||||||
| [669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20) | feat(value-sources): optional normalization for magnitude sources | alexei.dolgolyov |
|
|
||||||
| [6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9) | feat(value-sources): add sandboxed-Jinja template combinator | alexei.dolgolyov |
|
|
||||||
| [12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6) | docs: actualize README and API reference, embed screenshots | alexei.dolgolyov |
|
|
||||||
| [498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f) | refactor(storage): gate clone() behind an opt-in allowlist; expand duplicate tests | alexei.dolgolyov |
|
|
||||||
| [15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82) | feat(graph): duplicate a selected subgraph server-side | alexei.dolgolyov |
|
|
||||||
| [2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46) | feat(graph): make the visual editor a full wiring control surface | alexei.dolgolyov |
|
|
||||||
| [05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121) | fix(installer): open WebUI once after "Launch LedGrab" | alexei.dolgolyov |
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ android {
|
|||||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||||
// sideload updates silently refused to install.
|
// sideload updates silently refused to install.
|
||||||
versionCode = ledgrabVersionCode
|
versionCode = ledgrabVersionCode
|
||||||
versionName = "0.8.2"
|
versionName = "0.9.0"
|
||||||
|
|
||||||
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
||||||
// ABI in only when the matching pydantic-core wheel is on disk —
|
// 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")
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
// QR code generation for displaying server URL on TV
|
// QR code generation for displaying server URL on TV
|
||||||
implementation("com.google.zxing:core:3.5.3")
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
// 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
|
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
||||||
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
||||||
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
android:usesPermissionFlags="neverForLocation"
|
android:usesPermissionFlags="neverForLocation"
|
||||||
tools:targetApi="s" />
|
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. -->
|
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.ledgrab.android
|
package com.ledgrab.android
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,8 +26,23 @@ import java.security.SecureRandom
|
|||||||
*/
|
*/
|
||||||
class ApiKeyManager(context: Context) {
|
class ApiKeyManager(context: Context) {
|
||||||
|
|
||||||
private val prefs = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
|
// 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
|
// Once we've materialised a key in this process, cache it so
|
||||||
// subsequent reads don't hit prefs and don't risk re-checking
|
// subsequent reads don't hit prefs and don't risk re-checking
|
||||||
@@ -60,6 +78,20 @@ class ApiKeyManager(context: Context) {
|
|||||||
cached = existing
|
cached = existing
|
||||||
return 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()
|
val generated = generateKey()
|
||||||
// commit() (synchronous disk write) on the FIRST write so
|
// commit() (synchronous disk write) on the FIRST write so
|
||||||
// the key is durable before MainActivity encodes it into a
|
// 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 {
|
private fun generateKey(): String {
|
||||||
val bytes = ByteArray(KEY_BYTES)
|
val bytes = ByteArray(KEY_BYTES)
|
||||||
SecureRandom().nextBytes(bytes)
|
SecureRandom().nextBytes(bytes)
|
||||||
@@ -88,7 +229,11 @@ class ApiKeyManager(context: Context) {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ApiKeyManager"
|
private const val TAG = "ApiKeyManager"
|
||||||
private const val PREFS_NAME = "ledgrab_auth"
|
private const val PREFS_NAME = "ledgrab_auth"
|
||||||
|
private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
|
||||||
private const val KEY_API_KEY = "api_key"
|
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 KEY_BYTES = 32
|
||||||
private const val MIN_KEY_LENGTH = 32
|
private const val MIN_KEY_LENGTH = 32
|
||||||
|
|
||||||
|
|||||||
@@ -103,12 +103,32 @@ object BleBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)
|
Thread.sleep(timeoutMs)
|
||||||
} catch (_: InterruptedException) {
|
} catch (_: InterruptedException) {
|
||||||
Thread.currentThread().interrupt()
|
Thread.currentThread().interrupt()
|
||||||
} finally {
|
} 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()
|
return seen.values.toList()
|
||||||
}
|
}
|
||||||
@@ -136,7 +156,18 @@ object BleBridge {
|
|||||||
newState == BluetoothProfile.STATE_CONNECTED
|
newState == BluetoothProfile.STATE_CONNECTED
|
||||||
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
||||||
Log.d(TAG, "GATT connected to $address, discovering services")
|
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 -> {
|
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
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 {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||||
|
|
||||||
// CRITICAL: startForeground must be called IMMEDIATELY — before
|
// CRITICAL (Android 14+): for the MediaProjection path, validate the
|
||||||
// any other work, especially before getMediaProjection(). The
|
// projection token BEFORE promoting to a foreground service with the
|
||||||
// service type must match the work; pass it explicitly via
|
// mediaProjection FGS type. On service recreation (system redelivery
|
||||||
// ServiceCompat so we stay compatible back to API 24.
|
// 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 localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
||||||
val url = "http://$localIp:$SERVER_PORT"
|
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 {
|
try {
|
||||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
var t = if (useRoot) {
|
var t = if (useRoot) {
|
||||||
@@ -152,20 +188,13 @@ class CaptureService : Service() {
|
|||||||
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
||||||
isRunning = true
|
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 {
|
try {
|
||||||
if (useRoot) {
|
if (useRoot) {
|
||||||
startRootCapture(url)
|
startRootCapture(url)
|
||||||
} else {
|
} else {
|
||||||
startMediaProjectionCapture(intent!!, url)
|
// mediaProjectionResultData is guaranteed non-null here — the
|
||||||
|
// token was validated before startForeground above.
|
||||||
|
startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start capture", e)
|
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")
|
@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)
|
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||||
} else {
|
} else {
|
||||||
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (resultData == null) {
|
private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) {
|
||||||
Log.e(TAG, "No MediaProjection result data")
|
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||||
stopSelf()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val projectionManager =
|
val projectionManager =
|
||||||
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import android.service.notification.StatusBarNotification
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.chaquo.python.Python
|
import com.chaquo.python.Python
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.RejectedExecutionException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures posted OS notifications and forwards the posting app's display
|
* 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
|
// 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
|
// disk write and may play a sound, so pushes must not overlap. Off the main
|
||||||
// looper to keep the system service responsive.
|
// 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
|
// packageName -> resolved human-readable label. Matches the app_name the
|
||||||
// Windows/Linux backends pass, so per-app colors/filters keep working.
|
// Windows/Linux backends pass, so per-app colors/filters keep working.
|
||||||
@@ -51,17 +72,37 @@ class LedGrabNotificationListener : NotificationListenerService() {
|
|||||||
|
|
||||||
val label = resolveAppLabel(notification.packageName)
|
val label = resolveAppLabel(notification.packageName)
|
||||||
|
|
||||||
pushExecutor.execute {
|
// Obtain (creating if needed) the executor. onListenerConnected normally
|
||||||
try {
|
// creates it, but that callback is not reliably invoked on every
|
||||||
Python.getInstance()
|
// OEM/version (re)bind, and a notification can arrive before it fires —
|
||||||
.getModule(PY_MODULE)
|
// lazily creating here keeps a missing/late onListenerConnected from
|
||||||
.callAttr("push_notification", label)
|
// permanently disabling notification forwarding. A late submit onto an
|
||||||
} catch (t: Throwable) {
|
// executor that onListenerDisconnected is shutting down throws
|
||||||
// Never crash a system-bound service. Python.getInstance() throws
|
// RejectedExecutionException — guard with runCatching so a notification
|
||||||
// IllegalStateException if Python.start() hasn't run (e.g. the
|
// racing teardown can never crash this system-bound service.
|
||||||
// service was bound at boot before the app process initialized).
|
val executor = ensureExecutor() ?: run {
|
||||||
// Log at debug — the label is potentially sensitive on a shared TV.
|
Log.d(TAG, "no executor (listener disconnected) — skipping push")
|
||||||
Log.d(TAG, "push_notification failed: ${t.message}")
|
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. */
|
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
|
||||||
private fun resolveAppLabel(pkg: String): String {
|
private fun resolveAppLabel(pkg: String): String {
|
||||||
labelCache[pkg]?.let { return it }
|
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 resolved = runCatching {
|
||||||
val info = packageManager.getApplicationInfo(pkg, 0)
|
val info = packageManager.getApplicationInfo(pkg, 0)
|
||||||
packageManager.getApplicationLabel(info).toString()
|
packageManager.getApplicationLabel(info).toString()
|
||||||
}.getOrDefault(pkg)
|
}.getOrNull()
|
||||||
labelCache[pkg] = resolved
|
if (resolved != null) {
|
||||||
return resolved
|
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() {
|
override fun onListenerConnected() {
|
||||||
Log.i(TAG, "Notification listener connected")
|
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() {
|
override fun onListenerDisconnected() {
|
||||||
Log.i(TAG, "Notification listener disconnected")
|
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() {
|
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()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,7 @@ class MainActivity : Activity() {
|
|||||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||||
private const val REQUEST_RECORD_AUDIO = 1003
|
private const val REQUEST_RECORD_AUDIO = 1003
|
||||||
private const val REQUEST_CAMERA = 1004
|
private const val REQUEST_CAMERA = 1004
|
||||||
|
private const val REQUEST_BLUETOOTH = 1005
|
||||||
private const val QR_SIZE_PX = 560
|
private const val QR_SIZE_PX = 560
|
||||||
private const val NOTIF_PREFS = "ledgrab_notif"
|
private const val NOTIF_PREFS = "ledgrab_notif"
|
||||||
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
|
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
|
||||||
@@ -189,7 +191,13 @@ class MainActivity : Activity() {
|
|||||||
toggleButton.text = getString(R.string.btn_starting)
|
toggleButton.text = getString(R.string.btn_starting)
|
||||||
statusText.text = getString(R.string.status_checking_root)
|
statusText.text = getString(R.string.status_checking_root)
|
||||||
uiScope.launch(Dispatchers.IO) {
|
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) {
|
withContext(Dispatchers.Main) {
|
||||||
toggleButton.isEnabled = true
|
toggleButton.isEnabled = true
|
||||||
toggleButton.text = originalText
|
toggleButton.text = originalText
|
||||||
@@ -214,6 +222,7 @@ class MainActivity : Activity() {
|
|||||||
ensureNotificationPermission()
|
ensureNotificationPermission()
|
||||||
ensureNotificationListenerAccess()
|
ensureNotificationListenerAccess()
|
||||||
ensureCameraPermission()
|
ensureCameraPermission()
|
||||||
|
ensureBluetoothPermissions()
|
||||||
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
||||||
updateUI()
|
updateUI()
|
||||||
}
|
}
|
||||||
@@ -236,6 +245,7 @@ class MainActivity : Activity() {
|
|||||||
ensureNotificationListenerAccess()
|
ensureNotificationListenerAccess()
|
||||||
ensureAudioPermission()
|
ensureAudioPermission()
|
||||||
ensureCameraPermission()
|
ensureCameraPermission()
|
||||||
|
ensureBluetoothPermissions()
|
||||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||||
ContextCompat.startForegroundService(this, intent)
|
ContextCompat.startForegroundService(this, intent)
|
||||||
updateUI()
|
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. */
|
/** Whether the user has granted notification-listener access to this app. */
|
||||||
private fun isNotificationAccessGranted(): Boolean =
|
private fun isNotificationAccessGranted(): Boolean =
|
||||||
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
|
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit
|
|||||||
object Root {
|
object Root {
|
||||||
private const val TAG = "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(
|
private val SU_PATHS = listOf(
|
||||||
"/system/bin/su",
|
"/system/bin/su",
|
||||||
"/system/xbin/su",
|
"/system/xbin/su",
|
||||||
@@ -49,17 +54,19 @@ object Root {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var process: Process? = null
|
||||||
val granted = try {
|
val granted = try {
|
||||||
// redirectErrorStream merges stderr into stdout so a single
|
// redirectErrorStream merges stderr into stdout so a single
|
||||||
// drain thread is enough — avoids the classic pipe-buffer
|
// drain thread is enough — avoids the classic pipe-buffer
|
||||||
// deadlock where waitFor() blocks because stderr filled up.
|
// deadlock where waitFor() blocks because stderr filled up.
|
||||||
val process = ProcessBuilder("su", "-c", "id")
|
val proc = ProcessBuilder("su", "-c", "id")
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start()
|
.start()
|
||||||
|
process = proc
|
||||||
val outputBuilder = StringBuilder()
|
val outputBuilder = StringBuilder()
|
||||||
val drain = Thread({
|
val drain = Thread({
|
||||||
try {
|
try {
|
||||||
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
|
BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
|
||||||
val buf = CharArray(512)
|
val buf = CharArray(512)
|
||||||
while (true) {
|
while (true) {
|
||||||
val n = r.read(buf)
|
val n = r.read(buf)
|
||||||
@@ -72,17 +79,35 @@ object Root {
|
|||||||
}
|
}
|
||||||
}, "Root-su-drain").apply { isDaemon = true; start() }
|
}, "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) {
|
if (!finished) {
|
||||||
process.destroyForcibly()
|
proc.destroyForcibly()
|
||||||
drain.join(500)
|
drain.join(500)
|
||||||
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
drain.join(500)
|
drain.join(500)
|
||||||
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
||||||
if (process.exitValue() != 0) {
|
if (proc.exitValue() != 0) {
|
||||||
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
|
Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'")
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
val rooted = output.contains("uid=0")
|
val rooted = output.contains("uid=0")
|
||||||
@@ -90,8 +115,17 @@ object Root {
|
|||||||
rooted
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "su invocation failed: ${e.message}")
|
Log.w(TAG, "su invocation failed: ${e.message}")
|
||||||
|
runCatching { process?.destroyForcibly() }
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,14 +89,15 @@ class RootScreenrecord(
|
|||||||
running = true
|
running = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
imageReader = buildImageReader()
|
val reader = buildImageReader().also { imageReader = it }
|
||||||
decoder = buildDecoder(imageReader!!)
|
val codec = buildDecoder(reader).also { decoder = it }
|
||||||
process = spawnScreenrecord() ?: run {
|
val proc = spawnScreenrecord() ?: run {
|
||||||
stop()
|
stop()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
startInputPump(process!!.inputStream, decoder!!)
|
process = proc
|
||||||
startOutputDrain(decoder!!)
|
startInputPump(proc.inputStream, codec)
|
||||||
|
startOutputDrain(codec)
|
||||||
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -178,6 +179,14 @@ class RootScreenrecord(
|
|||||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
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)
|
bridge.pushRootFrame(frameBuffer, width, height)
|
||||||
framesDeliveredCounter.incrementAndGet()
|
framesDeliveredCounter.incrementAndGet()
|
||||||
} catch (e: Exception) {
|
} 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)
|
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
||||||
|
|
||||||
// Advance the pacing accumulator. If we fell badly behind
|
// Advance the pacing accumulator. If we fell badly behind
|
||||||
|
|||||||
+7
-1
@@ -31,9 +31,15 @@ Creates the Gitea release with a description table listing all artifacts. **The
|
|||||||
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
|
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
|
||||||
|
|
||||||
### 4. `build-docker`
|
### 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}`
|
- Registry: `{gitea_host}/{repo}:{tag}`
|
||||||
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
|
- 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
|
## Build Scripts
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
- 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).
|
- `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
|
## Common Tasks
|
||||||
|
|
||||||
### Adding a new API endpoint
|
### Adding a new API endpoint
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ auth:
|
|||||||
# api_keys:
|
# api_keys:
|
||||||
# my-client: "replace-with-output-of-openssl-rand-hex-32"
|
# 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.
|
# 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
|
# 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
|
# (the whole dir — both the database and assets), or uncomment the block
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ledgrab"
|
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"
|
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from .routes.snapshot import router as snapshot_router
|
|||||||
from .routes.graph import router as graph_router
|
from .routes.graph import router as graph_router
|
||||||
from .routes.calibration import router as calibration_router
|
from .routes.calibration import router as calibration_router
|
||||||
from .routes.setup import router as setup_router
|
from .routes.setup import router as setup_router
|
||||||
|
from .routes.activity_log import router as activity_log_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -76,5 +77,6 @@ router.include_router(snapshot_router)
|
|||||||
router.include_router(graph_router)
|
router.include_router(graph_router)
|
||||||
router.include_router(calibration_router)
|
router.include_router(calibration_router)
|
||||||
router.include_router(setup_router)
|
router.include_router(setup_router)
|
||||||
|
router.include_router(activity_log_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -3,18 +3,137 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, Security, status
|
from fastapi import Depends, HTTPException, Request, Security, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from ledgrab.config import get_config
|
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 import get_logger
|
||||||
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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 scheme for Bearer token
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
@@ -49,7 +168,7 @@ def _is_loopback(host: str | None) -> bool:
|
|||||||
return _classify_is_loopback(host)
|
return _classify_is_loopback(host)
|
||||||
|
|
||||||
|
|
||||||
def verify_api_key(
|
async def verify_api_key(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -63,6 +182,13 @@ def verify_api_key(
|
|||||||
LAN access requires an API key).
|
LAN access requires an API key).
|
||||||
- When API keys ARE configured, valid Bearer credentials are required.
|
- 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:
|
Args:
|
||||||
request: incoming request (used to read client host)
|
request: incoming request (used to read client host)
|
||||||
credentials: HTTP authorization credentials
|
credentials: HTTP authorization credentials
|
||||||
@@ -81,10 +207,12 @@ def verify_api_key(
|
|||||||
# No keys configured — allow loopback only.
|
# No keys configured — allow loopback only.
|
||||||
if _is_loopback(client_host):
|
if _is_loopback(client_host):
|
||||||
request.state.auth_label = "anonymous"
|
request.state.auth_label = "anonymous"
|
||||||
|
current_actor.set("anonymous")
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
# Allow caller to authenticate explicitly even without configured keys?
|
# Allow caller to authenticate explicitly even without configured keys?
|
||||||
# No — there are no keys to compare against. Reject.
|
# No — there are no keys to compare against. Reject.
|
||||||
logger.warning("Rejected LAN request from %s: no API key configured", client_host)
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail=(
|
detail=(
|
||||||
@@ -97,24 +225,32 @@ def verify_api_key(
|
|||||||
# Check if credentials are provided
|
# Check if credentials are provided
|
||||||
if not credentials:
|
if not credentials:
|
||||||
logger.warning("Request missing Authorization header")
|
logger.warning("Request missing Authorization header")
|
||||||
|
_record_auth_failure("missing Bearer token", client_host)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Missing API key - authentication is required",
|
detail="Missing API key - authentication is required",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract token
|
# Extract token — NEVER log or record the token value itself.
|
||||||
token = credentials.credentials
|
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
|
authenticated_as = None
|
||||||
for label, api_key in config.auth.api_keys.items():
|
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
|
authenticated_as = label
|
||||||
break
|
break
|
||||||
|
|
||||||
if not authenticated_as:
|
if not authenticated_as:
|
||||||
logger.warning("Invalid API key attempt")
|
logger.warning("Invalid API key attempt")
|
||||||
|
_record_auth_failure("invalid Bearer token", client_host)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid API key",
|
detail="Invalid API key",
|
||||||
@@ -127,6 +263,9 @@ def verify_api_key(
|
|||||||
# Stash the friendly label so the access-log middleware can attribute the
|
# Stash the friendly label so the access-log middleware can attribute the
|
||||||
# request to a client without re-running the token comparison.
|
# request to a client without re-running the token comparison.
|
||||||
request.state.auth_label = authenticated_as
|
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
|
return authenticated_as
|
||||||
|
|
||||||
|
|
||||||
@@ -135,6 +274,31 @@ def verify_api_key(
|
|||||||
AuthRequired = Annotated[str, Depends(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:
|
def require_authenticated(label: str) -> None:
|
||||||
"""Reject the anonymous (loopback) auth label.
|
"""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
|
# a strong signal even before the token check. Non-browser clients
|
||||||
# legitimately omit Origin; those fall through to the auth handshake.
|
# legitimately omit Origin; those fall through to the auth handshake.
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
client_host = websocket.client.host if websocket.client else None
|
||||||
origin = websocket.headers.get("origin")
|
origin = websocket.headers.get("origin")
|
||||||
if not _is_origin_allowed(origin, config.server.cors_origins):
|
if not _is_origin_allowed(origin, config.server.cors_origins):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Rejected WebSocket from origin %r (not in cors_origins)",
|
"Rejected WebSocket from origin %r (not in cors_origins)",
|
||||||
origin,
|
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:
|
try:
|
||||||
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
||||||
except _WS_SEND_BENIGN_EXC:
|
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:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
_record_ws_auth_success(label, client_host)
|
||||||
return label
|
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:
|
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()
|
config = get_config()
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
token_b = token.encode("utf-8")
|
||||||
for label, api_key in config.auth.api_keys.items():
|
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 label
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -275,6 +465,7 @@ async def verify_ws_auth(
|
|||||||
return None
|
return None
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
||||||
|
_record_auth_failure("WebSocket auth timeout", client_host)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
||||||
except _WS_SEND_BENIGN_EXC:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
@@ -332,6 +523,7 @@ async def verify_ws_auth(
|
|||||||
await websocket.send_json({"type": "auth_ok"})
|
await websocket.send_json({"type": "auth_ok"})
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
|
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:
|
try:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
@@ -343,10 +535,11 @@ async def verify_ws_auth(
|
|||||||
pass
|
pass
|
||||||
return None
|
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 "")
|
label = _match_api_key(token or "")
|
||||||
if not label:
|
if not label:
|
||||||
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
||||||
|
_record_auth_failure("invalid WebSocket token", client_host)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
||||||
except _WS_SEND_BENIGN_EXC:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
|
|||||||
@@ -37,11 +37,17 @@ from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
|||||||
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||||
|
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
|
||||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||||
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
||||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||||
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||||
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
||||||
|
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")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -168,6 +174,15 @@ def get_game_event_bus() -> GameEventBus:
|
|||||||
return _get("game_event_bus", "Game event bus")
|
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:
|
def get_mqtt_store() -> MQTTSourceStore:
|
||||||
return _get("mqtt_store", "MQTT source store")
|
return _get("mqtt_store", "MQTT source store")
|
||||||
|
|
||||||
@@ -196,16 +211,87 @@ def get_update_service() -> UpdateService:
|
|||||||
return _get("update_service", "Update service")
|
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 ────────────────────────────────────────────────────────
|
# ── Event helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
# entity_type → (_deps key, store method name) for human-name resolution.
|
||||||
"""Fire an entity_changed event via the ProcessorManager event bus.
|
# 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:
|
Args:
|
||||||
entity_type: e.g. "device", "output_target", "color_strip_source"
|
entity_type: e.g. "device", "output_target", "color_strip_source"
|
||||||
action: "created", "updated", or "deleted"
|
action: "created", "updated", or "deleted"
|
||||||
entity_id: The entity's unique ID
|
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")
|
pm = _deps.get("processor_manager")
|
||||||
if pm is not None:
|
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 ──────────────────────────────────────────────────────
|
# ── Initialization ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -252,11 +370,15 @@ def init_dependencies(
|
|||||||
ha_manager: HomeAssistantManager | None = None,
|
ha_manager: HomeAssistantManager | None = None,
|
||||||
game_integration_store: GameIntegrationStore | None = None,
|
game_integration_store: GameIntegrationStore | None = None,
|
||||||
game_event_bus: GameEventBus | None = None,
|
game_event_bus: GameEventBus | None = None,
|
||||||
|
lol_poll_manager: LoLPollManager | None = None,
|
||||||
mqtt_store: MQTTSourceStore | None = None,
|
mqtt_store: MQTTSourceStore | None = None,
|
||||||
mqtt_manager: MQTTManager | None = None,
|
mqtt_manager: MQTTManager | None = None,
|
||||||
http_endpoint_store: HTTPEndpointStore | None = None,
|
http_endpoint_store: HTTPEndpointStore | None = None,
|
||||||
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
|
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
|
||||||
pattern_template_store: PatternTemplateStore | None = None,
|
pattern_template_store: PatternTemplateStore | None = None,
|
||||||
|
activity_recorder: ActivityRecorder | None = None,
|
||||||
|
activity_log_repo: ActivityLogRepository | None = None,
|
||||||
|
activity_log_retention_engine: ActivityLogRetentionEngine | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
_deps.update(
|
_deps.update(
|
||||||
@@ -290,10 +412,14 @@ def init_dependencies(
|
|||||||
"ha_manager": ha_manager,
|
"ha_manager": ha_manager,
|
||||||
"game_integration_store": game_integration_store,
|
"game_integration_store": game_integration_store,
|
||||||
"game_event_bus": game_event_bus,
|
"game_event_bus": game_event_bus,
|
||||||
|
"lol_poll_manager": lol_poll_manager,
|
||||||
"mqtt_store": mqtt_store,
|
"mqtt_store": mqtt_store,
|
||||||
"mqtt_manager": mqtt_manager,
|
"mqtt_manager": mqtt_manager,
|
||||||
"http_endpoint_store": http_endpoint_store,
|
"http_endpoint_store": http_endpoint_store,
|
||||||
"audio_processing_template_store": audio_processing_template_store,
|
"audio_processing_template_store": audio_processing_template_store,
|
||||||
"pattern_template_store": pattern_template_store,
|
"pattern_template_store": pattern_template_store,
|
||||||
|
"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),
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
):
|
):
|
||||||
"""Delete an audio source."""
|
"""Delete an audio source."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_source(source_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if any CSS entities reference this audio source
|
# Check if any CSS entities reference this audio source
|
||||||
from ledgrab.storage.color_strip_source import AudioColorStripSource
|
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}'")
|
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
|
||||||
|
|
||||||
store.delete_source(source_id)
|
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:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -12,28 +12,35 @@ from ledgrab.api.dependencies import (
|
|||||||
get_scene_preset_store,
|
get_scene_preset_store,
|
||||||
)
|
)
|
||||||
from ledgrab.api.schemas.automations import (
|
from ledgrab.api.schemas.automations import (
|
||||||
|
ActionSchema,
|
||||||
AutomationCreate,
|
AutomationCreate,
|
||||||
AutomationListResponse,
|
AutomationListResponse,
|
||||||
AutomationResponse,
|
AutomationResponse,
|
||||||
|
AutomationTriggerResponse,
|
||||||
AutomationUpdate,
|
AutomationUpdate,
|
||||||
RuleSchema,
|
RuleSchema,
|
||||||
)
|
)
|
||||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||||
from ledgrab.storage.automation import (
|
from ledgrab.storage.automation import (
|
||||||
|
Action,
|
||||||
ApplicationRule,
|
ApplicationRule,
|
||||||
DisplayStateRule,
|
DisplayStateRule,
|
||||||
HomeAssistantRule,
|
HomeAssistantRule,
|
||||||
HTTPPollRule,
|
HTTPPollRule,
|
||||||
|
ManualTriggerRule,
|
||||||
MQTTRule,
|
MQTTRule,
|
||||||
Rule,
|
Rule,
|
||||||
|
SolarRule,
|
||||||
StartupRule,
|
StartupRule,
|
||||||
SystemIdleRule,
|
SystemIdleRule,
|
||||||
TimeOfDayRule,
|
TimeOfDayRule,
|
||||||
|
WebhookAction,
|
||||||
WebhookRule,
|
WebhookRule,
|
||||||
)
|
)
|
||||||
from ledgrab.storage.automation_store import AutomationStore
|
from ledgrab.storage.automation_store import AutomationStore
|
||||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.safe_source import validate_polling_url
|
||||||
from ledgrab.storage.base_store import EntityNotFoundError
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -55,6 +62,20 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
|
|||||||
days_of_week=s.days_of_week or [],
|
days_of_week=s.days_of_week or [],
|
||||||
timezone=s.timezone 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(
|
"system_idle": lambda: SystemIdleRule(
|
||||||
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
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,
|
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),
|
token=s.token or secrets.token_hex(16),
|
||||||
),
|
),
|
||||||
"startup": lambda: StartupRule(),
|
"startup": lambda: StartupRule(),
|
||||||
|
"manual_trigger": lambda: ManualTriggerRule(),
|
||||||
"home_assistant": lambda: HomeAssistantRule(
|
"home_assistant": lambda: HomeAssistantRule(
|
||||||
ha_source_id=s.ha_source_id or "",
|
ha_source_id=s.ha_source_id or "",
|
||||||
entity_id=s.entity_id or "",
|
entity_id=s.entity_id or "",
|
||||||
@@ -95,6 +117,43 @@ def _rule_to_schema(r: Rule) -> RuleSchema:
|
|||||||
return RuleSchema(**d)
|
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(
|
def _automation_to_response(
|
||||||
automation, engine: AutomationEngine, request: Request = None
|
automation, engine: AutomationEngine, request: Request = None
|
||||||
) -> AutomationResponse:
|
) -> AutomationResponse:
|
||||||
@@ -130,6 +189,7 @@ def _automation_to_response(
|
|||||||
last_activated_at=state.get("last_activated_at"),
|
last_activated_at=state.get("last_activated_at"),
|
||||||
last_deactivated_at=state.get("last_deactivated_at"),
|
last_deactivated_at=state.get("last_deactivated_at"),
|
||||||
tags=automation.tags,
|
tags=automation.tags,
|
||||||
|
actions=[_action_to_schema(a) for a in getattr(automation, "actions", [])],
|
||||||
icon=getattr(automation, "icon", "") or "",
|
icon=getattr(automation, "icon", "") or "",
|
||||||
icon_color=getattr(automation, "icon_color", "") or "",
|
icon_color=getattr(automation, "icon_color", "") or "",
|
||||||
created_at=automation.created_at,
|
created_at=automation.created_at,
|
||||||
@@ -186,6 +246,7 @@ async def create_automation(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
rules = [_rule_from_schema(r) for r in data.rules]
|
rules = [_rule_from_schema(r) for r in data.rules]
|
||||||
|
actions = [_action_from_schema(a) for a in data.actions]
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
@@ -201,6 +262,7 @@ async def create_automation(
|
|||||||
deactivation_mode=data.deactivation_mode,
|
deactivation_mode=data.deactivation_mode,
|
||||||
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
|
actions=actions,
|
||||||
icon=data.icon,
|
icon=data.icon,
|
||||||
icon_color=data.icon_color,
|
icon_color=data.icon_color,
|
||||||
)
|
)
|
||||||
@@ -283,6 +345,13 @@ async def update_automation(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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:
|
try:
|
||||||
# If disabling, deactivate first
|
# If disabling, deactivate first
|
||||||
if data.enabled is False:
|
if data.enabled is False:
|
||||||
@@ -297,6 +366,7 @@ async def update_automation(
|
|||||||
rules=rules,
|
rules=rules,
|
||||||
deactivation_mode=data.deactivation_mode,
|
deactivation_mode=data.deactivation_mode,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
|
actions=actions,
|
||||||
icon=data.icon,
|
icon=data.icon,
|
||||||
icon_color=data.icon_color,
|
icon_color=data.icon_color,
|
||||||
)
|
)
|
||||||
@@ -329,6 +399,12 @@ async def delete_automation(
|
|||||||
engine: AutomationEngine = Depends(get_automation_engine),
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
):
|
):
|
||||||
"""Delete an automation."""
|
"""Delete an automation."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_automation(automation_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Deactivate first
|
# Deactivate first
|
||||||
await engine.deactivate_if_active(automation_id)
|
await engine.deactivate_if_active(automation_id)
|
||||||
|
|
||||||
@@ -337,7 +413,7 @@ async def delete_automation(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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 =====
|
# ===== Enable/Disable =====
|
||||||
@@ -388,3 +464,37 @@ async def disable_automation(
|
|||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
return _automation_to_response(automation, engine, request)
|
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)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from ledgrab.api.schemas.system import (
|
|||||||
)
|
)
|
||||||
from ledgrab.config import get_config
|
from ledgrab.config import get_config
|
||||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.storage.asset_store import AssetStore
|
from ledgrab.storage.asset_store import AssetStore
|
||||||
from ledgrab.storage.database import Database, freeze_writes
|
from ledgrab.storage.database import Database, freeze_writes
|
||||||
from ledgrab.utils import get_logger, read_upload_capped
|
from ledgrab.utils import get_logger, read_upload_capped
|
||||||
@@ -35,6 +36,22 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
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]
|
_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")
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||||
filename = f"ledgrab-backup-{timestamp}.zip"
|
filename = f"ledgrab-backup-{timestamp}.zip"
|
||||||
|
|
||||||
|
_record_system("backup.created", f"Backup downloaded: {filename}", {"filename": filename})
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
zip_buffer,
|
zip_buffer,
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
@@ -243,6 +262,7 @@ async def restore_config(
|
|||||||
|
|
||||||
freeze_writes()
|
freeze_writes()
|
||||||
logger.info("Database restored from uploaded backup. Scheduling restart...")
|
logger.info("Database restored from uploaded backup. Scheduling restart...")
|
||||||
|
_record_system("backup.restored", "Database restored from uploaded backup")
|
||||||
_schedule_restart()
|
_schedule_restart()
|
||||||
|
|
||||||
return RestoreResponse(
|
return RestoreResponse(
|
||||||
@@ -257,6 +277,7 @@ def restart_server(_: AuthRequired):
|
|||||||
"""Schedule a server restart and return immediately."""
|
"""Schedule a server restart and return immediately."""
|
||||||
from ledgrab.server_ref import _broadcast_restarting
|
from ledgrab.server_ref import _broadcast_restarting
|
||||||
|
|
||||||
|
_record_system("server.restarting", "Server restart requested by user")
|
||||||
_broadcast_restarting()
|
_broadcast_restarting()
|
||||||
_schedule_restart()
|
_schedule_restart()
|
||||||
return {"status": "restarting"}
|
return {"status": "restarting"}
|
||||||
@@ -267,6 +288,7 @@ def shutdown_server(_: AuthRequired):
|
|||||||
"""Gracefully shut down the server."""
|
"""Gracefully shut down the server."""
|
||||||
from ledgrab.server_ref import request_shutdown
|
from ledgrab.server_ref import request_shutdown
|
||||||
|
|
||||||
|
_record_system("server.shutdown_requested", "Server shutdown requested by user")
|
||||||
request_shutdown()
|
request_shutdown()
|
||||||
return {"status": "shutting_down"}
|
return {"status": "shutting_down"}
|
||||||
|
|
||||||
@@ -300,11 +322,17 @@ async def update_auto_backup_settings(
|
|||||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||||
):
|
):
|
||||||
"""Update auto-backup settings (enable/disable, interval, max backups)."""
|
"""Update auto-backup settings (enable/disable, interval, max backups)."""
|
||||||
return await engine.update_settings(
|
result = await engine.update_settings(
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
interval_hours=body.interval_hours,
|
interval_hours=body.interval_hours,
|
||||||
max_backups=body.max_backups,
|
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"])
|
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
|
||||||
@@ -365,4 +393,5 @@ async def delete_saved_backup(
|
|||||||
engine.delete_backup(filename)
|
engine.delete_backup(filename)
|
||||||
except (ValueError, FileNotFoundError) as e:
|
except (ValueError, FileNotFoundError) as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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}
|
return {"status": "deleted", "filename": filename}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
|
|
||||||
from ledgrab.api.auth import AuthRequired
|
from ledgrab.api.auth import AuthRequired
|
||||||
from ledgrab.api.dependencies import get_processor_manager
|
from ledgrab.api.dependencies import get_processor_manager
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.api.schemas.calibration import (
|
from ledgrab.api.schemas.calibration import (
|
||||||
CalibrationSessionPositionRequest,
|
CalibrationSessionPositionRequest,
|
||||||
CalibrationSessionStartRequest,
|
CalibrationSessionStartRequest,
|
||||||
@@ -81,6 +82,19 @@ async def start_calibration_session(
|
|||||||
logger.error("Failed to start calibration session: %s", exc, exc_info=True)
|
logger.error("Failed to start calibration session: %s", exc, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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())
|
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)
|
logger.error("Failed to stop calibration session: %s", exc, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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())
|
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)
|
logger.error("Failed to cancel calibration session: %s", exc, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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())
|
return CalibrationSessionStateResponse(**session.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,12 @@ async def delete_color_strip_source(
|
|||||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
):
|
):
|
||||||
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
|
"""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:
|
try:
|
||||||
target_names = target_store.get_targets_referencing_css(source_id)
|
target_names = target_store.get_targets_referencing_css(source_id)
|
||||||
if target_names:
|
if target_names:
|
||||||
@@ -201,7 +207,7 @@ async def delete_color_strip_source(
|
|||||||
"Delete or reassign the processed source(s) first.",
|
"Delete or reassign the processed source(s) first.",
|
||||||
)
|
)
|
||||||
store.delete_source(source_id)
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -96,13 +96,16 @@ def _device_to_response(device) -> DeviceResponse:
|
|||||||
espnow_channel=device.espnow_channel,
|
espnow_channel=device.espnow_channel,
|
||||||
hue_paired=bool(device.hue_username and device.hue_client_key),
|
hue_paired=bool(device.hue_username and device.hue_client_key),
|
||||||
hue_entertainment_group_id=device.hue_entertainment_group_id,
|
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,
|
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
|
||||||
wiz_min_interval_ms=device.wiz_min_interval_ms,
|
wiz_min_interval_ms=device.wiz_min_interval_ms,
|
||||||
lifx_min_interval_ms=device.lifx_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,
|
govee_min_interval_ms=device.govee_min_interval_ms,
|
||||||
opc_channel=device.opc_channel,
|
opc_channel=device.opc_channel,
|
||||||
nanoleaf_paired=bool(device.nanoleaf_token),
|
nanoleaf_paired=bool(device.nanoleaf_token),
|
||||||
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
|
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
|
||||||
|
nanoleaf_per_panel=device.nanoleaf_per_panel,
|
||||||
spi_speed_hz=device.spi_speed_hz,
|
spi_speed_hz=device.spi_speed_hz,
|
||||||
spi_led_type=device.spi_led_type,
|
spi_led_type=device.spi_led_type,
|
||||||
chroma_device_type=device.chroma_device_type,
|
chroma_device_type=device.chroma_device_type,
|
||||||
@@ -261,6 +264,9 @@ async def create_device(
|
|||||||
hue_username=device_data.hue_username or "",
|
hue_username=device_data.hue_username or "",
|
||||||
hue_client_key=device_data.hue_client_key or "",
|
hue_client_key=device_data.hue_client_key or "",
|
||||||
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
|
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
|
||||||
|
hue_gradient_mode=(
|
||||||
|
device_data.hue_gradient_mode if device_data.hue_gradient_mode is not None else True
|
||||||
|
),
|
||||||
yeelight_min_interval_ms=(
|
yeelight_min_interval_ms=(
|
||||||
device_data.yeelight_min_interval_ms
|
device_data.yeelight_min_interval_ms
|
||||||
if device_data.yeelight_min_interval_ms is not None
|
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
|
if device_data.lifx_min_interval_ms is not None
|
||||||
else 50
|
else 50
|
||||||
),
|
),
|
||||||
|
lifx_per_zone=bool(device_data.lifx_per_zone),
|
||||||
govee_min_interval_ms=(
|
govee_min_interval_ms=(
|
||||||
device_data.govee_min_interval_ms
|
device_data.govee_min_interval_ms
|
||||||
if device_data.govee_min_interval_ms is not None
|
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
|
if device_data.nanoleaf_min_interval_ms is not None
|
||||||
else 100
|
else 100
|
||||||
),
|
),
|
||||||
|
nanoleaf_per_panel=bool(device_data.nanoleaf_per_panel),
|
||||||
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
||||||
spi_led_type=device_data.spi_led_type or "WS2812B",
|
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||||
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||||
@@ -631,13 +639,16 @@ async def update_device(
|
|||||||
hue_username=update_data.hue_username,
|
hue_username=update_data.hue_username,
|
||||||
hue_client_key=update_data.hue_client_key,
|
hue_client_key=update_data.hue_client_key,
|
||||||
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
||||||
|
hue_gradient_mode=update_data.hue_gradient_mode,
|
||||||
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
|
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
|
||||||
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
|
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
|
||||||
lifx_min_interval_ms=update_data.lifx_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,
|
govee_min_interval_ms=update_data.govee_min_interval_ms,
|
||||||
opc_channel=update_data.opc_channel,
|
opc_channel=update_data.opc_channel,
|
||||||
nanoleaf_token=update_data.nanoleaf_token,
|
nanoleaf_token=update_data.nanoleaf_token,
|
||||||
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
|
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_speed_hz=update_data.spi_speed_hz,
|
||||||
spi_led_type=update_data.spi_led_type,
|
spi_led_type=update_data.spi_led_type,
|
||||||
chroma_device_type=update_data.chroma_device_type,
|
chroma_device_type=update_data.chroma_device_type,
|
||||||
@@ -701,6 +712,13 @@ async def delete_device(
|
|||||||
):
|
):
|
||||||
"""Delete/detach a device. Returns 409 if referenced by a target."""
|
"""Delete/detach a device. Returns 409 if referenced by a target."""
|
||||||
try:
|
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
|
# Check if any target references this device
|
||||||
refs = target_store.get_targets_for_device(device_id)
|
refs = target_store.get_targets_for_device(device_id)
|
||||||
if refs:
|
if refs:
|
||||||
@@ -728,7 +746,7 @@ async def delete_device(
|
|||||||
# Delete from storage
|
# Delete from storage
|
||||||
store.delete_device(device_id)
|
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}")
|
logger.info(f"Deleted device {device_id}")
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ adapter metadata, and diagnostics.
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -16,6 +17,7 @@ from ledgrab.api.dependencies import (
|
|||||||
get_database,
|
get_database,
|
||||||
get_game_integration_store,
|
get_game_integration_store,
|
||||||
get_game_event_bus,
|
get_game_event_bus,
|
||||||
|
get_lol_poll_manager,
|
||||||
)
|
)
|
||||||
from ledgrab.api.schemas.game_integration import (
|
from ledgrab.api.schemas.game_integration import (
|
||||||
AdapterInfoResponse,
|
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.adapter_registry import AdapterRegistry
|
||||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
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.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.storage.game_integration_store import GameIntegrationStore
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
@@ -46,15 +55,77 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
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
|
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
|
||||||
_prev_states: dict[str, dict[str, Any]] = {}
|
#
|
||||||
|
# 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]]:
|
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
|
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 ────────────────────────────────────
|
# ── 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:
|
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
|
from ledgrab.api.schemas.game_integration import EventMappingSchema
|
||||||
|
|
||||||
return GameIntegrationResponse(
|
return GameIntegrationResponse(
|
||||||
@@ -142,7 +201,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
|
|||||||
name=config.name,
|
name=config.name,
|
||||||
adapter_type=config.adapter_type,
|
adapter_type=config.adapter_type,
|
||||||
enabled=config.enabled,
|
enabled=config.enabled,
|
||||||
adapter_config=config.adapter_config,
|
adapter_config=_redact_secrets(config.adapter_config),
|
||||||
event_mappings=[
|
event_mappings=[
|
||||||
EventMappingSchema(
|
EventMappingSchema(
|
||||||
event_type=m.event_type,
|
event_type=m.event_type,
|
||||||
@@ -234,6 +293,7 @@ async def create_integration(
|
|||||||
data: GameIntegrationCreate,
|
data: GameIntegrationCreate,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||||
|
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
|
||||||
):
|
):
|
||||||
"""Create a new game integration config."""
|
"""Create a new game integration config."""
|
||||||
try:
|
try:
|
||||||
@@ -262,6 +322,8 @@ async def create_integration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fire_entity_event("game_integration", "created", config.id)
|
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)
|
return _config_to_response(config)
|
||||||
|
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
@@ -301,6 +363,7 @@ async def update_integration(
|
|||||||
data: GameIntegrationUpdate,
|
data: GameIntegrationUpdate,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||||
|
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
|
||||||
):
|
):
|
||||||
"""Update a game integration config."""
|
"""Update a game integration config."""
|
||||||
try:
|
try:
|
||||||
@@ -318,12 +381,20 @@ async def update_integration(
|
|||||||
for m in data.event_mappings
|
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(
|
config = store.update_integration(
|
||||||
integration_id=integration_id,
|
integration_id=integration_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
adapter_type=data.adapter_type,
|
adapter_type=data.adapter_type,
|
||||||
enabled=data.enabled,
|
enabled=data.enabled,
|
||||||
adapter_config=data.adapter_config,
|
adapter_config=adapter_config,
|
||||||
event_mappings=mappings,
|
event_mappings=mappings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
@@ -332,6 +403,8 @@ async def update_integration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fire_entity_event("game_integration", "updated", integration_id)
|
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)
|
return _config_to_response(config)
|
||||||
|
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
@@ -352,11 +425,14 @@ async def delete_integration(
|
|||||||
integration_id: str,
|
integration_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||||
|
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
|
||||||
):
|
):
|
||||||
"""Delete a game integration config."""
|
"""Delete a game integration config."""
|
||||||
try:
|
try:
|
||||||
store.delete_integration(integration_id)
|
store.delete_integration(integration_id)
|
||||||
_cleanup_state(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)
|
fire_entity_event("game_integration", "deleted", integration_id)
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
@@ -387,7 +463,16 @@ async def ingest_event(
|
|||||||
called before standard API auth.
|
called before standard API auth.
|
||||||
|
|
||||||
No AuthRequired dependency — adapter-level auth is used instead.
|
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:
|
try:
|
||||||
config = store.get_integration(integration_id)
|
config = store.get_integration(integration_id)
|
||||||
except EntityNotFoundError:
|
except EntityNotFoundError:
|
||||||
@@ -402,9 +487,18 @@ async def ingest_event(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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)
|
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")
|
raise HTTPException(status_code=403, detail="Adapter authentication failed")
|
||||||
|
|
||||||
# Parse payload through adapter
|
# Parse payload through adapter
|
||||||
|
|||||||
@@ -152,13 +152,19 @@ async def delete_gradient(
|
|||||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
):
|
):
|
||||||
"""Delete a gradient (fails if built-in or referenced by sources)."""
|
"""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:
|
try:
|
||||||
# Check references
|
# Check references
|
||||||
for source in css_store.get_all_sources():
|
for source in css_store.get_all_sources():
|
||||||
if getattr(source, "gradient_id", None) == gradient_id:
|
if getattr(source, "gradient_id", None) == gradient_id:
|
||||||
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
||||||
store.delete_gradient(gradient_id)
|
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:
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
status = 404 if "not found" in str(e).lower() else 400
|
status = 404 if "not found" in str(e).lower() else 400
|
||||||
raise HTTPException(status_code=status, detail=str(e))
|
raise HTTPException(status_code=status, detail=str(e))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
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_source import HomeAssistantSource
|
||||||
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.net_classify import validate_lan_host
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -37,6 +39,23 @@ router = APIRouter()
|
|||||||
_REDACTED_TOKEN = "***"
|
_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(
|
def _to_response(
|
||||||
source: HomeAssistantSource,
|
source: HomeAssistantSource,
|
||||||
manager: HomeAssistantManager,
|
manager: HomeAssistantManager,
|
||||||
@@ -99,6 +118,7 @@ async def create_ha_source(
|
|||||||
manager: HomeAssistantManager = Depends(get_ha_manager),
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
_validate_ha_host(data.host)
|
||||||
source = store.create_source(
|
source = store.create_source(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
host=data.host,
|
host=data.host,
|
||||||
@@ -153,6 +173,7 @@ async def update_ha_source(
|
|||||||
manager: HomeAssistantManager = Depends(get_ha_manager),
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
_validate_ha_host(data.host)
|
||||||
source = store.update_source(
|
source = store.update_source(
|
||||||
source_id,
|
source_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
|
|||||||
password_set=bool(source.password),
|
password_set=bool(source.password),
|
||||||
client_id=source.client_id,
|
client_id=source.client_id,
|
||||||
base_topic=source.base_topic,
|
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,
|
connected=runtime.is_connected if runtime else False,
|
||||||
description=source.description,
|
description=source.description,
|
||||||
tags=source.tags,
|
tags=source.tags,
|
||||||
@@ -90,6 +92,8 @@ async def create_mqtt_source(
|
|||||||
password=data.password,
|
password=data.password,
|
||||||
client_id=data.client_id,
|
client_id=data.client_id,
|
||||||
base_topic=data.base_topic,
|
base_topic=data.base_topic,
|
||||||
|
publish_ha_discovery=data.publish_ha_discovery,
|
||||||
|
discovery_prefix=data.discovery_prefix,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
icon=data.icon,
|
icon=data.icon,
|
||||||
@@ -97,6 +101,8 @@ async def create_mqtt_source(
|
|||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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)
|
fire_entity_event("mqtt_source", "created", source.id)
|
||||||
return _to_response(source, manager)
|
return _to_response(source, manager)
|
||||||
|
|
||||||
@@ -141,6 +147,8 @@ async def update_mqtt_source(
|
|||||||
password=data.password,
|
password=data.password,
|
||||||
client_id=data.client_id,
|
client_id=data.client_id,
|
||||||
base_topic=data.base_topic,
|
base_topic=data.base_topic,
|
||||||
|
publish_ha_discovery=data.publish_ha_discovery,
|
||||||
|
discovery_prefix=data.discovery_prefix,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
icon=data.icon,
|
icon=data.icon,
|
||||||
@@ -151,6 +159,8 @@ async def update_mqtt_source(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
await manager.update_source(source_id)
|
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)
|
fire_entity_event("mqtt_source", "updated", source.id)
|
||||||
return _to_response(source, manager)
|
return _to_response(source, manager)
|
||||||
|
|
||||||
@@ -162,6 +172,9 @@ async def delete_mqtt_source(
|
|||||||
store: MQTTSourceStore = Depends(get_mqtt_store),
|
store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||||
manager: MQTTManager = Depends(get_mqtt_manager),
|
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:
|
try:
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
except EntityNotFoundError:
|
except EntityNotFoundError:
|
||||||
|
|||||||
@@ -624,6 +624,13 @@ async def delete_target(
|
|||||||
):
|
):
|
||||||
"""Delete a output target. Stops processing first if active."""
|
"""Delete a output target. Stops processing first if active."""
|
||||||
try:
|
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
|
# Stop processing if running
|
||||||
try:
|
try:
|
||||||
await manager.stop_processing(target_id)
|
await manager.stop_processing(target_id)
|
||||||
@@ -641,7 +648,7 @@ async def delete_target(
|
|||||||
# Delete from store
|
# Delete from store
|
||||||
target_store.delete_target(target_id)
|
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}")
|
logger.info(f"Deleted target {target_id}")
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ledgrab.api.dependencies import (
|
|||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
)
|
)
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.api.schemas.output_targets import (
|
from ledgrab.api.schemas.output_targets import (
|
||||||
BulkTargetRequest,
|
BulkTargetRequest,
|
||||||
BulkTargetResponse,
|
BulkTargetResponse,
|
||||||
@@ -28,6 +29,7 @@ from ledgrab.storage.color_strip_source import (
|
|||||||
from ledgrab.storage.picture_source_store import PictureSourceStore
|
from ledgrab.storage.picture_source_store import PictureSourceStore
|
||||||
from ledgrab.storage.wled_output_target import WledOutputTarget
|
from ledgrab.storage.wled_output_target import WledOutputTarget
|
||||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -35,6 +37,23 @@ logger = get_logger(__name__)
|
|||||||
router = APIRouter()
|
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 =====
|
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
|
||||||
|
|
||||||
|
|
||||||
@@ -53,10 +72,18 @@ async def bulk_start_processing(
|
|||||||
|
|
||||||
for target_id in body.ids:
|
for target_id in body.ids:
|
||||||
try:
|
try:
|
||||||
target_store.get_target(target_id)
|
_tgt = target_store.get_target(target_id)
|
||||||
await manager.start_processing(target_id)
|
await manager.start_processing(target_id)
|
||||||
started.append(target_id)
|
started.append(target_id)
|
||||||
logger.info(f"Bulk start: started processing for target {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:
|
except ValueError as e:
|
||||||
errors[target_id] = str(e)
|
errors[target_id] = str(e)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
@@ -78,6 +105,7 @@ async def bulk_start_processing(
|
|||||||
async def bulk_stop_processing(
|
async def bulk_stop_processing(
|
||||||
body: BulkTargetRequest,
|
body: BulkTargetRequest,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
|
"""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)
|
await manager.stop_processing(target_id)
|
||||||
stopped.append(target_id)
|
stopped.append(target_id)
|
||||||
logger.info(f"Bulk stop: stopped processing for target {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:
|
except ValueError as e:
|
||||||
errors[target_id] = str(e)
|
errors[target_id] = str(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -112,11 +152,19 @@ async def start_processing(
|
|||||||
logger.info("Start processing requested for target %s", target_id)
|
logger.info("Start processing requested for target %s", target_id)
|
||||||
try:
|
try:
|
||||||
# Verify target exists in store
|
# Verify target exists in store
|
||||||
target_store.get_target(target_id)
|
target = target_store.get_target(target_id)
|
||||||
|
|
||||||
await manager.start_processing(target_id)
|
await manager.start_processing(target_id)
|
||||||
|
|
||||||
logger.info(f"Started processing for target {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}
|
return {"status": "started", "target_id": target_id}
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -137,6 +185,7 @@ async def start_processing(
|
|||||||
async def stop_processing(
|
async def stop_processing(
|
||||||
target_id: str,
|
target_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Stop processing for a output target."""
|
"""Stop processing for a output target."""
|
||||||
@@ -144,6 +193,18 @@ async def stop_processing(
|
|||||||
await manager.stop_processing(target_id)
|
await manager.stop_processing(target_id)
|
||||||
|
|
||||||
logger.info(f"Stopped processing for target {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}
|
return {"status": "stopped", "target_id": target_id}
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -374,6 +374,12 @@ async def delete_picture_source(
|
|||||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
):
|
):
|
||||||
"""Delete a picture source."""
|
"""Delete a picture source."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_stream(stream_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if any target transitively references this stream via a CSS
|
# Check if any target transitively references this stream via a CSS
|
||||||
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
|
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.",
|
f"{css_names}. Please reassign or delete those first.",
|
||||||
)
|
)
|
||||||
store.delete_stream(stream_id)
|
store.delete_stream(stream_id)
|
||||||
fire_entity_event("picture_source", "deleted", stream_id)
|
fire_entity_event("picture_source", "deleted", stream_id, entity_name=_entity_name)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ledgrab.api.auth import AuthRequired
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
from ledgrab.api.dependencies import (
|
from ledgrab.api.dependencies import (
|
||||||
fire_entity_event,
|
fire_entity_event,
|
||||||
get_playlist_engine,
|
get_playlist_engine,
|
||||||
@@ -220,13 +221,19 @@ async def delete_scene_playlist(
|
|||||||
engine: PlaylistEngine = Depends(get_playlist_engine),
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
):
|
):
|
||||||
"""Delete a scene playlist (stops it first if it is currently cycling)."""
|
"""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:
|
try:
|
||||||
store.delete_playlist(playlist_id)
|
store.delete_playlist(playlist_id)
|
||||||
except (ValueError, EntityNotFoundError) as e:
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
await engine.stop_if_running(playlist_id)
|
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 =====
|
# ===== Cycling control =====
|
||||||
@@ -255,6 +262,28 @@ async def start_scene_playlist(
|
|||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
fire_entity_event("scene_playlist", "updated", playlist_id)
|
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())
|
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||||
|
|
||||||
|
|
||||||
@@ -265,11 +294,35 @@ async def start_scene_playlist(
|
|||||||
)
|
)
|
||||||
async def stop_scene_playlist(
|
async def stop_scene_playlist(
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
engine: PlaylistEngine = Depends(get_playlist_engine),
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
):
|
):
|
||||||
"""Stop the active playlist (leaves the last applied scene in place)."""
|
"""Stop the active playlist (leaves the last applied scene in place)."""
|
||||||
stopped_id = engine.get_running_playlist_id()
|
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()
|
await engine.stop()
|
||||||
if stopped_id:
|
if stopped_id:
|
||||||
fire_entity_event("scene_playlist", "updated", 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())
|
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ledgrab.api.auth import AuthRequired
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
from ledgrab.api.dependencies import (
|
from ledgrab.api.dependencies import (
|
||||||
fire_entity_event,
|
fire_entity_event,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
@@ -208,12 +209,18 @@ async def delete_scene_preset(
|
|||||||
store: ScenePresetStore = Depends(get_scene_preset_store),
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
):
|
):
|
||||||
"""Delete a scene preset."""
|
"""Delete a scene preset."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_preset(preset_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
store.delete_preset(preset_id)
|
store.delete_preset(preset_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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 =====
|
# ===== Recapture =====
|
||||||
@@ -282,4 +289,21 @@ async def activate_scene_preset(
|
|||||||
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
||||||
|
|
||||||
fire_entity_event("scene_preset", "updated", preset_id)
|
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)
|
return ActivateResponse(status=status, errors=errors)
|
||||||
|
|||||||
@@ -149,6 +149,12 @@ async def delete_sync_clock(
|
|||||||
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
):
|
):
|
||||||
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
|
"""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:
|
try:
|
||||||
# Check references
|
# Check references
|
||||||
for source in css_store.get_all_sources():
|
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}'")
|
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
|
||||||
manager.release_all_for(clock_id)
|
manager.release_all_for(clock_id)
|
||||||
store.delete_clock(clock_id)
|
store.delete_clock(clock_id)
|
||||||
fire_entity_event("sync_clock", "deleted", clock_id)
|
fire_entity_event("sync_clock", "deleted", clock_id, entity_name=_entity_name)
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,29 @@ from ledgrab.api.schemas.system import (
|
|||||||
ShutdownActionRequest,
|
ShutdownActionRequest,
|
||||||
ShutdownActionResponse,
|
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.storage.database import Database
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +135,11 @@ async def update_shutdown_action(
|
|||||||
"""Set what happens to LED targets when the server shuts down."""
|
"""Set what happens to LED targets when the server shuts down."""
|
||||||
db.set_setting("shutdown_action", {"action": body.action})
|
db.set_setting("shutdown_action", {"action": body.action})
|
||||||
logger.info("Shutdown action updated: %s", 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)
|
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)
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||||
output = (stdout.decode() + stderr.decode()).strip()
|
output = (stdout.decode() + stderr.decode()).strip()
|
||||||
if "connected" in output.lower():
|
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}
|
return {"status": "connected", "address": address, "message": output}
|
||||||
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -276,6 +310,17 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
|||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
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()}
|
return {"status": "disconnected", "message": stdout.decode().strip()}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ async def delete_template(
|
|||||||
|
|
||||||
Validates that no streams are currently using this template before deletion.
|
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:
|
try:
|
||||||
# Check if any streams are using this template
|
# Check if any streams are using this template
|
||||||
streams_using_template = []
|
streams_using_template = []
|
||||||
@@ -203,7 +209,7 @@ async def delete_template(
|
|||||||
|
|
||||||
# Proceed with deletion
|
# Proceed with deletion
|
||||||
template_store.delete_template(template_id)
|
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:
|
except HTTPException:
|
||||||
raise # Re-raise HTTP exceptions as-is
|
raise # Re-raise HTTP exceptions as-is
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ledgrab.api.schemas.update import (
|
|||||||
UpdateStatusResponse,
|
UpdateStatusResponse,
|
||||||
)
|
)
|
||||||
from ledgrab.core.update.update_service import UpdateService
|
from ledgrab.core.update.update_service import UpdateService
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -42,6 +43,17 @@ async def dismiss_update(
|
|||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
service.dismiss(body.version)
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +75,18 @@ async def apply_update(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await service.apply_update()
|
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"}
|
return {"ok": True, "message": "Update applied, server shutting down"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
||||||
@@ -83,8 +107,20 @@ async def update_update_settings(
|
|||||||
body: UpdateSettingsRequest,
|
body: UpdateSettingsRequest,
|
||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
return await service.update_settings(
|
result = await service.update_settings(
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
check_interval_hours=body.check_interval_hours,
|
check_interval_hours=body.check_interval_hours,
|
||||||
include_prerelease=body.include_prerelease,
|
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)",
|
||||||
|
)
|
||||||
@@ -36,7 +36,29 @@ class RuleSchema(BaseModel):
|
|||||||
)
|
)
|
||||||
timezone: str | None = Field(
|
timezone: str | None = Field(
|
||||||
None,
|
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
|
# System idle rule fields
|
||||||
idle_minutes: int | None = Field(
|
idle_minutes: int | None = Field(
|
||||||
@@ -86,6 +108,37 @@ class RuleSchema(BaseModel):
|
|||||||
ConditionSchema = RuleSchema
|
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):
|
class AutomationCreate(BaseModel):
|
||||||
"""Request to create an automation."""
|
"""Request to create an automation."""
|
||||||
|
|
||||||
@@ -101,6 +154,9 @@ class AutomationCreate(BaseModel):
|
|||||||
None, description="Scene preset for fallback deactivation"
|
None, description="Scene preset for fallback deactivation"
|
||||||
)
|
)
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
actions: List[ActionSchema] = Field(
|
||||||
|
default_factory=list, description="Outbound actions (e.g. webhooks)"
|
||||||
|
)
|
||||||
icon: str | None = Field(
|
icon: str | None = Field(
|
||||||
None,
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
@@ -126,6 +182,7 @@ class AutomationUpdate(BaseModel):
|
|||||||
None, description="Scene preset for fallback deactivation"
|
None, description="Scene preset for fallback deactivation"
|
||||||
)
|
)
|
||||||
tags: List[str] | None = None
|
tags: List[str] | None = None
|
||||||
|
actions: List[ActionSchema] | None = Field(None, description="Outbound actions (e.g. webhooks)")
|
||||||
icon: str | None = Field(
|
icon: str | None = Field(
|
||||||
None,
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
@@ -150,6 +207,9 @@ class AutomationResponse(BaseModel):
|
|||||||
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
||||||
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
|
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
actions: List[ActionSchema] = Field(
|
||||||
|
default_factory=list, description="Outbound actions (e.g. webhooks)"
|
||||||
|
)
|
||||||
webhook_url: str | None = Field(
|
webhook_url: str | None = Field(
|
||||||
None, description="Webhook URL for the first webhook rule (if any)"
|
None, description="Webhook URL for the first webhook rule (if any)"
|
||||||
)
|
)
|
||||||
@@ -179,3 +239,15 @@ class AutomationListResponse(BaseModel):
|
|||||||
|
|
||||||
automations: List[AutomationResponse] = Field(description="List of automations")
|
automations: List[AutomationResponse] = Field(description="List of automations")
|
||||||
count: int = Field(description="Number 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")
|
scale: Any = Field(description="Spatial scale")
|
||||||
mirror: bool = Field(description="Mirror/bounce mode")
|
mirror: bool = Field(description="Mirror/bounce mode")
|
||||||
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
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):
|
class CompositeCSSResponse(_CSSResponseBase):
|
||||||
@@ -332,6 +336,12 @@ class EffectCSSCreate(_CSSCreateBase):
|
|||||||
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
|
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
|
||||||
mirror: bool | None = Field(None, description="Mirror/bounce mode")
|
mirror: bool | None = Field(None, description="Mirror/bounce mode")
|
||||||
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
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):
|
class CompositeCSSCreate(_CSSCreateBase):
|
||||||
@@ -532,6 +542,12 @@ class EffectCSSUpdate(_CSSUpdateBase):
|
|||||||
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
|
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
|
||||||
mirror: bool | None = Field(None, description="Mirror/bounce mode")
|
mirror: bool | None = Field(None, description="Mirror/bounce mode")
|
||||||
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
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):
|
class CompositeCSSUpdate(_CSSUpdateBase):
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class DeviceCreate(BaseModel):
|
|||||||
hue_entertainment_group_id: str | None = Field(
|
hue_entertainment_group_id: str | None = Field(
|
||||||
None, description="Hue entertainment group/zone ID"
|
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 fields
|
||||||
yeelight_min_interval_ms: int | None = Field(
|
yeelight_min_interval_ms: int | None = Field(
|
||||||
None,
|
None,
|
||||||
@@ -80,6 +84,10 @@ class DeviceCreate(BaseModel):
|
|||||||
le=10000,
|
le=10000,
|
||||||
description="LIFX client-side rate limit between commands in ms (default 50)",
|
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 fields
|
||||||
govee_min_interval_ms: int | None = Field(
|
govee_min_interval_ms: int | None = Field(
|
||||||
None,
|
None,
|
||||||
@@ -106,6 +114,9 @@ class DeviceCreate(BaseModel):
|
|||||||
le=10000,
|
le=10000,
|
||||||
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
|
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 Direct fields
|
||||||
spi_speed_hz: int | None = Field(
|
spi_speed_hz: int | None = Field(
|
||||||
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
|
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
|
||||||
@@ -195,6 +206,10 @@ class DeviceUpdate(BaseModel):
|
|||||||
hue_username: str | None = Field(None, description="Hue bridge username")
|
hue_username: str | None = Field(None, description="Hue bridge username")
|
||||||
hue_client_key: str | None = Field(None, description="Hue entertainment client key")
|
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_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(
|
yeelight_min_interval_ms: int | None = Field(
|
||||||
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
|
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(
|
lifx_min_interval_ms: int | None = Field(
|
||||||
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
|
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(
|
govee_min_interval_ms: int | None = Field(
|
||||||
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
|
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(
|
nanoleaf_min_interval_ms: int | None = Field(
|
||||||
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
|
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_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")
|
spi_led_type: str | None = Field(None, description="LED chipset type")
|
||||||
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
|
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
|
||||||
@@ -356,6 +377,14 @@ class Calibration(BaseModel):
|
|||||||
roi_height: float = Field(
|
roi_height: float = Field(
|
||||||
default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)"
|
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):
|
class CalibrationTestModeRequest(BaseModel):
|
||||||
@@ -428,11 +457,19 @@ class DeviceResponse(BaseModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
|
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(
|
yeelight_min_interval_ms: int = Field(
|
||||||
default=500, description="Yeelight client-side rate limit in ms"
|
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")
|
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_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")
|
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)")
|
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
|
||||||
nanoleaf_paired: bool = Field(
|
nanoleaf_paired: bool = Field(
|
||||||
@@ -446,6 +483,9 @@ class DeviceResponse(BaseModel):
|
|||||||
nanoleaf_min_interval_ms: int = Field(
|
nanoleaf_min_interval_ms: int = Field(
|
||||||
default=100, description="Nanoleaf client-side rate limit in ms"
|
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_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
|
||||||
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||||
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ class MQTTSourceCreate(BaseModel):
|
|||||||
password: str = Field(default="", description="Broker password (optional)")
|
password: str = Field(default="", description="Broker password (optional)")
|
||||||
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
||||||
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
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)
|
description: str | None = Field(None, description="Optional description", max_length=500)
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
icon: str | None = Field(
|
icon: str | None = Field(
|
||||||
@@ -40,6 +49,15 @@ class MQTTSourceUpdate(BaseModel):
|
|||||||
password: str | None = Field(None, description="Broker password")
|
password: str | None = Field(None, description="Broker password")
|
||||||
client_id: str | None = Field(None, description="MQTT client ID")
|
client_id: str | None = Field(None, description="MQTT client ID")
|
||||||
base_topic: str | None = Field(None, description="Base topic prefix")
|
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)
|
description: str | None = Field(None, description="Optional description", max_length=500)
|
||||||
tags: List[str] | None = None
|
tags: List[str] | None = None
|
||||||
icon: str | None = Field(
|
icon: str | None = Field(
|
||||||
@@ -65,6 +83,8 @@ class MQTTSourceResponse(BaseModel):
|
|||||||
password_set: bool = Field(default=False, description="Whether a password is configured")
|
password_set: bool = Field(default=False, description="Whether a password is configured")
|
||||||
client_id: str = Field(description="MQTT client ID")
|
client_id: str = Field(description="MQTT client ID")
|
||||||
base_topic: str = Field(description="Base topic prefix")
|
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")
|
connected: bool = Field(default=False, description="Whether the broker connection is active")
|
||||||
description: str | None = Field(None, description="Description")
|
description: str | None = Field(None, description="Description")
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ class AuthConfig(BaseSettings):
|
|||||||
"""Authentication configuration."""
|
"""Authentication configuration."""
|
||||||
|
|
||||||
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
|
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):
|
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,
|
DisplayStateRule,
|
||||||
HomeAssistantRule,
|
HomeAssistantRule,
|
||||||
HTTPPollRule,
|
HTTPPollRule,
|
||||||
|
ManualTriggerRule,
|
||||||
MQTTRule,
|
MQTTRule,
|
||||||
Rule,
|
Rule,
|
||||||
|
SolarRule,
|
||||||
StartupRule,
|
StartupRule,
|
||||||
SystemIdleRule,
|
SystemIdleRule,
|
||||||
TimeOfDayRule,
|
TimeOfDayRule,
|
||||||
@@ -23,6 +25,7 @@ from ledgrab.storage.automation import (
|
|||||||
from ledgrab.storage.automation_store import AutomationStore
|
from ledgrab.storage.automation_store import AutomationStore
|
||||||
from ledgrab.storage.scene_preset import ScenePreset
|
from ledgrab.storage.scene_preset import ScenePreset
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.solar import compute_solar_times, utc_offset_hours_for
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -141,6 +144,11 @@ class AutomationEngine:
|
|||||||
self._last_deactivated: Dict[str, datetime] = {}
|
self._last_deactivated: Dict[str, datetime] = {}
|
||||||
# webhook_token → bool (volatile state set by webhook calls)
|
# webhook_token → bool (volatile state set by webhook calls)
|
||||||
self._webhook_states: Dict[str, bool] = {}
|
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
|
# HA source IDs currently acquired by the engine
|
||||||
self._ha_acquired: Set[str] = set()
|
self._ha_acquired: Set[str] = set()
|
||||||
# MQTT source IDs currently acquired by the engine
|
# MQTT source IDs currently acquired by the engine
|
||||||
@@ -369,6 +377,32 @@ class AutomationEngine:
|
|||||||
display_state,
|
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:
|
async def _evaluate_all_locked(self) -> None:
|
||||||
automations = self._store.get_all_automations()
|
automations = self._store.get_all_automations()
|
||||||
if not automations:
|
if not automations:
|
||||||
@@ -377,23 +411,15 @@ class AutomationEngine:
|
|||||||
await self._deactivate_automation(aid)
|
await self._deactivate_automation(aid)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine which detection methods are actually needed
|
# Determine which detection methods are actually needed (across the
|
||||||
match_types_used: set = set()
|
# rules of every *enabled* automation — disabled ones are skipped below).
|
||||||
needs_idle = False
|
(
|
||||||
needs_display_state = False
|
needs_running,
|
||||||
for a in automations:
|
needs_topmost,
|
||||||
if a.enabled:
|
needs_fullscreen,
|
||||||
for r in a.rules:
|
needs_idle,
|
||||||
if isinstance(r, ApplicationRule):
|
needs_display_state,
|
||||||
match_types_used.add(r.match_type)
|
) = self._detection_needs([r for a in automations if a.enabled for r in a.rules])
|
||||||
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
|
|
||||||
|
|
||||||
# Single executor call for all platform detection
|
# Single executor call for all platform detection
|
||||||
(
|
(
|
||||||
@@ -526,6 +552,9 @@ class AutomationEngine:
|
|||||||
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
|
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
|
||||||
return self._evaluate_time_of_day(rule)
|
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:
|
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
|
||||||
return self._evaluate_idle(rule, ctx.idle_seconds)
|
return self._evaluate_idle(rule, ctx.idle_seconds)
|
||||||
|
|
||||||
@@ -538,12 +567,40 @@ class AutomationEngine:
|
|||||||
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
|
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
|
||||||
return self._webhook_states.get(rule.token, False)
|
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:
|
def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
|
||||||
return self._evaluate_home_assistant(rule)
|
return self._evaluate_home_assistant(rule)
|
||||||
|
|
||||||
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
|
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
|
||||||
return self._evaluate_http_poll(rule)
|
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
|
@staticmethod
|
||||||
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
||||||
now = _now_in_tz(rule.timezone)
|
now = _now_in_tz(rule.timezone)
|
||||||
@@ -552,20 +609,34 @@ class AutomationEngine:
|
|||||||
parts_e = rule.end_time.split(":")
|
parts_e = rule.end_time.split(":")
|
||||||
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
||||||
end = int(parts_e[0]) * 60 + int(parts_e[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:
|
@staticmethod
|
||||||
if not (start <= current <= end):
|
def _evaluate_solar(rule: SolarRule) -> bool:
|
||||||
return False
|
# One ``now`` drives every read: day-of-year, the UTC offset for the
|
||||||
return not days or now.weekday() in days
|
# 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
|
def _event_minutes(event: str) -> int:
|
||||||
# START day, so the after-midnight tail is matched against yesterday.
|
hour = sunset_h if event == "sunset" else sunrise_h
|
||||||
if current >= start: # evening portion — today's window
|
return int(round(hour * 60))
|
||||||
return not days or now.weekday() in days
|
|
||||||
if current <= end: # early-morning portion — yesterday's window
|
# compute_solar_times clamps sunrise < sunset, so the only way to wrap
|
||||||
return not days or ((now.weekday() - 1) % 7) in days
|
# past midnight is via the offsets — which ``_weekday_window_active``
|
||||||
return False
|
# 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
|
@staticmethod
|
||||||
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
|
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
|
||||||
@@ -675,6 +746,62 @@ class AutomationEngine:
|
|||||||
# Default: "running"
|
# Default: "running"
|
||||||
return any(app in running_procs for app in apps_lower)
|
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:
|
async def _activate_automation(self, automation: Automation) -> None:
|
||||||
if not automation.scene_preset_id:
|
if not automation.scene_preset_id:
|
||||||
# No scene configured — just mark active (rules matched but nothing to do)
|
# 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._last_activated[automation.id] = datetime.now(timezone.utc)
|
||||||
self._fire_event(automation.id, "activated")
|
self._fire_event(automation.id, "activated")
|
||||||
logger.info(f"Automation '{automation.name}' activated (no scene configured)")
|
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
|
return
|
||||||
|
|
||||||
if not self._scene_preset_store or not self._target_store or not self._device_store:
|
if not self._scene_preset_store or not self._target_store or not self._device_store:
|
||||||
@@ -726,6 +858,141 @@ class AutomationEngine:
|
|||||||
else:
|
else:
|
||||||
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
|
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:
|
async def _deactivate_automation(self, automation_id: str) -> None:
|
||||||
was_active = self._active_automations.pop(automation_id, False)
|
was_active = self._active_automations.pop(automation_id, False)
|
||||||
if not was_active:
|
if not was_active:
|
||||||
@@ -751,6 +1018,47 @@ class AutomationEngine:
|
|||||||
# Clean up any leftover snapshot
|
# Clean up any leftover snapshot
|
||||||
self._pre_activation_snapshots.pop(automation_id, None)
|
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:
|
async def _deactivate_revert(self, automation_id: str) -> None:
|
||||||
"""Revert to pre-activation snapshot."""
|
"""Revert to pre-activation snapshot."""
|
||||||
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||||
@@ -855,10 +1163,12 @@ AutomationEngine._RULE_HANDLERS = {
|
|||||||
StartupRule: AutomationEngine._handle_startup,
|
StartupRule: AutomationEngine._handle_startup,
|
||||||
ApplicationRule: AutomationEngine._handle_application,
|
ApplicationRule: AutomationEngine._handle_application,
|
||||||
TimeOfDayRule: AutomationEngine._handle_time_of_day,
|
TimeOfDayRule: AutomationEngine._handle_time_of_day,
|
||||||
|
SolarRule: AutomationEngine._handle_solar,
|
||||||
SystemIdleRule: AutomationEngine._handle_system_idle,
|
SystemIdleRule: AutomationEngine._handle_system_idle,
|
||||||
DisplayStateRule: AutomationEngine._handle_display_state,
|
DisplayStateRule: AutomationEngine._handle_display_state,
|
||||||
MQTTRule: AutomationEngine._handle_mqtt,
|
MQTTRule: AutomationEngine._handle_mqtt,
|
||||||
WebhookRule: AutomationEngine._handle_webhook,
|
WebhookRule: AutomationEngine._handle_webhook,
|
||||||
|
ManualTriggerRule: AutomationEngine._handle_manual,
|
||||||
HomeAssistantRule: AutomationEngine._handle_home_assistant,
|
HomeAssistantRule: AutomationEngine._handle_home_assistant,
|
||||||
HTTPPollRule: AutomationEngine._handle_http_poll,
|
HTTPPollRule: AutomationEngine._handle_http_poll,
|
||||||
}
|
}
|
||||||
@@ -876,10 +1186,12 @@ def _assert_rule_handler_coverage() -> None:
|
|||||||
StartupRule,
|
StartupRule,
|
||||||
ApplicationRule,
|
ApplicationRule,
|
||||||
TimeOfDayRule,
|
TimeOfDayRule,
|
||||||
|
SolarRule,
|
||||||
SystemIdleRule,
|
SystemIdleRule,
|
||||||
DisplayStateRule,
|
DisplayStateRule,
|
||||||
MQTTRule,
|
MQTTRule,
|
||||||
WebhookRule,
|
WebhookRule,
|
||||||
|
ManualTriggerRule,
|
||||||
HomeAssistantRule,
|
HomeAssistantRule,
|
||||||
HTTPPollRule,
|
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
|
||||||
@@ -120,6 +120,11 @@ class CalibrationConfig:
|
|||||||
roi_y: float = 0.0
|
roi_y: float = 0.0
|
||||||
roi_width: float = 1.0
|
roi_width: float = 1.0
|
||||||
roi_height: 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
|
@property
|
||||||
def has_roi(self) -> bool:
|
def has_roi(self) -> bool:
|
||||||
@@ -349,6 +354,8 @@ class PixelMapper:
|
|||||||
"""
|
"""
|
||||||
self.calibration = calibration
|
self.calibration = calibration
|
||||||
self.interpolation_mode = interpolation_mode
|
self.interpolation_mode = interpolation_mode
|
||||||
|
# Per-frame counter driving the temporal dither phase.
|
||||||
|
self._dither_frame = 0
|
||||||
|
|
||||||
# Validate calibration
|
# Validate calibration
|
||||||
self.calibration.validate()
|
self.calibration.validate()
|
||||||
@@ -430,7 +437,16 @@ class PixelMapper:
|
|||||||
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
|
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
|
||||||
the shared kernel handles all allocations on first use.
|
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:
|
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
|
||||||
"""Map screen border pixels to LED colors.
|
"""Map screen border pixels to LED colors.
|
||||||
@@ -449,6 +465,7 @@ class PixelMapper:
|
|||||||
"""
|
"""
|
||||||
led_array = self._led_buf
|
led_array = self._led_buf
|
||||||
led_array[:] = 0
|
led_array[:] = 0
|
||||||
|
self._dither_frame += 1
|
||||||
|
|
||||||
# Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll)
|
# Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll)
|
||||||
for i, segment in enumerate(self.calibration.segments):
|
for i, segment in enumerate(self.calibration.segments):
|
||||||
@@ -514,6 +531,7 @@ class AdvancedPixelMapper:
|
|||||||
):
|
):
|
||||||
self.calibration = calibration
|
self.calibration = calibration
|
||||||
self.interpolation_mode = interpolation_mode
|
self.interpolation_mode = interpolation_mode
|
||||||
|
self._dither_frame = 0
|
||||||
calibration.validate()
|
calibration.validate()
|
||||||
|
|
||||||
if interpolation_mode == "average":
|
if interpolation_mode == "average":
|
||||||
@@ -600,7 +618,16 @@ class AdvancedPixelMapper:
|
|||||||
``cache_key`` is an integer (e.g. line index) so multiple per-line
|
``cache_key`` is an integer (e.g. line index) so multiple per-line
|
||||||
edges can share the same ``self._edge_cache`` dict without colliding.
|
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(
|
def _map_edge_fallback(
|
||||||
self,
|
self,
|
||||||
@@ -622,6 +649,7 @@ class AdvancedPixelMapper:
|
|||||||
"""
|
"""
|
||||||
led_array = self._led_buf
|
led_array = self._led_buf
|
||||||
led_array[:] = 0
|
led_array[:] = 0
|
||||||
|
self._dither_frame += 1
|
||||||
|
|
||||||
for i, line in enumerate(self.calibration.lines):
|
for i, line in enumerate(self.calibration.lines):
|
||||||
frame = frames.get(line.picture_source_id)
|
frame = frames.get(line.picture_source_id)
|
||||||
@@ -824,6 +852,30 @@ def create_default_calibration(
|
|||||||
right_count = max(1, right_count)
|
right_count = max(1, right_count)
|
||||||
left_count = max(1, left_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(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
start_position="bottom_left",
|
start_position="bottom_left",
|
||||||
@@ -878,6 +930,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
|||||||
offset=data.get("offset", 0),
|
offset=data.get("offset", 0),
|
||||||
skip_leds_start=data.get("skip_leds_start", 0),
|
skip_leds_start=data.get("skip_leds_start", 0),
|
||||||
skip_leds_end=data.get("skip_leds_end", 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()
|
config.validate()
|
||||||
return config
|
return config
|
||||||
@@ -907,6 +961,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
|||||||
roi_y=data.get("roi_y", 0.0),
|
roi_y=data.get("roi_y", 0.0),
|
||||||
roi_width=data.get("roi_width", 1.0),
|
roi_width=data.get("roi_width", 1.0),
|
||||||
roi_height=data.get("roi_height", 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()
|
config.validate()
|
||||||
@@ -951,6 +1007,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
result["skip_leds_start"] = config.skip_leds_start
|
result["skip_leds_start"] = config.skip_leds_start
|
||||||
if config.skip_leds_end > 0:
|
if config.skip_leds_end > 0:
|
||||||
result["skip_leds_end"] = config.skip_leds_end
|
result["skip_leds_end"] = config.skip_leds_end
|
||||||
|
if config.linear_blend:
|
||||||
|
result["linear_blend"] = True
|
||||||
|
if config.dither:
|
||||||
|
result["dither"] = True
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Simple mode
|
# Simple mode
|
||||||
@@ -984,4 +1044,8 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
result["roi_y"] = config.roi_y
|
result["roi_y"] = config.roi_y
|
||||||
result["roi_width"] = config.roi_width
|
result["roi_width"] = config.roi_width
|
||||||
result["roi_height"] = config.roi_height
|
result["roi_height"] = config.roi_height
|
||||||
|
if config.linear_blend:
|
||||||
|
result["linear_blend"] = True
|
||||||
|
if config.dither:
|
||||||
|
result["dither"] = True
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -233,8 +233,20 @@ class CalibrationSession:
|
|||||||
self._last_activity = datetime.now(timezone.utc)
|
self._last_activity = datetime.now(timezone.utc)
|
||||||
self._active = True
|
self._active = True
|
||||||
|
|
||||||
# Clear the device to black so the chase starts from a clean state
|
# Clear the device to black so the chase starts from a clean state.
|
||||||
await manager.send_clear_pixels(device_id)
|
# 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
|
# Start idle-timeout watchdog
|
||||||
self._timeout_task = asyncio.ensure_future(self._idle_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
|
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
|
# 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
|
# 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.
|
# 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,
|
led_count: int,
|
||||||
cache: Dict[Hashable, _CacheEntry],
|
cache: Dict[Hashable, _CacheEntry],
|
||||||
cache_key: Hashable,
|
cache_key: Hashable,
|
||||||
|
linear: bool = False,
|
||||||
|
dither: bool = False,
|
||||||
|
frame_index: int = 0,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Vectorised average colour per LED segment.
|
"""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
|
over axis=0 (collapsing rows), then segment along the width; for
|
||||||
left/right edges we average over axis=1 then segment along the height.
|
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 —
|
Returns a view into the caller-owned cache's ``out_uint8`` buffer —
|
||||||
do NOT retain the result across calls without copying.
|
do NOT retain the result across calls without copying.
|
||||||
"""
|
"""
|
||||||
@@ -110,8 +124,13 @@ def average_edge_to_leds(
|
|||||||
out_uint8,
|
out_uint8,
|
||||||
) = entry
|
) = 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)
|
# 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.
|
# Cumulative sum so each LED segment's sum is two array lookups apart.
|
||||||
cumsum_buf[0] = 0
|
cumsum_buf[0] = 0
|
||||||
@@ -122,8 +141,16 @@ def average_edge_to_leds(
|
|||||||
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
|
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
|
||||||
np.subtract(sums_buf, starts_buf, out=sums_buf)
|
np.subtract(sums_buf, starts_buf, out=sums_buf)
|
||||||
np.divide(sums_buf, lengths, out=sums_buf)
|
np.divide(sums_buf, lengths, out=sums_buf)
|
||||||
np.clip(sums_buf, 0, 255, out=sums_buf)
|
if dither:
|
||||||
np.copyto(out_uint8, sums_buf, casting="unsafe")
|
# 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
|
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
|
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]:
|
def parse_adalight_url(url: str) -> Tuple[str, int]:
|
||||||
"""Backwards-compatible alias for :func:`parse_serial_url`."""
|
"""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.write, frame)
|
||||||
await loop.run_in_executor(executor, self._serial.flush)
|
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}")
|
logger.info(f"Adalight black frame sent and flushed: {self._port}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to send black frame on close: {e}")
|
logger.warning(f"Failed to send black frame on close: {e}")
|
||||||
|
|||||||
@@ -154,6 +154,15 @@ class DDPClient:
|
|||||||
all buses (observed in multi-bus setups). We reorder pixel channels
|
all buses (observed in multi-bus setups). We reorder pixel channels
|
||||||
here so the hardware receives the correct byte order directly.
|
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:
|
Args:
|
||||||
pixel_array: (N, 3) uint8 numpy array in RGB order
|
pixel_array: (N, 3) uint8 numpy array in RGB order
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ class HueConfig(BaseDeviceConfig):
|
|||||||
hue_username: str = ""
|
hue_username: str = ""
|
||||||
hue_client_key: str = ""
|
hue_client_key: str = ""
|
||||||
hue_entertainment_group_id: 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)
|
@dataclass(frozen=True)
|
||||||
@@ -115,6 +118,8 @@ class LIFXConfig(BaseDeviceConfig):
|
|||||||
|
|
||||||
device_type: Literal["lifx"] = "lifx"
|
device_type: Literal["lifx"] = "lifx"
|
||||||
lifx_min_interval_ms: int = 50
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -153,6 +158,8 @@ class NanoleafConfig(BaseDeviceConfig):
|
|||||||
device_type: Literal["nanoleaf"] = "nanoleaf"
|
device_type: Literal["nanoleaf"] = "nanoleaf"
|
||||||
nanoleaf_token: str = ""
|
nanoleaf_token: str = ""
|
||||||
nanoleaf_min_interval_ms: int = 100
|
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)
|
@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.serial_transport import list_serial_ports
|
||||||
from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE
|
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 import get_logger
|
||||||
from ledgrab.utils.platform import is_android
|
from ledgrab.utils.platform import is_android
|
||||||
|
|
||||||
@@ -286,3 +287,34 @@ class DiscoveryWatcher:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Discovery watcher: fire_event failed: %s", 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)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import List, Tuple
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
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
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -24,15 +25,40 @@ COLOR_SPACE_RGB = 0x00
|
|||||||
HEADER_SIZE = 16
|
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(
|
def _build_entertainment_frame(
|
||||||
lights: List[Tuple[int, int, int]],
|
colors: List[Tuple[int, int, int]],
|
||||||
brightness: int = 255,
|
brightness: int = 255,
|
||||||
sequence: int = 0,
|
sequence: int = 0,
|
||||||
|
channel_ids: List[int] | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Build a Hue Entertainment API v2 UDP frame.
|
"""Build a Hue Entertainment API v2 UDP frame.
|
||||||
|
|
||||||
Each light gets 7 bytes: [light_id(2B)][R(2B)][G(2B)][B(2B)]
|
Each record is 7 bytes: [channel_id(1B)][R(2B)][G(2B)][B(2B)]. Colors are
|
||||||
Colors are 16-bit (0-65535). We scale 8-bit RGB + brightness.
|
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
|
||||||
header = bytearray(HEADER_SIZE)
|
header = bytearray(HEADER_SIZE)
|
||||||
@@ -45,15 +71,15 @@ def _build_entertainment_frame(
|
|||||||
header[14] = COLOR_SPACE_RGB
|
header[14] = COLOR_SPACE_RGB
|
||||||
header[15] = 0x00 # reserved
|
header[15] = 0x00 # reserved
|
||||||
|
|
||||||
# Light data
|
# Channel data
|
||||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||||
data = bytearray()
|
data = bytearray()
|
||||||
for idx, (r, g, b) in enumerate(lights):
|
for idx, (r, g, b) in enumerate(colors):
|
||||||
light_id = idx # 0-based light index in entertainment group
|
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
|
r16 = int(r * 257) # scale 0-255 to 0-65535
|
||||||
g16 = int(g * 257)
|
g16 = int(g * 257)
|
||||||
b16 = int(b * 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)
|
return bytes(header) + bytes(data)
|
||||||
|
|
||||||
@@ -62,7 +88,11 @@ class HueClient(LEDClient):
|
|||||||
"""LED client for Philips Hue Entertainment API streaming.
|
"""LED client for Philips Hue Entertainment API streaming.
|
||||||
|
|
||||||
Uses UDP (optionally DTLS) to stream color data at ~25 fps to a Hue
|
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__(
|
def __init__(
|
||||||
@@ -72,6 +102,7 @@ class HueClient(LEDClient):
|
|||||||
hue_username: str = "",
|
hue_username: str = "",
|
||||||
hue_client_key: str = "",
|
hue_client_key: str = "",
|
||||||
hue_entertainment_group_id: str = "",
|
hue_entertainment_group_id: str = "",
|
||||||
|
gradient_mode: bool = True,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
self._bridge_ip = url.replace("hue://", "").rstrip("/")
|
self._bridge_ip = url.replace("hue://", "").rstrip("/")
|
||||||
@@ -79,15 +110,55 @@ class HueClient(LEDClient):
|
|||||||
self._username = hue_username
|
self._username = hue_username
|
||||||
self._client_key = hue_client_key
|
self._client_key = hue_client_key
|
||||||
self._group_id = hue_entertainment_group_id
|
self._group_id = hue_entertainment_group_id
|
||||||
|
self._gradient_mode = gradient_mode
|
||||||
|
self._channel_ids: List[int] = []
|
||||||
self._sock: socket.socket | None = None
|
self._sock: socket.socket | None = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._sequence = 0
|
self._sequence = 0
|
||||||
self._dtls_sock = None
|
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:
|
async def connect(self) -> bool:
|
||||||
# Activate entertainment streaming via REST API
|
# Activate entertainment streaming via REST API
|
||||||
await self._activate_streaming(True)
|
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
|
# Open UDP socket for entertainment streaming
|
||||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
self._sock.setblocking(False)
|
self._sock.setblocking(False)
|
||||||
@@ -179,12 +250,19 @@ class HueClient(LEDClient):
|
|||||||
if not self._connected:
|
if not self._connected:
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(pixels, np.ndarray):
|
# Resample the strip to the number of addressable elements: the
|
||||||
light_colors = [tuple(pixels[i]) for i in range(min(len(pixels), self._led_count))]
|
# discovered channel count in gradient mode, else the configured
|
||||||
else:
|
# light count. ``resample_to_n`` spreads the strip spatially and
|
||||||
light_colors = pixels[: self._led_count]
|
# 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
|
self._sequence = (self._sequence + 1) & 0xFF
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class HueDeviceProvider(LEDDeviceProvider):
|
|||||||
hue_username=config.hue_username,
|
hue_username=config.hue_username,
|
||||||
hue_client_key=config.hue_client_key,
|
hue_client_key=config.hue_client_key,
|
||||||
hue_entertainment_group_id=config.hue_entertainment_group_id,
|
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:
|
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import numpy as np
|
|||||||
|
|
||||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
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 average_color as _average_color
|
||||||
|
from ledgrab.core.devices.pixel_reduce import resample_to_n
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -41,6 +42,21 @@ MSG_GET_SERVICE = 2
|
|||||||
MSG_STATE_SERVICE = 3
|
MSG_STATE_SERVICE = 3
|
||||||
MSG_SET_POWER = 21
|
MSG_SET_POWER = 21
|
||||||
MSG_SET_COLOR = 102
|
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 field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024
|
||||||
_FRAME_TAGGED = 0x3400
|
_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)
|
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:
|
def _parse_state_service_reply(raw: bytes) -> dict | None:
|
||||||
"""Parse a LIFX StateService (discovery) reply.
|
"""Parse a LIFX StateService (discovery) reply.
|
||||||
|
|
||||||
@@ -163,15 +278,25 @@ def _parse_state_service_reply(raw: bytes) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
class _LIFXProtocol(asyncio.DatagramProtocol):
|
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):
|
def connection_made(self, transport):
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
|
||||||
def datagram_received(self, data, addr):
|
def datagram_received(self, data, addr):
|
||||||
# LIFX bulbs sometimes echo back state on broadcast. We don't need it
|
if len(self.received) < self._MAX_BUFFER:
|
||||||
# for streaming ambilight — discard.
|
self.received.append(bytes(data))
|
||||||
pass
|
|
||||||
|
|
||||||
def error_received(self, exc):
|
def error_received(self, exc):
|
||||||
logger.debug("LIFX UDP error: %s", exc)
|
logger.debug("LIFX UDP error: %s", exc)
|
||||||
@@ -186,6 +311,7 @@ class LIFXClient(LEDClient):
|
|||||||
led_count: int = 1,
|
led_count: int = 1,
|
||||||
*,
|
*,
|
||||||
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
||||||
|
per_zone: bool = False,
|
||||||
):
|
):
|
||||||
host, port = parse_lifx_url(url)
|
host, port = parse_lifx_url(url)
|
||||||
self._host = host
|
self._host = host
|
||||||
@@ -197,6 +323,12 @@ class LIFXClient(LEDClient):
|
|||||||
self._connected = False
|
self._connected = False
|
||||||
self._next_tx_at: float = 0.0
|
self._next_tx_at: float = 0.0
|
||||||
self._sequence: int = 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
|
@property
|
||||||
def host(self) -> str:
|
def host(self) -> str:
|
||||||
@@ -212,6 +344,12 @@ class LIFXClient(LEDClient):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def device_led_count(self) -> int | None:
|
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
|
return self._led_count or None
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
@@ -227,9 +365,56 @@ class LIFXClient(LEDClient):
|
|||||||
self._transport = transport
|
self._transport = transport
|
||||||
self._protocol = protocol # type: ignore[assignment]
|
self._protocol = protocol # type: ignore[assignment]
|
||||||
self._connected = True
|
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
|
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:
|
async def close(self) -> None:
|
||||||
if self._transport is not None:
|
if self._transport is not None:
|
||||||
try:
|
try:
|
||||||
@@ -255,25 +440,63 @@ class LIFXClient(LEDClient):
|
|||||||
)
|
)
|
||||||
self._transport.sendto(packet)
|
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(
|
async def send_pixels(
|
||||||
self,
|
self,
|
||||||
pixels: List[Tuple[int, int, int]] | np.ndarray,
|
pixels: List[Tuple[int, int, int]] | np.ndarray,
|
||||||
brightness: int = 255,
|
brightness: int = 255,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Average the strip → HSBK → SetColor."""
|
"""Stream per-zone/tile when detected, else average the strip → SetColor."""
|
||||||
if not self.is_connected:
|
if not self.is_connected:
|
||||||
raise RuntimeError("LIFXClient not connected")
|
raise RuntimeError("LIFXClient not connected")
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now < self._next_tx_at:
|
if now < self._next_tx_at:
|
||||||
return True
|
return True
|
||||||
r, g, b = _average_color(pixels)
|
self._emit_pixels(pixels, brightness)
|
||||||
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._next_tx_at = now + self._min_interval_s
|
self._next_tx_at = now + self._min_interval_s
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -288,14 +511,7 @@ class LIFXClient(LEDClient):
|
|||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now < self._next_tx_at:
|
if now < self._next_tx_at:
|
||||||
return
|
return
|
||||||
r, g, b = _average_color(pixels)
|
self._emit_pixels(pixels, brightness)
|
||||||
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._next_tx_at = now + self._min_interval_s
|
self._next_tx_at = now + self._min_interval_s
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class LIFXDeviceProvider(LEDDeviceProvider):
|
|||||||
config.device_url,
|
config.device_url,
|
||||||
led_count=config.led_count,
|
led_count=config.led_count,
|
||||||
min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0),
|
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:
|
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
|
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.
|
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
|
Two output modes:
|
||||||
HSBT (hue / saturation / brightness; kelvin only matters when sat=0).
|
* **Single-colour** (default): average the strip to one HSB triple and
|
||||||
LedGrab averages the incoming strip to one HSB triple. Per-panel streaming
|
``PUT /api/v1/{token}/state`` — matches every other consumer-bulb driver.
|
||||||
mode (``extControl`` UDP, ~60 Hz, addresses each panel individually) is
|
* **Per-panel** (``per_panel=True``): enable ``extControl`` v2 and stream a
|
||||||
documented but not implemented here — the MVP keeps the device acting as
|
UDP packet per frame to port 60222, addressing each panel individually
|
||||||
a single-pixel target like Yeelight / Hue.
|
(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
|
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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -37,9 +40,62 @@ from ledgrab.utils import get_logger
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
NANOLEAF_PORT = 16021
|
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
|
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:
|
def parse_nanoleaf_url(url: str) -> str:
|
||||||
"""Pull the host out of ``nanoleaf://host`` or accept a bare host.
|
"""Pull the host out of ``nanoleaf://host`` or accept a bare host.
|
||||||
|
|
||||||
@@ -131,6 +187,7 @@ class NanoleafClient(LEDClient):
|
|||||||
auth_token: str = "",
|
auth_token: str = "",
|
||||||
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
||||||
request_timeout_s: float = 3.0,
|
request_timeout_s: float = 3.0,
|
||||||
|
per_panel: bool = False,
|
||||||
):
|
):
|
||||||
self._host = parse_nanoleaf_url(url)
|
self._host = parse_nanoleaf_url(url)
|
||||||
self._token = auth_token
|
self._token = auth_token
|
||||||
@@ -140,6 +197,11 @@ class NanoleafClient(LEDClient):
|
|||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._next_tx_at: float = 0.0
|
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
|
@property
|
||||||
def host(self) -> str:
|
def host(self) -> str:
|
||||||
@@ -157,6 +219,9 @@ class NanoleafClient(LEDClient):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def device_led_count(self) -> int | None:
|
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
|
return self._led_count or None
|
||||||
|
|
||||||
def _state_url(self) -> str:
|
def _state_url(self) -> str:
|
||||||
@@ -169,9 +234,63 @@ class NanoleafClient(LEDClient):
|
|||||||
raise RuntimeError("NanoleafClient requires an auth_token; pair the device first")
|
raise RuntimeError("NanoleafClient requires an auth_token; pair the device first")
|
||||||
self._http = httpx.AsyncClient(timeout=self._request_timeout_s)
|
self._http = httpx.AsyncClient(timeout=self._request_timeout_s)
|
||||||
self._connected = True
|
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
|
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:
|
async def close(self) -> None:
|
||||||
if self._http is not None:
|
if self._http is not None:
|
||||||
try:
|
try:
|
||||||
@@ -179,6 +298,13 @@ class NanoleafClient(LEDClient):
|
|||||||
except (httpx.HTTPError, RuntimeError):
|
except (httpx.HTTPError, RuntimeError):
|
||||||
pass
|
pass
|
||||||
self._http = None
|
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
|
self._connected = False
|
||||||
|
|
||||||
async def _put_state(self, body: dict) -> None:
|
async def _put_state(self, body: dict) -> None:
|
||||||
@@ -197,12 +323,28 @@ class NanoleafClient(LEDClient):
|
|||||||
pixels: List[Tuple[int, int, int]] | np.ndarray,
|
pixels: List[Tuple[int, int, int]] | np.ndarray,
|
||||||
brightness: int = 255,
|
brightness: int = 255,
|
||||||
) -> bool:
|
) -> 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:
|
if not self.is_connected:
|
||||||
raise RuntimeError("NanoleafClient not 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:
|
if loop_now < self._next_tx_at:
|
||||||
return True
|
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)
|
r, g, b = _average_color(pixels)
|
||||||
if brightness < 255:
|
if brightness < 255:
|
||||||
scale = max(0, min(255, brightness)) / 255.0
|
scale = max(0, min(255, brightness)) / 255.0
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class NanoleafDeviceProvider(LEDDeviceProvider):
|
|||||||
led_count=config.led_count,
|
led_count=config.led_count,
|
||||||
auth_token=config.nanoleaf_token,
|
auth_token=config.nanoleaf_token,
|
||||||
min_interval_s=max(0.0, config.nanoleaf_min_interval_ms / 1000.0),
|
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:
|
async def pair_device(self, url: str) -> dict:
|
||||||
|
|||||||
@@ -40,3 +40,29 @@ def average_color(
|
|||||||
total_b += b
|
total_b += b
|
||||||
n = len(pixels)
|
n = len(pixels)
|
||||||
return total_r // n, total_g // n, total_b // n
|
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", {})
|
auth_section = payload.get("auth", {})
|
||||||
actual_token = auth_section.get("token", "")
|
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
|
@classmethod
|
||||||
def get_config_schema(cls) -> dict[str, Any]:
|
def get_config_schema(cls) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -177,7 +177,12 @@ class Dota2Adapter(GameAdapter):
|
|||||||
|
|
||||||
auth_section = payload.get("auth", {})
|
auth_section = payload.get("auth", {})
|
||||||
actual_token = auth_section.get("token", "")
|
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
|
@classmethod
|
||||||
def get_config_schema(cls) -> dict[str, Any]:
|
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.
|
rather than a YAML file. Delegates all parsing logic to MappingAdapter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from ledgrab.core.game_integration.base_adapter import GameAdapter
|
from ledgrab.core.game_integration.base_adapter import GameAdapter
|
||||||
@@ -54,11 +55,18 @@ class GenericWebhookAdapter(GameAdapter):
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
adapter_config: dict[str, Any],
|
adapter_config: dict[str, Any],
|
||||||
) -> bool:
|
) -> 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")
|
expected_token = adapter_config.get("auth_token")
|
||||||
if not expected_token:
|
if not expected_token:
|
||||||
# No auth configured
|
# No token configured — reject (secure-by-default for a
|
||||||
return True
|
# network-facing adapter; an open webhook is a LAN attack surface).
|
||||||
|
return False
|
||||||
|
|
||||||
auth_header = adapter_config.get("auth_header", "Authorization")
|
auth_header = adapter_config.get("auth_header", "Authorization")
|
||||||
actual_value = headers.get(auth_header, "")
|
actual_value = headers.get(auth_header, "")
|
||||||
@@ -67,18 +75,32 @@ class GenericWebhookAdapter(GameAdapter):
|
|||||||
if actual_value.startswith("Bearer "):
|
if actual_value.startswith("Bearer "):
|
||||||
actual_value = actual_value[7:]
|
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
|
@classmethod
|
||||||
def get_config_schema(cls) -> dict[str, Any]:
|
def get_config_schema(cls) -> dict[str, Any]:
|
||||||
"""Return generic webhook config schema."""
|
"""Return generic webhook config schema."""
|
||||||
return {
|
return {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"required": ["auth_token"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"auth_token": {
|
"auth_token": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Auth Token",
|
"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": {
|
"auth_header": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -136,15 +158,17 @@ class GenericWebhookAdapter(GameAdapter):
|
|||||||
"HTTP POST requests with JSON payloads.\n\n"
|
"HTTP POST requests with JSON payloads.\n\n"
|
||||||
"**Steps:**\n"
|
"**Steps:**\n"
|
||||||
"1. Configure your event mappings above — map JSON paths to standard events\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"
|
"3. Point your game/application to:\n"
|
||||||
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
|
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
|
||||||
"**Mapping example:**\n"
|
"**Mapping example:**\n"
|
||||||
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\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"
|
"- 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"
|
"- Set `Authorization: Bearer <token>` header in your webhook sender\n"
|
||||||
"- Or configure a custom auth header name in the adapter config\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.
|
entirely driven by the parsed YAML definition.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -241,7 +242,14 @@ class MappingAdapter(GameAdapter):
|
|||||||
expected_key = "auth_token"
|
expected_key = "auth_token"
|
||||||
expected_value = adapter_config.get(expected_key, "")
|
expected_value = adapter_config.get(expected_key, "")
|
||||||
actual_value = headers.get(header_name, "")
|
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}'")
|
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
|
||||||
return False
|
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)
|
||||||
@@ -22,11 +22,27 @@ class MQTTManager:
|
|||||||
Multiple consumers share the same runtime via acquire/release.
|
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
|
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)
|
# source_id -> (runtime, ref_count)
|
||||||
self._runtimes: Dict[str, tuple] = {}
|
self._runtimes: Dict[str, tuple] = {}
|
||||||
|
# Sources for which we hold a discovery acquire() reference.
|
||||||
|
self._discovery_sources: set[str] = set()
|
||||||
self._lock = asyncio.Lock()
|
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:
|
async def acquire(self, source_id: str) -> MQTTRuntime:
|
||||||
"""Get or create a runtime for the given MQTT source. Increments ref count."""
|
"""Get or create a runtime for the given MQTT source. Increments ref count."""
|
||||||
@@ -100,6 +116,88 @@ class MQTTManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to update MQTT runtime %s: %s", source_id, 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]]:
|
def get_connection_status(self) -> List[Dict[str, Any]]:
|
||||||
"""Get status of all active MQTT connections (for dashboard indicators)."""
|
"""Get status of all active MQTT connections (for dashboard indicators)."""
|
||||||
result = []
|
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 0–1
|
||||||
|
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.05–0.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
|
# Inject gradient store for palette resolution
|
||||||
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
|
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
|
||||||
css_stream.set_gradient_store(self._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
|
# Inject asset store for notification sound playback
|
||||||
if self._asset_store and hasattr(css_stream, "set_asset_store"):
|
if self._asset_store and hasattr(css_stream, "set_asset_store"):
|
||||||
css_stream.set_asset_store(self._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
|
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
|
||||||
self._resize_cache: Dict[tuple, tuple] = {}
|
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)
|
# layer_index -> (source_id, consumer_id, stream)
|
||||||
self._sub_streams: Dict[int, tuple] = {}
|
self._sub_streams: Dict[int, tuple] = {}
|
||||||
# layer_index -> (vs_id, value_stream)
|
# layer_index -> (vs_id, value_stream)
|
||||||
@@ -314,8 +317,14 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
n_src = len(colors)
|
n_src = len(colors)
|
||||||
if n_src == target_n:
|
if n_src == target_n:
|
||||||
return colors
|
return colors
|
||||||
src_x = np.linspace(0, 1, n_src)
|
# Cache the (src_x, dst_x) linspace arrays keyed by (n_src, target_n)
|
||||||
dst_x = np.linspace(0, 1, 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
|
buf = self._resize_buf
|
||||||
for ch in range(3):
|
for ch in range(3):
|
||||||
np.copyto(
|
np.copyto(
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ to the user's location and the current season.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import math
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -84,71 +83,16 @@ _daylight_lut: np.ndarray | None = None
|
|||||||
|
|
||||||
|
|
||||||
# ── Solar position helpers ──────────────────────────────────────────────
|
# ── Solar position helpers ──────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# ``compute_solar_times`` / ``utc_offset_hours_for`` moved to
|
||||||
def _compute_solar_times(
|
# ``ledgrab.utils.solar`` (pure math, no project imports) so the automation
|
||||||
latitude: float,
|
# engine can reuse them without importing this processing module. Imported
|
||||||
longitude: float,
|
# under their old private names here so the rest of this file (and
|
||||||
day_of_year: int,
|
# ``value_stream``, which imports them from here) is unchanged.
|
||||||
utc_offset_hours: float = 0.0,
|
from ledgrab.utils.solar import ( # noqa: E402
|
||||||
) -> tuple:
|
compute_solar_times as _compute_solar_times,
|
||||||
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time.
|
utc_offset_hours_for as _utc_offset_hours_for,
|
||||||
|
)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
|
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,
|
check_device_health,
|
||||||
get_device_capabilities,
|
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
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -128,6 +130,41 @@ class DeviceHealthMixin:
|
|||||||
"latency_ms": state.health.latency_ms,
|
"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
|
# Auto-sync LED count
|
||||||
reported = state.health.device_led_count
|
reported = state.health.device_led_count
|
||||||
|
|||||||
@@ -137,16 +137,34 @@ class DeviceTestModeMixin:
|
|||||||
await self._send_pixels_to_device(device_id, pixels)
|
await self._send_pixels_to_device(device_id, pixels)
|
||||||
|
|
||||||
async def _send_clear_pixels(self, device_id: str) -> None:
|
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]
|
ds = self._devices[device_id]
|
||||||
pixels = [(0, 0, 0)] * ds.led_count
|
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:
|
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
|
||||||
"""Send pixels to a device via cached idle client.
|
"""Send pixels to a device via cached idle client.
|
||||||
|
|
||||||
Reuses a cached connection to avoid repeated serial reconnections
|
Reuses a cached connection to avoid repeated serial reconnections
|
||||||
(which trigger Arduino bootloader reset on Adalight devices).
|
(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:
|
try:
|
||||||
client = await self._get_idle_client(device_id)
|
client = await self._get_idle_client(device_id)
|
||||||
|
|||||||
@@ -304,6 +304,13 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
# Sparkle rain state
|
# Sparkle rain state
|
||||||
self._sparkle_state: np.ndarray | None = None # per-LED brightness 0..1
|
self._sparkle_state: np.ndarray | None = None # per-LED brightness 0..1
|
||||||
self._gradient_store = None # injected by stream manager
|
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)
|
self._update_from_source(source)
|
||||||
|
|
||||||
def set_gradient_store(self, gradient_store) -> None:
|
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._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
|
||||||
self._scale = bfloat(getattr(source, "scale", 1.0), 1.0)
|
self._scale = bfloat(getattr(source, "scale", 1.0), 1.0)
|
||||||
self._mirror = bool(getattr(source, "mirror", False))
|
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:
|
with self._colors_lock:
|
||||||
self._colors: np.ndarray | None = None
|
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:
|
def configure(self, device_led_count: int) -> None:
|
||||||
if self._auto_size and device_led_count > 0:
|
if self._auto_size and device_led_count > 0:
|
||||||
new_count = max(self._led_count, device_led_count)
|
new_count = max(self._led_count, device_led_count)
|
||||||
@@ -369,6 +403,8 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._running:
|
if self._running:
|
||||||
return
|
return
|
||||||
|
if self._audio_reactive and self._audio_tap is not None:
|
||||||
|
self._audio_tap.start()
|
||||||
self._running = True
|
self._running = True
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._animate_loop,
|
target=self._animate_loop,
|
||||||
@@ -387,6 +423,8 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
if self._thread.is_alive():
|
if self._thread.is_alive():
|
||||||
logger.warning("EffectColorStripStream animate thread did not terminate within 5s")
|
logger.warning("EffectColorStripStream animate thread did not terminate within 5s")
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
if self._audio_tap is not None:
|
||||||
|
self._audio_tap.stop()
|
||||||
self._heat = None
|
self._heat = None
|
||||||
self._heat_n = 0
|
self._heat_n = 0
|
||||||
logger.info("EffectColorStripStream stopped")
|
logger.info("EffectColorStripStream stopped")
|
||||||
@@ -399,12 +437,39 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
from ledgrab.storage.color_strip_source import EffectColorStripSource
|
from ledgrab.storage.color_strip_source import EffectColorStripSource
|
||||||
|
|
||||||
if isinstance(source, 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
|
prev_led_count = self._led_count if self._auto_size else None
|
||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
if prev_led_count and self._auto_size:
|
if prev_led_count and self._auto_size:
|
||||||
self._led_count = prev_led_count
|
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")
|
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:
|
def set_clock(self, clock) -> None:
|
||||||
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
|
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
@@ -476,6 +541,9 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
continue
|
continue
|
||||||
render_fn(self, buf, n, anim_time)
|
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:
|
with self._colors_lock:
|
||||||
self._colors = buf
|
self._colors = buf
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -973,13 +1041,18 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
# Use noise at very low frequency for blob movement
|
# Use noise at very low frequency for blob movement
|
||||||
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)
|
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
|
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)
|
np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
|
||||||
self._s_f32_a += t * speed * 0.07 + 100.0
|
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
|
# Combine: create blob-like shapes with soft edges
|
||||||
combined = self._s_f32_a
|
combined = self._s_f32_a
|
||||||
|
|||||||
@@ -739,10 +739,19 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
self._last_preview_data = data
|
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:
|
try:
|
||||||
await ws.send_bytes(data)
|
await asyncio.wait_for(ws.send_bytes(data), timeout=send_timeout)
|
||||||
return True
|
return True
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug("LED preview broadcast WS send timed out (slow client dropped)")
|
||||||
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("LED preview broadcast WS send failed: %s", e)
|
logger.debug("LED preview broadcast WS send failed: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -138,22 +138,42 @@ class PlaylistEngine:
|
|||||||
"""Stop the playlist only if ``playlist_id`` is the one running.
|
"""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
|
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:
|
async with self._lifecycle_lock:
|
||||||
await self.stop()
|
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) =====
|
# ===== Query API (used by routes) =====
|
||||||
|
|
||||||
def is_running(self) -> bool:
|
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:
|
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:
|
def get_state(self) -> dict:
|
||||||
if self._state is not None and self.is_running():
|
# Snapshot both refs once: the event loop won't preempt this sync method
|
||||||
return self._state.to_dict()
|
# 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)
|
return dict(_IDLE_STATE)
|
||||||
|
|
||||||
# ===== Internal =====
|
# ===== Internal =====
|
||||||
@@ -201,13 +221,25 @@ class PlaylistEngine:
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Natural end (non-loop or guard). Clear state without recursing
|
# Natural end (non-loop or guard). Clear state without recursing
|
||||||
# through stop() (which would try to cancel this very task). Guard
|
# through stop() (which would try to cancel this very task). Take
|
||||||
# against a concurrent start_playlist having already replaced us:
|
# the lifecycle lock so the clear + 'stopped' event are atomic with
|
||||||
# only clear if we are still the engine's current task.
|
# respect to a concurrent start/stop/stop_if_running — otherwise the
|
||||||
if self._task is asyncio.current_task():
|
# two could interleave and emit duplicate or contradictory terminal
|
||||||
self._task = None
|
# events. _run never calls _cancel_task and never otherwise holds
|
||||||
ended_id = self._state.playlist_id if self._state else None
|
# this lock, so acquiring it here cannot deadlock; if a canceller
|
||||||
self._state = None
|
# 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)
|
self._fire_event("stopped", playlist_id=ended_id)
|
||||||
logger.info("Playlist '%s' finished", playlist_id)
|
logger.info("Playlist '%s' finished", playlist_id)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -220,18 +252,24 @@ class PlaylistEngine:
|
|||||||
order = self._resolve_order(playlist)
|
order = self._resolve_order(playlist)
|
||||||
applied_any = False
|
applied_any = False
|
||||||
|
|
||||||
for index, item in enumerate(order):
|
for orig_index, item in order:
|
||||||
duration = clamp_duration(item.duration_seconds)
|
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)
|
applied = await self._apply_item(item.scene_preset_id)
|
||||||
if applied:
|
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
|
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
|
# Only dwell on scenes we actually applied; skip missing ones
|
||||||
# immediately so the cycle doesn't stall on a dead reference.
|
# immediately so the cycle doesn't stall on a dead reference.
|
||||||
await asyncio.sleep(duration)
|
await asyncio.sleep(duration)
|
||||||
@@ -239,11 +277,16 @@ class PlaylistEngine:
|
|||||||
return applied_any
|
return applied_any
|
||||||
|
|
||||||
def _resolve_order(self, playlist: ScenePlaylist) -> List:
|
def _resolve_order(self, playlist: ScenePlaylist) -> List:
|
||||||
if playlist.shuffle and len(playlist.items) > 1:
|
"""Return ``(orig_index, item)`` pairs, optionally shuffled.
|
||||||
shuffled = list(playlist.items)
|
|
||||||
random.shuffle(shuffled) # noqa: S311 - cosmetic ordering, not security
|
The original persisted index travels with each item so ``current_index``
|
||||||
return shuffled
|
in the runtime state always maps back into ``playlist.items`` even when
|
||||||
return list(playlist.items)
|
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:
|
async def _apply_item(self, preset_id: str) -> bool:
|
||||||
"""Apply one scene preset. Returns False if it could not be applied."""
|
"""Apply one scene preset. Returns False if it could not be applied."""
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from ledgrab.core.automations.automation_engine import AutomationEngine
|
|||||||
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
||||||
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||||
|
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
|
||||||
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
|
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.game_integration.community_loader import register_community_adapters
|
||||||
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
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
|
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
||||||
import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration
|
import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration
|
||||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
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.processing.os_notification_listener import OsNotificationListener
|
||||||
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
|
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
|
||||||
from ledgrab.core.update.update_service import UpdateService
|
from ledgrab.core.update.update_service import UpdateService
|
||||||
@@ -176,14 +180,22 @@ weather_manager = WeatherManager(weather_source_store)
|
|||||||
ha_store = HomeAssistantStore(db)
|
ha_store = HomeAssistantStore(db)
|
||||||
ha_manager = HomeAssistantManager(ha_store)
|
ha_manager = HomeAssistantManager(ha_store)
|
||||||
mqtt_source_store = MQTTSourceStore(db)
|
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)
|
http_endpoint_store = HTTPEndpointStore(db)
|
||||||
audio_processing_template_store = AudioProcessingTemplateStore(db)
|
audio_processing_template_store = AudioProcessingTemplateStore(db)
|
||||||
game_integration_store = GameIntegrationStore(db)
|
game_integration_store = GameIntegrationStore(db)
|
||||||
pattern_template_store = PatternTemplateStore(db)
|
pattern_template_store = PatternTemplateStore(db)
|
||||||
game_event_bus = GameEventBus()
|
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()
|
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(
|
processor_manager = ProcessorManager(
|
||||||
ProcessorDependencies(
|
ProcessorDependencies(
|
||||||
picture_source_store=picture_source_store,
|
picture_source_store=picture_source_store,
|
||||||
@@ -257,6 +269,15 @@ async def lifespan(app: FastAPI):
|
|||||||
client_labels = ", ".join(config.auth.api_keys.keys())
|
client_labels = ", ".join(config.auth.api_keys.keys())
|
||||||
logger.info(f"Authorized clients: {client_labels}")
|
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.
|
# One-shot migration: legacy global ``mqtt:`` config block → first MQTTSource.
|
||||||
# No-op once the store has any entries.
|
# No-op once the store has any entries.
|
||||||
try:
|
try:
|
||||||
@@ -290,6 +311,17 @@ async def lifespan(app: FastAPI):
|
|||||||
processor_manager=processor_manager,
|
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
|
# Create auto-backup engine — derive paths from database location so that
|
||||||
# demo mode auto-backups go to data/demo/ instead of data/.
|
# demo mode auto-backups go to data/demo/ instead of data/.
|
||||||
_data_dir = Path(config.storage.database_file).parent
|
_data_dir = Path(config.storage.database_file).parent
|
||||||
@@ -342,12 +374,19 @@ async def lifespan(app: FastAPI):
|
|||||||
ha_manager=ha_manager,
|
ha_manager=ha_manager,
|
||||||
game_integration_store=game_integration_store,
|
game_integration_store=game_integration_store,
|
||||||
game_event_bus=game_event_bus,
|
game_event_bus=game_event_bus,
|
||||||
|
lol_poll_manager=lol_poll_manager,
|
||||||
mqtt_store=mqtt_source_store,
|
mqtt_store=mqtt_source_store,
|
||||||
mqtt_manager=mqtt_manager,
|
mqtt_manager=mqtt_manager,
|
||||||
http_endpoint_store=http_endpoint_store,
|
http_endpoint_store=http_endpoint_store,
|
||||||
audio_processing_template_store=audio_processing_template_store,
|
audio_processing_template_store=audio_processing_template_store,
|
||||||
pattern_template_store=pattern_template_store,
|
pattern_template_store=pattern_template_store,
|
||||||
|
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
|
# Register devices in processor manager for health monitoring
|
||||||
devices = device_store.get_all_devices()
|
devices = device_store.get_all_devices()
|
||||||
@@ -387,12 +426,25 @@ async def lifespan(app: FastAPI):
|
|||||||
# Start automation engine (evaluates conditions and activates scenes)
|
# Start automation engine (evaluates conditions and activates scenes)
|
||||||
await automation_engine.start()
|
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)
|
# Start auto-backup engine (periodic configuration backups)
|
||||||
await auto_backup_engine.start()
|
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)
|
# Start update checker (periodic release polling)
|
||||||
await update_service.start()
|
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)
|
# Start OS notification listener (Windows toast → notification CSS streams)
|
||||||
os_notif_listener = OsNotificationListener(
|
os_notif_listener = OsNotificationListener(
|
||||||
color_strip_store=color_strip_store,
|
color_strip_store=color_strip_store,
|
||||||
@@ -438,6 +490,19 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Shutdown step '%s' raised: %s", label, 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.
|
# Legacy hook — SQLite stores are write-through so this only logs.
|
||||||
# Durability comes from PRAGMA synchronous=FULL + the explicit
|
# Durability comes from PRAGMA synchronous=FULL + the explicit
|
||||||
# wal_checkpoint(TRUNCATE) in Database.close() at the end of this block.
|
# 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.
|
# 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)
|
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
|
# Stop discovery watcher and OS notification listener so they stop
|
||||||
# firing events into a shutting-down processor manager.
|
# firing events into a shutting-down processor manager.
|
||||||
if discovery_watcher is not None:
|
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("update_service.stop", update_service.stop(), timeout=0.5)
|
||||||
await _bounded("auto_backup_engine.stop", auto_backup_engine.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
|
# 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
|
# 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 ────────────────────────────────────────────
|
# ── Auth-gated OpenAPI surface ────────────────────────────────────────────
|
||||||
# Re-add the docs endpoints we disabled above, now protected by the same
|
# Re-add the docs endpoints we disabled above, protected by the same Bearer
|
||||||
# Bearer auth as the rest of the API. When auth is unconfigured, loopback
|
# auth as the rest of the API. The ``DocsAccess`` dependency relaxes this to
|
||||||
# clients still get in anonymously (per ``verify_api_key`` policy).
|
# 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 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)
|
@app.get("/openapi.json", include_in_schema=False)
|
||||||
async def _openapi(_auth: AuthRequired):
|
async def _openapi(_auth: DocsAccess):
|
||||||
return JSONResponse(app.openapi())
|
return JSONResponse(app.openapi())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/docs", include_in_schema=False)
|
@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")
|
return get_swagger_ui_html(openapi_url="/openapi.json", title=f"{app.title} — API docs")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/redoc", include_in_schema=False)
|
@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")
|
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 (AUTH…CAPTURE) 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;
|
||||||
|
}
|
||||||
@@ -19,5 +19,6 @@
|
|||||||
@import './graph-editor.css';
|
@import './graph-editor.css';
|
||||||
@import './appearance.css';
|
@import './appearance.css';
|
||||||
@import './game-integration.css';
|
@import './game-integration.css';
|
||||||
|
@import './activity-log.css';
|
||||||
@import './mobile.css';
|
@import './mobile.css';
|
||||||
@import './tv.css';
|
@import './tv.css';
|
||||||
|
|||||||
@@ -196,6 +196,37 @@
|
|||||||
width: 100%;
|
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 {
|
.time-range-label {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -79,6 +79,12 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-screen-total:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-screen-total.mismatch {
|
.preview-screen-total.mismatch {
|
||||||
color: #FFC107;
|
color: #FFC107;
|
||||||
}
|
}
|
||||||
@@ -123,6 +129,12 @@
|
|||||||
background: rgba(128, 128, 128, 0.25);
|
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 {
|
.preview-edge.edge-disabled {
|
||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -374,6 +386,13 @@
|
|||||||
color: rgba(76, 175, 80, 0.6);
|
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 {
|
.preview-corner.active:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
@@ -412,6 +431,12 @@
|
|||||||
background: rgba(255, 255, 255, 0.25);
|
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 {
|
.direction-toggle #direction-icon {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ select.field-invalid {
|
|||||||
display: block;
|
display: block;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-sm);
|
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;
|
overflow: hidden;
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
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. */
|
/* Token palette — restrained, three accents plus muted operators. */
|
||||||
.jinja-hl .tok-str { color: var(--success-color); }
|
.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-fn { color: var(--primary-color); font-weight: 600; }
|
||||||
.jinja-hl .tok-raw { color: #c678dd; font-style: italic; }
|
.jinja-hl .tok-raw { color: var(--ch-violet); font-style: italic; }
|
||||||
.jinja-hl .tok-var { color: #61afef; }
|
.jinja-hl .tok-var { color: var(--ch-cyan); }
|
||||||
.jinja-hl .tok-op { color: var(--text-muted); }
|
.jinja-hl .tok-op { color: var(--text-muted); }
|
||||||
|
|
||||||
/* ── Template-input rows ─────────────────────────────────────── */
|
/* ── Template-input rows ─────────────────────────────────────── */
|
||||||
@@ -496,8 +496,8 @@ select.field-invalid {
|
|||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
background: color-mix(in srgb, #61afef 16%, transparent);
|
background: color-mix(in srgb, var(--ch-cyan) 16%, transparent);
|
||||||
color: #61afef;
|
color: var(--ch-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.jinja-hints-examples { margin: 4px 0 0; padding-left: 0; list-style: none; }
|
.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,
|
.discovery-item:focus-visible,
|
||||||
.tag-chip-remove:focus-visible,
|
.tag-chip-remove:focus-visible,
|
||||||
.cs-filter-reset: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: 2px solid var(--primary-color);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
@@ -1118,7 +1123,8 @@ textarea:focus-visible {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
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:
|
background:
|
||||||
repeating-linear-gradient(135deg,
|
repeating-linear-gradient(135deg,
|
||||||
color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px,
|
color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px,
|
||||||
@@ -1625,14 +1631,14 @@ textarea:focus-visible {
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border: 1px solid var(--border-color, #333);
|
border: 1px solid var(--border-color, #333);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--surface-2, #1e1e2e);
|
background: var(--lux-bg-2);
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
.type-picker-tab:hover {
|
.type-picker-tab:hover {
|
||||||
background: var(--surface-3, #2a2a3e);
|
background: var(--lux-bg-3);
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
.type-picker-tab.active {
|
.type-picker-tab.active {
|
||||||
@@ -2326,7 +2332,7 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.autocal-step-desc {
|
.autocal-step-desc {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted, var(--secondary-text-color));
|
color: var(--text-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -2355,7 +2361,8 @@ textarea:focus-visible {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.autocal-corner-btn:hover {
|
.autocal-corner-btn:hover,
|
||||||
|
.autocal-corner-btn:focus-visible {
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
|
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
@@ -2424,7 +2431,8 @@ textarea:focus-visible {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.autocal-direction-btn:hover {
|
.autocal-direction-btn:hover,
|
||||||
|
.autocal-direction-btn:focus-visible {
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
|
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
|
||||||
color: var(--primary-color);
|
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: 1px solid color-mix(in srgb, var(--primary-color) 20%, var(--border-color));
|
||||||
border-radius: var(--radius-sm, 6px);
|
border-radius: var(--radius-sm, 6px);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, var(--secondary-text-color));
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocal-led-dot {
|
.autocal-led-dot {
|
||||||
@@ -2489,7 +2497,7 @@ textarea:focus-visible {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 700;
|
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;
|
transition: border-color 0.2s, background 0.2s, color 0.2s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -2618,7 +2626,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.autocal-solved-key {
|
.autocal-solved-key {
|
||||||
color: var(--text-muted, var(--secondary-text-color));
|
color: var(--text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 68px;
|
min-width: 68px;
|
||||||
}
|
}
|
||||||
@@ -2720,7 +2728,7 @@ textarea:focus-visible {
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: var(--bg-secondary, var(--bg-2, #2a2a2a));
|
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);
|
border: 1.5px solid var(--border-color);
|
||||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
@@ -2775,7 +2783,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
.wizard-step-desc {
|
.wizard-step-desc {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted, var(--secondary-text-color));
|
color: var(--text-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -2829,7 +2837,7 @@ textarea:focus-visible {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted, var(--secondary-text-color));
|
color: var(--text-muted);
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
.wizard-section-label--scan {
|
.wizard-section-label--scan {
|
||||||
@@ -2860,7 +2868,7 @@ textarea:focus-visible {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted, var(--secondary-text-color));
|
color: var(--text-muted);
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md, 8px);
|
border-radius: var(--radius-md, 8px);
|
||||||
@@ -2868,7 +2876,7 @@ textarea:focus-visible {
|
|||||||
.wizard-discovery-empty {
|
.wizard-discovery-empty {
|
||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted, var(--secondary-text-color));
|
color: var(--text-muted);
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md, 8px);
|
border-radius: var(--radius-md, 8px);
|
||||||
@@ -2891,7 +2899,8 @@ textarea:focus-visible {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
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);
|
border-color: var(--primary-color);
|
||||||
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
|
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-icon .icon { width: 20px; height: 20px; }
|
||||||
.wizard-discovery-details { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
.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-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 {
|
.wizard-discovery-badge {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -2926,7 +2935,8 @@ textarea:focus-visible {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
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);
|
border-color: var(--primary-color);
|
||||||
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
|
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-icon .icon { width: 20px; height: 20px; }
|
||||||
.wizard-display-details { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
.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-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 { color: var(--primary-color); }
|
||||||
.wizard-display-check .icon { width: 16px; height: 16px; }
|
.wizard-display-check .icon { width: 16px; height: 16px; }
|
||||||
.wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; }
|
.wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; }
|
||||||
@@ -2953,7 +2963,7 @@ textarea:focus-visible {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md, 8px);
|
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 */
|
/* Calibrate container */
|
||||||
.wizard-calibrate-container {
|
.wizard-calibrate-container {
|
||||||
@@ -2990,7 +3000,7 @@ textarea:focus-visible {
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
.wizard-done-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; gap: 12px; }
|
.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-done-value { font-weight: 600; color: var(--text-color); text-align: right; }
|
||||||
|
|
||||||
/* Wizard form rows */
|
/* Wizard form rows */
|
||||||
|
|||||||
@@ -2512,7 +2512,11 @@
|
|||||||
padding-top: 8px;
|
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 */
|
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
|
||||||
border: 1px solid var(--danger-color);
|
border: 1px solid var(--danger-color);
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
@@ -3257,6 +3261,60 @@
|
|||||||
user-select: none;
|
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,
|
#gradient-canvas,
|
||||||
#ge-gradient-canvas {
|
#ge-gradient-canvas {
|
||||||
width: 100%;
|
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="filters"] { animation-delay: 0.10s; }
|
||||||
.ds-section[data-ds-key="routing"] { animation-delay: 0.06s; }
|
.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="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="filtering"] { animation-delay: 0.14s; }
|
||||||
.ds-section[data-ds-key="broker"] { animation-delay: 0.06s; }
|
.ds-section[data-ds-key="broker"] { animation-delay: 0.06s; }
|
||||||
.ds-section[data-ds-key="protocol"] { animation-delay: 0.10s; }
|
.ds-section[data-ds-key="protocol"] { animation-delay: 0.10s; }
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
|
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
|
||||||
saveAutomationEditor, addAutomationRule,
|
saveAutomationEditor, addAutomationRule,
|
||||||
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
|
toggleAutomationEnabled, triggerAutomationNow, cloneAutomation, deleteAutomation, copyWebhookUrl,
|
||||||
} from './features/automations.ts';
|
} from './features/automations.ts';
|
||||||
import {
|
import {
|
||||||
showGameIntegrationEditor, saveGameIntegration, closeGameIntegrationModal,
|
showGameIntegrationEditor, saveGameIntegration, closeGameIntegrationModal,
|
||||||
@@ -150,7 +150,7 @@ import {
|
|||||||
// Layer 5: color-strip sources
|
// Layer 5: color-strip sources
|
||||||
import {
|
import {
|
||||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||||
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
|
onCSSTypeChange, onEffectTypeChange, onEffectReactiveToggle, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
|
||||||
compositeAddLayer, compositeRemoveLayer,
|
compositeAddLayer, compositeRemoveLayer,
|
||||||
mappedAddZone, mappedRemoveZone,
|
mappedAddZone, mappedRemoveZone,
|
||||||
onAudioVizChange,
|
onAudioVizChange,
|
||||||
@@ -228,6 +228,24 @@ import {
|
|||||||
mountAutoCalibration, unmountAutoCalibration,
|
mountAutoCalibration, unmountAutoCalibration,
|
||||||
} from './features/auto-calibration.ts';
|
} 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
|
// Layer 5.5: graph editor
|
||||||
import {
|
import {
|
||||||
loadGraphEditor,
|
loadGraphEditor,
|
||||||
@@ -257,6 +275,7 @@ import {
|
|||||||
loadDaylightTimezone, saveDaylightTimezone,
|
loadDaylightTimezone, saveDaylightTimezone,
|
||||||
requestNotifPermissionFromSettings, testNotifFromSettings,
|
requestNotifPermissionFromSettings, testNotifFromSettings,
|
||||||
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
|
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||||
|
loadActivityLogSettings, saveActivityLogSettings, activityLogSettingsExport, clearActivityLog,
|
||||||
} from './features/settings.ts';
|
} from './features/settings.ts';
|
||||||
import {
|
import {
|
||||||
loadUpdateStatus, initUpdateListener, checkForUpdates,
|
loadUpdateStatus, initUpdateListener, checkForUpdates,
|
||||||
@@ -486,6 +505,7 @@ Object.assign(window, {
|
|||||||
saveAutomationEditor,
|
saveAutomationEditor,
|
||||||
addAutomationRule,
|
addAutomationRule,
|
||||||
toggleAutomationEnabled,
|
toggleAutomationEnabled,
|
||||||
|
triggerAutomationNow,
|
||||||
cloneAutomation,
|
cloneAutomation,
|
||||||
deleteAutomation,
|
deleteAutomation,
|
||||||
copyWebhookUrl,
|
copyWebhookUrl,
|
||||||
@@ -567,6 +587,7 @@ Object.assign(window, {
|
|||||||
deleteColorStrip,
|
deleteColorStrip,
|
||||||
onCSSTypeChange,
|
onCSSTypeChange,
|
||||||
onEffectTypeChange,
|
onEffectTypeChange,
|
||||||
|
onEffectReactiveToggle,
|
||||||
onEffectPaletteChange,
|
onEffectPaletteChange,
|
||||||
onCSSClockChange,
|
onCSSClockChange,
|
||||||
onAnimationTypeChange,
|
onAnimationTypeChange,
|
||||||
@@ -741,6 +762,10 @@ Object.assign(window, {
|
|||||||
saveExternalUrl,
|
saveExternalUrl,
|
||||||
revertExternalUrl,
|
revertExternalUrl,
|
||||||
getBaseOrigin,
|
getBaseOrigin,
|
||||||
|
loadActivityLogSettings,
|
||||||
|
saveActivityLogSettings,
|
||||||
|
activityLogSettingsExport,
|
||||||
|
clearActivityLog,
|
||||||
|
|
||||||
// update
|
// update
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
@@ -762,6 +787,22 @@ Object.assign(window, {
|
|||||||
applyStylePreset,
|
applyStylePreset,
|
||||||
applyBgEffect,
|
applyBgEffect,
|
||||||
renderAppearanceTab,
|
renderAppearanceTab,
|
||||||
|
|
||||||
|
// activity log
|
||||||
|
loadActivityLog,
|
||||||
|
activityLogToggleDetail,
|
||||||
|
activityLogToggleCat,
|
||||||
|
activityLogToggleSev,
|
||||||
|
activityLogOnSearch,
|
||||||
|
activityLogOnActor,
|
||||||
|
activityLogOnEntityType,
|
||||||
|
activityLogOnSince,
|
||||||
|
activityLogOnUntil,
|
||||||
|
activityLogClearFilters,
|
||||||
|
activityLogPreset,
|
||||||
|
activityLogLoadMore,
|
||||||
|
activityLogExport,
|
||||||
|
activityLogNavigateToEntity,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Global keyboard shortcuts ───
|
// ─── Global keyboard shortcuts ───
|
||||||
@@ -779,7 +820,7 @@ document.addEventListener('keydown', (e) => {
|
|||||||
|
|
||||||
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs)
|
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs)
|
||||||
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
|
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];
|
const tab = tabMap[e.key];
|
||||||
if (tab) {
|
if (tab) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { openAuthedWs } from './ws-auth.ts';
|
|||||||
* update_download_progress — update_service.py (consumed by features/update.ts)
|
* update_download_progress — update_service.py (consumed by features/update.ts)
|
||||||
* device_discovered — discovery_watcher.py (consumed by features/notifications-watcher.ts)
|
* device_discovered — discovery_watcher.py (consumed by features/notifications-watcher.ts)
|
||||||
* device_lost — 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
|
* Missing any of these silently breaks the corresponding UI flow — keep
|
||||||
* this list in sync when adding new event types on the server side.
|
* 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',
|
'update_download_progress',
|
||||||
'device_discovered',
|
'device_discovered',
|
||||||
'device_lost',
|
'device_lost',
|
||||||
|
'activity_logged', // source: core/activity_log/recorder.py
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface ServerEventEnvelope {
|
interface ServerEventEnvelope {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { IconSelect } from './icon-select.ts';
|
|||||||
let currentLocale = 'en';
|
let currentLocale = 'en';
|
||||||
let _localeIconSelect: IconSelect | null = null;
|
let _localeIconSelect: IconSelect | null = null;
|
||||||
let translations = {};
|
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;
|
let _initialized = false;
|
||||||
|
|
||||||
const supportedLocales = {
|
const supportedLocales = {
|
||||||
@@ -37,9 +40,12 @@ export function t(key: string, params: Record<string, any> = {}) {
|
|||||||
let text;
|
let text;
|
||||||
if ('count' in params) {
|
if ('count' in params) {
|
||||||
const form = getPluralForm(currentLocale, params.count);
|
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 {
|
} else {
|
||||||
text = translations[key] || fallbackTranslations[key] || key;
|
text = translations[key] || baseTranslations[key] || fallbackTranslations[key] || key;
|
||||||
}
|
}
|
||||||
Object.keys(params).forEach(param => {
|
Object.keys(params).forEach(param => {
|
||||||
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
|
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
|
||||||
@@ -80,6 +86,13 @@ export async function setLocale(locale: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
translations = await loadTranslations(locale);
|
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;
|
currentLocale = locale;
|
||||||
document.documentElement.setAttribute('data-locale', locale);
|
document.documentElement.setAttribute('data-locale', locale);
|
||||||
document.documentElement.setAttribute('lang', 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
|
// 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"/>';
|
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.
|
// Easing curve glyphs — custom mini-charts that draw the actual curve.
|
||||||
// Curve travels from (4, 20) to (20, 4); each path renders the easing
|
// Curve travels from (4, 20) to (20, 4); each path renders the easing
|
||||||
// function directly so the picker shows the shape, not a metaphor.
|
// 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_GIT_MERGE = _svg(P.gitMerge);
|
||||||
export const ICON_COPY = _svg(P.copy);
|
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 ─────────────────────────────────
|
// ── Game integration icons ─────────────────────────────────
|
||||||
|
|
||||||
export const ICON_GAMEPAD = _svg(P.gamepad2);
|
export const ICON_GAMEPAD = _svg(P.gamepad2);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const TAB_REGISTRY: Readonly<Record<string, TabConfig>> = {
|
|||||||
automations: { loadFnName: 'loadAutomations',
|
automations: { loadFnName: 'loadAutomations',
|
||||||
subTab: { storageKey: 'activeAutomationTab', defaultSubTab: 'automations', switchFnName: 'switchAutomationTab' } },
|
subTab: { storageKey: 'activeAutomationTab', defaultSubTab: 'automations', switchFnName: 'switchAutomationTab' } },
|
||||||
graph: { loadFnName: 'loadGraphEditor' },
|
graph: { loadFnName: 'loadGraphEditor' },
|
||||||
|
activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get the full config for a tab, or undefined if not registered. */
|
/** Get the full config for a tab, or undefined if not registered. */
|
||||||
|
|||||||
@@ -555,6 +555,90 @@ export function formatCompact(n: number | null | undefined) {
|
|||||||
return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B';
|
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 {
|
export function formatUptime(seconds: number | null | undefined): string {
|
||||||
if (!seconds || seconds <= 0) return '-';
|
if (!seconds || seconds <= 0) return '-';
|
||||||
const total = Math.floor(seconds);
|
const total = Math.floor(seconds);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user