Compare commits

..

6 Commits

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

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

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

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

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

Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing
test (automation manual_trigger handler coverage) is a separate in-progress
item owned elsewhere, intentionally left as-is.
2026-06-22 23:21:24 +03:00
117 changed files with 6633 additions and 654 deletions
+52
View File
@@ -354,6 +354,58 @@ jobs:
docker push "$REGISTRY:latest"
fi
# Best-effort arm64 (Raspberry Pi / arm64 HAOS hosts). Runs AFTER the
# amd64 push so the amd64 image always ships even if this fails.
# Deliberately avoids `docker buildx` (its docker-container driver needs
# nested networking the TrueNAS runners lack — see contexts/ci-cd.md):
# instead it cross-builds a single arm64 image via QEMU binfmt and folds
# amd64 + arm64 into multi-arch manifest lists under the existing tags.
# `continue-on-error` keeps a runner that can't emulate arm64 from
# failing the release; the plain amd64 tags pushed above remain valid.
- name: Build + publish arm64 (multi-arch manifest, best-effort)
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
continue-on-error: true
run: |
set -e
export DOCKER_CLI_EXPERIMENTAL=enabled
TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}"
VERSION="${{ steps.meta.outputs.version }}"
# Register arm64 emulation. If the runner forbids privileged
# containers this fails and the whole step is skipped.
docker run --privileged --rm tonistiigi/binfmt --install arm64
# Cross-build the arm64 image (QEMU-emulated — slow but uses arm64
# manylinux wheels, so no source compilation). Stays in the local
# daemon alongside the amd64 image from the previous build step.
DOCKER_BUILDKIT=1 docker build \
--platform linux/arm64 \
--build-arg APP_VERSION="$VERSION" \
--label "org.opencontainers.image.version=$VERSION" \
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
-t "$REGISTRY:$VERSION-arm64" \
./server
# Fold amd64 + arm64 into a multi-arch manifest list under each
# user-facing tag. The arch-suffixed tags remain pullable directly.
publish_manifest() {
local t="$1"
docker tag "$REGISTRY:$t" "$REGISTRY:$t-amd64"
docker push "$REGISTRY:$t-amd64"
docker tag "$REGISTRY:$VERSION-arm64" "$REGISTRY:$t-arm64"
docker push "$REGISTRY:$t-arm64"
docker manifest create --amend "$REGISTRY:$t" \
"$REGISTRY:$t-amd64" "$REGISTRY:$t-arm64"
docker manifest push "$REGISTRY:$t"
}
publish_manifest "$TAG"
publish_manifest "$VERSION"
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
publish_manifest "latest"
fi
# ── Publish the release (flip draft=false) ─────────────────
# Runs only after every build job succeeded so users never see a
# release that's missing artifacts or sha256 sidecars (the in-app
+33 -2
View File
@@ -2,9 +2,40 @@
## Code Search
**If `ast-index` is available, use it as the PRIMARY code search tool.** It is significantly faster than grep and returns structured, accurate results. Fall back to grep/Glob only when ast-index is not installed, returns empty results, or when searching regex patterns/string literals/comments.
**Priority order: `vex` (PRIMARY) → `ast-index` (fallback) → Grep/Glob (last resort).** This repo has a fully-featured `.vex.toml` index. Use vex first for any symbol/definition/usage/call-graph lookup. Fall back to ast-index only when vex legitimately can't help, and to Grep/Glob only for regex patterns, string literals, comments, config files, or unparsed languages.
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt.
**IMPORTANT — use ALL vex indexing features.** The index is built with every capability enabled, and queries must take advantage of them. Keep them ON and exploit them:
| Capability | Status | Powers |
| ---------- | ------ | ------ |
| Semantic embeddings (`jina-code`, 768-dim) | ON | `vex search` (semantic channel), `similar`, `find_similar`, `duplicates` |
| Call graph | ON | `vex callers`, `callees`, `paths`, `reachable`, `bundle --mode pr-impact` |
| BM25 | ON | hybrid RRF text channel in `vex search` |
| Pattern index | ON | `vex pattern` AST-shape matching |
| C++ includes | ON | include-graph resolution |
| Body tokens (incremental HNSW) | ON | fast incremental reindex |
| History | ON | `vex history`, `vex diff <rev>` blame/evolution queries |
**In-session, use the `mcp__vex__*` MCP tools** (`search`, `show`, `usages`, `callers`, `callees`, `bundle`, `outline`, `implementations`, `similar`, `grep`, `status`, etc.) — MCP output is far cheaper in tokens than `Bash("vex …")`. Drop to Bash `vex` only for CLI-only features (`pattern`, `diff`, `paths`, `reachable`, `bundle`, `history`, `--strict`/`--why` flags), for subagent prompts, or for shell composition.
```bash
vex search "query" --semantic # Hybrid semantic + BM25 search
vex show <Symbol> # Definition body (prefer over Read)
vex usages <Symbol> --strict # Reference sites (AST-precise on T1 langs)
vex callers <Function> # Call sites (function-scoped)
vex callees <Function> # Outgoing calls
vex paths --from <A> --to <B> # Multi-hop call-graph path
vex bundle --mode pr-impact --base master # Changed symbols + callers + reachable tests
vex pattern '$X async fn returning Response' # AST-shape (metavariables)
vex diff master # Symbol-level branch diff
vex history <Symbol> # Commit evolution of a symbol
```
**Maintenance:** the index has `auto_update = true`, so it refreshes on stale queries. After a `vex self-update`, rerun `vex index --history --semantic --embedder jina-code --device cuda` so newly-added extractors populate and all features stay enabled. Verify with `vex status` — every capability line should read `yes`.
**IMPORTANT for subagents:** Subagents don't inherit MCP. When spawning Agent subagents (Plan, Explore, general-purpose, etc.), instruct them to use `vex` via Bash for code search (e.g. include "Use `vex search`, `vex show`, `vex usages`, `vex callers` via Bash for code search; ast-index is the fallback"). Don't tell them to default to grep/Glob.
**Fallback — `ast-index`** (use only when vex is unavailable):
```bash
ast-index search "Query" # Universal search
+69 -78
View File
@@ -1,100 +1,91 @@
## v0.8.2 (2026-06-08)
## v0.9.0 (2026-06-23)
### User-facing changes
A large feature release: a full activity/audit log, two roadmap batches of
capture and smart-light improvements, per-pixel control for LIFX/Hue/Nanoleaf,
and new outbound integrations (webhooks + Home Assistant MQTT discovery).
#### Features
### Features
##### WLED native realtime UDP output
- New realtime UDP sink speaking WLED's **DRGB / DRGBW / DNRGB** protocols, with automatic revert to the device's prior state when streaming stops ([7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec))
#### Activity Log
- Persistent activity/audit log: storage model with migration, recorder with
actor context and retention, and event instrumentation across four
categories ([1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f), [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e), [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c))
- REST API for list / export / settings / clear ([4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275))
- Activity tab with smart filtering, live updates, and export, plus a
dashboard widget and settings panel ([9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f), [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21))
##### Automatic brightness limiting (ABL) / power budget
- Per-LED power budgeting that caps total draw by scaling brightness to a configurable current/PSU limit, preventing brownouts on long strips ([ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156))
#### Per-pixel smart lights
- LIFX multizone (SetExtendedColorZones) and Tile per-pixel streaming,
auto-detected on connect with single-colour fallback ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- Philips Hue gradient-lightstrip mapping: Entertainment v2 frames keyed by
channel id, with a `hue_gradient_mode` toggle ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- Nanoleaf extControl v2 per-panel UDP streaming (`per_panel` mode) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
##### Scene playlists
- Scenes can be grouped into **playlists with timed auto-cycling**, so a target can rotate through looks on a schedule ([f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e))
- Playlist + cycling state is included in the aggregated `/snapshot` response ([abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c))
#### Capture & effects
- Linear-light blending and spatio-temporal dithering, opt-in per calibration ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- Audio-reactive palette modulation across all 12 procedural effects ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- Color-harmony gradient generator (complementary / analogous / triadic / …) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
##### Auto edge-calibration + guided first-run setup wizard
- Backend core for **automatic screen-edge calibration** ([0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8)), a one-call setup scaffold with an onboarding flag ([9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d)), and a browser-driven calibration UI ([9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688))
- A **guided first-run setup wizard** ties it together for new installs ([81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808)), with all-provider source discovery and a spatial corner picker ([dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38))
#### Automations & integrations
- Solar sunrise/sunset automation trigger (new `utils/solar.py`) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- Outbound webhook automation action (Discord / IFTTT / Zapier / Node-RED),
SSRF-gated at save and fire time ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- Home Assistant MQTT auto-discovery: read-only binary sensors per automation,
availability via birth/will, with cleanup on disable/delete ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
- League of Legends poller wired via a `LoLPollManager` + shared runtime state ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
- `auth.expose_docs` flag (default off) to view `/docs`, `/redoc`, and
`/openapi.json` without a token; all real endpoints stay protected ([126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2))
##### Region-of-interest (ROI) screen capture
- Screen sampling can now be cropped to a **region of interest** instead of the whole display ([ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546))
##### Built-in "look" presets
- One-click looks: **Cinematic / Vivid / Cozy / Soft / Cool** ([e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c))
##### Weekday + timezone scheduling
- The time-of-day automation rule now supports **weekday selection and explicit timezones** ([1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac))
##### Value sources
- New **sandboxed-Jinja template combinator** for composing value sources ([6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9)) and optional normalization for magnitude sources ([669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20))
##### Visual graph editor
- The editor is now a **full wiring control surface** ([2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46)), and you can **duplicate a selected subgraph** server-side ([15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82))
##### Android on-device capture
- **System audio playback capture** ([fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1)), **OS notification capture** via NotificationListenerService ([0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83)), **webcam capture** via Camera2 ([4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6)), and a **foreground-app automation condition** ([1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2))
#### Bug Fixes
- **Security:** removed an active **weak default API key** from the shipped config — fresh installs no longer ship with a guessable key. Set your own key on first run ([5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5))
- Removed a broken legacy `/system/mqtt/settings` route ([fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201))
- Scene brightness value-source changes now sync to the live processor immediately ([02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3))
- Wizard hardening: scaffolded targets are registered with the ProcessorManager and the final review step is more robust ([6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05))
- Installer opens the WebUI only once after "Launch LedGrab" ([05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121))
### Bug Fixes
- Pre-release review hardening: solar timezone crash, webhook header CRLF,
MQTT topic-prefix injection, thread-safe `get_stats` copy, MQTT discovery
lock, `reactive_mode` Literal, and calibration-modal accessibility ([0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db))
- Comprehensive review fixes across security, concurrency, performance,
Android, and UI ([17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0))
- Activity Log polish: accessible export menu, i18n placeholders, dashboard
section reconciliation, column alignment, ticking time, and no spinner
flash on instant filtering ([3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3), [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06), [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8), [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca), [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c))
---
### Development / Internal
#### Backend / Storage
- `clone()` is now gated behind an **opt-in allowlist**, with expanded duplicate-handling tests ([498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f))
#### CI/Build
- Best-effort arm64 multi-arch Docker manifest via QEMU + `docker manifest`
(amd64 path untouched) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
#### Frontend
- In-progress dashboard customization groundwork ([6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569))
#### Chores
- Activity Log feature plan/subplan scaffold, post-merge cleanup, and context
graduated into CLAUDE.md ([1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6), [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235))
#### Docs
- Actualized README + API reference with embedded screenshots ([12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6)), graph-editor wiring-control roadmap ([d505388](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d505388)), Android audio-capture design notes ([4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc)); removed stale ANDROID-REVIEW planning docs ([9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15))
#### Tests
- Large new suites for calibration solver/session (incl. adversarial), setup & scene-playlist routes, playlist engine, and ROI capture. Full suite: **2149 passing, 2 skipped**
> Tests: ~180 new unit tests added across the activity log, roadmap features,
> and integrations. Release gate green: ruff + tsc + build clean,
> **pytest 2739 passed / 2 skipped**.
---
<details>
<summary>All Commits (31)</summary>
<summary>All Commits</summary>
| Hash | Message | Author |
| ---- | ------- | ------ |
| [dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38) | fix(calibration-wizard): all-provider discovery + spatial corner picker | alexei.dolgolyov |
| [6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05) | fix(setup): register scaffolded target with ProcessorManager + final-review hardening | alexei.dolgolyov |
| [81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808) | feat(onboarding): guided first-run setup wizard (phase 4, final) | alexei.dolgolyov |
| [abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c) | feat(snapshot): include scene playlists + cycling state in snapshot | alexei.dolgolyov |
| [9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688) | feat(calibration): browser-driven auto edge-calibration UI (phase 3) | alexei.dolgolyov |
| [9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d) | feat(setup): one-call setup scaffold + onboarding flag (phase 2) | alexei.dolgolyov |
| [0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8) | feat(calibration): auto edge-calibration backend core (phase 1) | alexei.dolgolyov |
| [6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569) | wip(dashboard): in-progress dashboard customization changes | alexei.dolgolyov |
| [f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e) | feat(scenes): scene playlists with timed auto-cycling | alexei.dolgolyov |
| [ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546) | feat(capture): region-of-interest (ROI) crop for screen sampling | alexei.dolgolyov |
| [1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac) | feat(automations): weekday + timezone scheduling for time-of-day rule | alexei.dolgolyov |
| [e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c) | feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool) | alexei.dolgolyov |
| [7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec) | feat(wled): native realtime UDP output (DRGB/DRGBW/DNRGB) with auto-revert | alexei.dolgolyov |
| [ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156) | feat(targets): automatic brightness limiting (ABL) / per-LED power budget | alexei.dolgolyov |
| [02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3) | fix(scenes): sync brightness value-source change to live processor | alexei.dolgolyov |
| [fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201) | fix(api): remove broken legacy /system/mqtt/settings route | alexei.dolgolyov |
| [5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5) | fix(security): remove active weak default API key from shipped config | alexei.dolgolyov |
| [9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15) | docs(android): remove ANDROID-REVIEW planning/review docs | alexei.dolgolyov |
| [1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2) | feat(android): foreground-app automation condition | alexei.dolgolyov |
| [4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6) | feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine) | alexei.dolgolyov |
| [0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83) | feat(android): on-device OS notification capture (NotificationListenerService) | alexei.dolgolyov |
| [4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc) | docs(android): add audio-capture design + missing-functionality review | alexei.dolgolyov |
| [fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1) | feat(audio): Android on-device system playback capture | alexei.dolgolyov |
| [669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20) | feat(value-sources): optional normalization for magnitude sources | alexei.dolgolyov |
| [6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9) | feat(value-sources): add sandboxed-Jinja template combinator | alexei.dolgolyov |
| [12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6) | docs: actualize README and API reference, embed screenshots | alexei.dolgolyov |
| [498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f) | refactor(storage): gate clone() behind an opt-in allowlist; expand duplicate tests | alexei.dolgolyov |
| [15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82) | feat(graph): duplicate a selected subgraph server-side | alexei.dolgolyov |
| [2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46) | feat(graph): make the visual editor a full wiring control surface | alexei.dolgolyov |
| [05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121) | fix(installer): open WebUI once after "Launch LedGrab" | alexei.dolgolyov |
|------|---------|--------|
| [0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db) | fix: address pre-release review findings (2026-06-23) | alexei.dolgolyov |
| [39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554) | feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations | alexei.dolgolyov |
| [6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25) | feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations | alexei.dolgolyov |
| [126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2) | feat(auth): add auth.expose_docs flag to view API docs without a token | alexei.dolgolyov |
| [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235) | chore(activity-log): post-merge cleanup + graduate context to CLAUDE.md | alexei.dolgolyov |
| [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c) | fix(activity-log): no spinner flash on instant filtering | alexei.dolgolyov |
| [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca) | fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix | alexei.dolgolyov |
| [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8) | fix(activity-log): dashboard section reconciliation + activity column alignment | alexei.dolgolyov |
| [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06) | fix(activity-log): post-test polish - localize descriptions, dashboard widget, ticking time | alexei.dolgolyov |
| [3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3) | fix(activity-log): final-review fixes - crosslink keys + sanitize parity | alexei.dolgolyov |
| [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21) | feat(activity-log): phase 6 - dashboard widget + settings panel + docs | alexei.dolgolyov |
| [9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f) | feat(activity-log): phase 5 - Activity tab (smart filtering, live updates, export) | alexei.dolgolyov |
| [4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275) | feat(activity-log): phase 4 - REST API (list/export/settings/clear) | alexei.dolgolyov |
| [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c) | feat(activity-log): phase 3 - event instrumentation (4 categories) | alexei.dolgolyov |
| [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e) | feat(activity-log): phase 2 - recorder, actor context, retention, lifecycle | alexei.dolgolyov |
| [1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f) | feat(activity-log): phase 1 - storage model, migration, repository | alexei.dolgolyov |
| [1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6) | chore(activity-log): scaffold feature plan and phase subplans | alexei.dolgolyov |
| [17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0) | fix: resolve comprehensive review findings (security, concurrency, perf, Android, UI) | alexei.dolgolyov |
</details>
+1 -1
View File
@@ -41,7 +41,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.8.2"
versionName = "0.9.0"
// ABI selection. Detect armeabi-v7a wheel presence and opt the
// ABI in only when the matching pydantic-core wheel is on disk —
@@ -32,15 +32,15 @@ class ApiKeyManager(context: Context) {
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
// or absent keystore, or a key got corrupted), creation throws — fall back
// to plain SharedPreferences so a keystore failure NEVER bricks the local
// API key (which would 401 every LAN client). [encrypted] records which
// path we took so we don't repeatedly attempt migration.
private val encrypted: Boolean
// API key (which would 401 every LAN client).
private val prefs: SharedPreferences
init {
val (store, isEncrypted) = buildPrefs(appContext)
prefs = store
encrypted = isEncrypted
// 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()
}
@@ -113,26 +113,48 @@ class ApiKeyManager(context: Context) {
*/
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
return try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val store = EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
store to true
createEncrypted(context) to true
} catch (e: Exception) {
// Keystore unavailable/corrupt — degrade to plain prefs rather
// than crashing. Worst case the key is stored unencrypted on a
// single-user TV box, which is the pre-existing behaviour.
Log.w(TAG, "EncryptedSharedPreferences unavailable, using plain prefs: ${e.message}")
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
// 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
@@ -176,12 +198,17 @@ class ApiKeyManager(context: Context) {
/**
* Recover a still-present key from the legacy plain store — either the live
* key (failed/never-run migration) or the `.migrated` backup. Returns null
* when on the plain-prefs path (no legacy/encrypted split) or no valid key
* survives. Guarantees [getOrCreateKey] never rotates an existing key as long
* as the legacy file survives.
* 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? {
if (!encrypted) return null
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val candidate = legacy.getString(KEY_API_KEY, null)
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
@@ -42,6 +42,12 @@ class LedGrabNotificationListener : NotificationListenerService() {
// onListenerConnected can't race two executors into existence.
private val executorLock = Any()
// Tracks whether the listener is currently connected. ensureExecutor() only
// CREATES a new executor while connected — otherwise a notification racing
// onListenerDisconnected (which nulls pushExecutor) would spin up a fresh
// executor that nothing reaps until the next disconnect cycle (a thread leak).
@Volatile private var connected: Boolean = false
// packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working.
// Naturally bounded by the number of notification-posting apps (tens) and
@@ -74,7 +80,10 @@ class LedGrabNotificationListener : NotificationListenerService() {
// executor that onListenerDisconnected is shutting down throws
// RejectedExecutionException — guard with runCatching so a notification
// racing teardown can never crash this system-bound service.
val executor = ensureExecutor()
val executor = ensureExecutor() ?: run {
Log.d(TAG, "no executor (listener disconnected) — skipping push")
return
}
runCatching {
executor.execute {
try {
@@ -116,13 +125,16 @@ class LedGrabNotificationListener : NotificationListenerService() {
}
/**
* Return the push executor, creating it under [executorLock] if absent.
* Safe against a concurrent onListenerConnected/onNotificationPosted race
* (single executor) and against a missing onListenerConnected callback.
* 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 {
private fun ensureExecutor(): ExecutorService? {
pushExecutor?.let { return it }
synchronized(executorLock) {
if (!connected) return null
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
}
}
@@ -134,15 +146,16 @@ class LedGrabNotificationListener : NotificationListenerService() {
// executor here rather than in onCreate/onDestroy. onNotificationPosted
// also lazily creates it (via ensureExecutor) in case this callback is
// late or skipped on some ROMs.
connected = true
ensureExecutor()
}
override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected")
// Tear the executor down on disconnect; a fresh one is created on the
// next onListenerConnected. Null out first so any in-flight
// onNotificationPosted snapshots see null (skips submit) rather than
// racing a shutdown executor.
// 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()
@@ -152,6 +165,7 @@ class LedGrabNotificationListener : NotificationListenerService() {
override fun onDestroy() {
// Defensive: onListenerDisconnected normally clears this first, but
// shut down here too in case onDestroy fires without a prior disconnect.
connected = false
pushExecutor?.shutdown()
pushExecutor = null
super.onDestroy()
+7 -1
View File
@@ -31,9 +31,15 @@ Creates the Gitea release with a description table listing all artifacts. **The
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
### 4. `build-docker`
- Plain `docker build` + `docker push` (no Buildx — TrueNAS runners lack nested networking)
- Plain `docker build` + `docker push` for **amd64** (no Buildx — TrueNAS runners lack nested networking)
- Registry: `{gitea_host}/{repo}:{tag}`
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
- **arm64 is best-effort** (Raspberry Pi / arm64 HAOS): a `continue-on-error` step
cross-builds arm64 via **QEMU binfmt** (`tonistiigi/binfmt`) + `docker manifest`
(NOT buildx — sidesteps the docker-container-driver networking limit) and folds
amd64 + arm64 into multi-arch manifest lists under the same tags, plus
`:{tag}-amd64` / `:{tag}-arm64` arch-suffixed tags. If the runner can't run
privileged binfmt the step is skipped and the amd64 tags above remain valid.
## Build Scripts
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.8.2"
version = "0.9.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+81 -35
View File
@@ -3,7 +3,9 @@
import asyncio
import json
import secrets
import threading
import time
from collections import OrderedDict
from typing import Annotated
from urllib.parse import urlparse
@@ -31,15 +33,26 @@ logger = get_logger(__name__)
# suppressed — only the *audit recording* is de-duplicated.
#
# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP
# entries. When the cap is exceeded the oldest-seen IP (lowest timestamp) is
# evicted so the dict stays bounded regardless of the number of distinct source
# IPs an attacker can forge.
# 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
_auth_record_last: dict[str, float] = {}
# 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:
@@ -48,19 +61,26 @@ def _should_record_auth_failure(client_ip: str) -> bool:
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()
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
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 single oldest entry.
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
oldest_ip = min(_auth_record_last, key=lambda ip: _auth_record_last[ip])
del _auth_record_last[oldest_ip]
# 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)
_auth_record_last[client_ip] = now
return True
# 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:
@@ -72,23 +92,29 @@ def _record_auth_failure(reason: str, client_host: str | None) -> None:
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).
"""
if not _should_record_auth_failure(client_host or "unknown"):
return # throttled — drop duplicate recording for this IP/window
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
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"},
)
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:
@@ -142,7 +168,7 @@ def _is_loopback(host: str | None) -> bool:
return _classify_is_loopback(host)
def verify_api_key(
async def verify_api_key(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str:
@@ -156,6 +182,13 @@ def verify_api_key(
LAN access requires an API key).
- When API keys ARE configured, valid Bearer credentials are required.
This is an ``async`` dependency on purpose: token comparison is CPU-trivial,
and an async dependency runs in the SAME task/context as the route handler,
so ``current_actor.set(...)`` below is visible to ``ActivityRecorder`` when
the handler later records an entity event. A sync dependency would run in a
throwaway threadpool context and the actor mutation would be discarded,
attributing every audited action to "system".
Args:
request: incoming request (used to read client host)
credentials: HTTP authorization credentials
@@ -202,10 +235,16 @@ def verify_api_key(
# Extract token — NEVER log or record the token value itself.
token = credentials.credentials
# Find matching key and return its label using constant-time comparison
# Find matching key and return its label using constant-time comparison.
# Compare UTF-8 byte encodings: secrets.compare_digest raises TypeError on
# non-ASCII str (an attacker can put 0x80-0xFF in the Authorization header,
# which Starlette latin-1-decodes to a non-ASCII str). Byte comparison is
# well-defined for any input and preserves constant-time behavior, so a
# bad/non-ASCII token cleanly falls through to the 401 below instead of 500.
token_b = (token or "").encode("utf-8")
authenticated_as = None
for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
authenticated_as = label
break
@@ -235,7 +274,7 @@ def verify_api_key(
AuthRequired = Annotated[str, Depends(verify_api_key)]
def verify_docs_access(
async def verify_docs_access(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str:
@@ -253,7 +292,7 @@ def verify_docs_access(
if get_config().auth.expose_docs:
request.state.auth_label = "anonymous-docs"
return "anonymous-docs"
return verify_api_key(request, credentials)
return await verify_api_key(request, credentials)
# Dependency for the OpenAPI docs routes — relaxed when auth.expose_docs is set
@@ -361,12 +400,19 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
def _match_api_key(token: str) -> str | None:
"""Return the label matching *token* using constant-time comparison, or None."""
"""Return the label matching *token* using constant-time comparison, or None.
Compares UTF-8 byte encodings so a non-ASCII token (a JSON string in the WS
auth message trivially carries non-ASCII) cannot raise TypeError out of
``secrets.compare_digest`` — it simply fails to match and yields a clean
``auth_error`` instead of crashing the handler.
"""
config = get_config()
if not token:
return None
token_b = token.encode("utf-8")
for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
return label
return None
+40 -24
View File
@@ -37,6 +37,7 @@ from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
@@ -173,6 +174,15 @@ def get_game_event_bus() -> GameEventBus:
return _get("game_event_bus", "Game event bus")
def get_lol_poll_manager() -> LoLPollManager | None:
"""LoL poll manager, or None if not wired (e.g. minimal test harnesses).
Polling is a best-effort background feature, so callers guard on None
rather than 500-ing a CRUD request when the manager is absent.
"""
return _deps.get("lol_poll_manager")
def get_mqtt_store() -> MQTTSourceStore:
return _get("mqtt_store", "MQTT source store")
@@ -216,36 +226,40 @@ def get_activity_log_retention_engine() -> ActivityLogRetentionEngine:
# ── Event helper ────────────────────────────────────────────────────────
# entity_type → (_deps key, store method name) for human-name resolution.
# Module-level constant: built once at import rather than per audited mutation
# (``_resolve_entity_name`` is the create/update audit choke point).
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
"output_target": ("output_target_store", "get_target"),
"device": ("device_store", "get_device"),
"picture_source": ("picture_source_store", "get_source"),
"audio_source": ("audio_source_store", "get_source"),
"color_strip_source": ("color_strip_store", "get_source"),
"template": ("template_store", "get_template"),
"capture_template": ("template_store", "get_template"),
"pp_template": ("pp_template_store", "get_template"),
"automation": ("automation_store", "get_automation"),
"scene_preset": ("scene_preset_store", "get_preset"),
"scene_playlist": ("scene_playlist_store", "get_playlist"),
"sync_clock": ("sync_clock_store", "get_clock"),
"gradient": ("gradient_store", "get_gradient"),
"audio_template": ("audio_template_store", "get_template"),
"value_source": ("value_source_store", "get_source"),
"cspt": ("cspt_store", "get_template"),
"audio_processing_template": ("audio_processing_template_store", "get_template"),
"pattern_template": ("pattern_template_store", "get_template"),
"home_assistant_source": ("ha_store", "get_source"),
"mqtt_source": ("mqtt_store", "get_source"),
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
}
def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None:
"""Best-effort: look up a human name for *entity_id* from the matching store.
Returns ``None`` when the store is missing, the entity is gone, or any
exception occurs (e.g. during delete the entity may have just been removed).
"""
# Map entity_type → (_deps key, method name on the store)
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
"output_target": ("output_target_store", "get_target"),
"device": ("device_store", "get_device"),
"picture_source": ("picture_source_store", "get_source"),
"audio_source": ("audio_source_store", "get_source"),
"color_strip_source": ("color_strip_store", "get_source"),
"template": ("template_store", "get_template"),
"capture_template": ("template_store", "get_template"),
"pp_template": ("pp_template_store", "get_template"),
"automation": ("automation_store", "get_automation"),
"scene_preset": ("scene_preset_store", "get_preset"),
"scene_playlist": ("scene_playlist_store", "get_playlist"),
"sync_clock": ("sync_clock_store", "get_clock"),
"gradient": ("gradient_store", "get_gradient"),
"audio_template": ("audio_template_store", "get_template"),
"value_source": ("value_source_store", "get_source"),
"cspt": ("cspt_store", "get_template"),
"audio_processing_template": ("audio_processing_template_store", "get_template"),
"pattern_template": ("pattern_template_store", "get_template"),
"home_assistant_source": ("ha_store", "get_source"),
"mqtt_source": ("mqtt_store", "get_source"),
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
}
entry = _STORE_LOOKUP.get(entity_type)
if entry is None:
return None
@@ -356,6 +370,7 @@ def init_dependencies(
ha_manager: HomeAssistantManager | None = None,
game_integration_store: GameIntegrationStore | None = None,
game_event_bus: GameEventBus | None = None,
lol_poll_manager: LoLPollManager | None = None,
mqtt_store: MQTTSourceStore | None = None,
mqtt_manager: MQTTManager | None = None,
http_endpoint_store: HTTPEndpointStore | None = None,
@@ -397,6 +412,7 @@ def init_dependencies(
"ha_manager": ha_manager,
"game_integration_store": game_integration_store,
"game_event_bus": game_event_bus,
"lol_poll_manager": lol_poll_manager,
"mqtt_store": mqtt_store,
"mqtt_manager": mqtt_manager,
"http_endpoint_store": http_endpoint_store,
+58 -26
View File
@@ -57,6 +57,7 @@ from ledgrab.api.schemas.activity_log import (
)
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
@@ -66,6 +67,12 @@ router = APIRouter(prefix="/api/v1/activity-log", tags=["Activity Log"])
_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",
@@ -85,6 +92,11 @@ _CSV_COLUMNS = [
# 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.
@@ -97,6 +109,23 @@ def _csv_safe(value: str) -> str:
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,
@@ -127,7 +156,7 @@ def _build_filters(
@router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries")
def list_activity_log(
auth: AuthRequired, # noqa: ARG001
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Filters ────────────────────────────────────────────────────────────
categories: Annotated[
@@ -145,15 +174,15 @@ def list_activity_log(
] = None,
actor: Annotated[
str | None,
Query(description="Filter by actor label (exact match)"),
Query(max_length=_MAX_ID_FILTER, description="Filter by actor label (exact match)"),
] = None,
entity_type: Annotated[
str | None,
Query(description="Filter by entity type (exact match)"),
Query(max_length=_MAX_ID_FILTER, description="Filter by entity type (exact match)"),
] = None,
entity_id: Annotated[
str | None,
Query(description="Filter by entity id (exact match)"),
Query(max_length=_MAX_ID_FILTER, description="Filter by entity id (exact match)"),
] = None,
since: Annotated[
datetime | None,
@@ -165,7 +194,10 @@ def list_activity_log(
] = None,
q: Annotated[
str | None,
Query(description="Free-text search in the message field (substring)"),
Query(
max_length=_MAX_TEXT_FILTER,
description="Free-text search in the message field (substring)",
),
] = None,
# ── Pagination ─────────────────────────────────────────────────────────
before_seq: Annotated[
@@ -204,27 +236,21 @@ def list_activity_log(
# by slicing [1:], which is the actual page content for the client.
# When we got <= limit rows, this is the last page and all rows are included.
effective_limit = min(limit, _MAX_LIMIT)
entries_plus = repo.query(filters, before_seq=before_seq, limit=effective_limit + 1)
has_more = len(entries_plus) > effective_limit
if has_more:
# Drop the oldest probe row; keep the newest `limit` entries.
entries = entries_plus[1:]
else:
entries = entries_plus
# 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)
# Compute next_before_seq: the seq of the oldest entry on this page.
# query() returns entries ascending (entries[0] is oldest); its seq is the
# cursor for the next page. The next request passes before_seq=X to get
# entries with seq < X, i.e. entries older than the oldest entry on this page.
# get_seq_for_id() does a cheap indexed point-lookup.
next_before_seq: int | None = None
if has_more and entries:
next_before_seq = repo.get_seq_for_id(entries[0].id)
# 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=[entry_to_dict(e) for e in entries], # type: ignore[arg-type]
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,
@@ -259,9 +285,15 @@ def _export_csv_generator(
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:
cell = str(d.get(col, "") or "")
# 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)
@@ -310,12 +342,12 @@ def export_activity_log(
# ── Same filters as list ───────────────────────────────────────────────
categories: Annotated[list[str] | None, Query()] = None,
severities: Annotated[list[str] | None, Query()] = None,
actor: Annotated[str | None, Query()] = None,
entity_type: Annotated[str | None, Query()] = None,
entity_id: Annotated[str | None, Query()] = None,
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()] = None,
q: Annotated[str | None, Query(max_length=_MAX_TEXT_FILTER)] = None,
) -> StreamingResponse:
"""Stream all matching entries as CSV or JSON.
@@ -12,28 +12,35 @@ from ledgrab.api.dependencies import (
get_scene_preset_store,
)
from ledgrab.api.schemas.automations import (
ActionSchema,
AutomationCreate,
AutomationListResponse,
AutomationResponse,
AutomationTriggerResponse,
AutomationUpdate,
RuleSchema,
)
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.storage.automation import (
Action,
ApplicationRule,
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
ManualTriggerRule,
MQTTRule,
Rule,
SolarRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
WebhookAction,
WebhookRule,
)
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger
from ledgrab.utils.safe_source import validate_polling_url
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -55,6 +62,20 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
days_of_week=s.days_of_week or [],
timezone=s.timezone or "",
),
# SolarRule.from_dict validates events, clamps offsets/coords, and
# filters weekdays — route the raw schema values through it.
"solar": lambda: SolarRule.from_dict(
{
"start_event": s.start_event,
"start_offset_minutes": s.start_offset_minutes,
"end_event": s.end_event,
"end_offset_minutes": s.end_offset_minutes,
"latitude": s.latitude,
"longitude": s.longitude,
"days_of_week": s.days_of_week or [],
"timezone": s.timezone or "",
}
),
"system_idle": lambda: SystemIdleRule(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
when_idle=s.when_idle if s.when_idle is not None else True,
@@ -72,6 +93,7 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
token=s.token or secrets.token_hex(16),
),
"startup": lambda: StartupRule(),
"manual_trigger": lambda: ManualTriggerRule(),
"home_assistant": lambda: HomeAssistantRule(
ha_source_id=s.ha_source_id or "",
entity_id=s.entity_id or "",
@@ -95,6 +117,43 @@ def _rule_to_schema(r: Rule) -> RuleSchema:
return RuleSchema(**d)
def _action_from_schema(s: ActionSchema) -> Action:
"""Build a domain Action from its request schema, validating the webhook URL.
The SSRF gate runs here (save time) AND again at fire time, closing the
DNS-rebinding window. A bad/blocked URL rejects the whole save with 400.
"""
if s.action_type != "webhook":
raise ValueError(f"Unknown action type: {s.action_type}")
url = (s.webhook_url or "").strip()
if not url:
raise ValueError("webhook action requires a webhook_url")
method = (s.method or "POST").upper()
if method not in ("POST", "PUT", "GET"):
raise ValueError(f"Invalid webhook method: {method}. Must be POST, PUT or GET.")
fire_on = s.fire_on or "activate"
if fire_on not in ("activate", "deactivate", "both"):
raise ValueError(f"Invalid fire_on: {fire_on}. Must be activate, deactivate or both.")
# content_type is emitted verbatim as the outbound Content-Type header — reject
# control chars (CR/LF) so it can't be used to inject additional HTTP headers.
content_type = (s.content_type or "application/json").strip()
if len(content_type) > 128 or any(ord(c) < 0x20 or ord(c) > 0x7E for c in content_type):
raise ValueError("Invalid content_type: control or non-ASCII characters are not allowed.")
# Raises HTTPException(400) on a blocked/loopback/metadata target.
validate_polling_url(url)
return WebhookAction(
webhook_url=url,
method=method,
body_template=s.body_template or "",
content_type=content_type,
fire_on=fire_on,
)
def _action_to_schema(a: Action) -> ActionSchema:
return ActionSchema(**a.to_dict())
def _automation_to_response(
automation, engine: AutomationEngine, request: Request = None
) -> AutomationResponse:
@@ -130,6 +189,7 @@ def _automation_to_response(
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
tags=automation.tags,
actions=[_action_to_schema(a) for a in getattr(automation, "actions", [])],
icon=getattr(automation, "icon", "") or "",
icon_color=getattr(automation, "icon_color", "") or "",
created_at=automation.created_at,
@@ -186,6 +246,7 @@ async def create_automation(
try:
rules = [_rule_from_schema(r) for r in data.rules]
actions = [_action_from_schema(a) for a in data.actions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -201,6 +262,7 @@ async def create_automation(
deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags,
actions=actions,
icon=data.icon,
icon_color=data.icon_color,
)
@@ -283,6 +345,13 @@ async def update_automation(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
actions = None
if data.actions is not None:
try:
actions = [_action_from_schema(a) for a in data.actions]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
try:
# If disabling, deactivate first
if data.enabled is False:
@@ -297,6 +366,7 @@ async def update_automation(
rules=rules,
deactivation_mode=data.deactivation_mode,
tags=data.tags,
actions=actions,
icon=data.icon,
icon_color=data.icon_color,
)
@@ -394,3 +464,37 @@ async def disable_automation(
raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine, request)
# ===== Manual trigger =====
@router.post(
"/api/v1/automations/{automation_id}/trigger",
response_model=AutomationTriggerResponse,
tags=["Automations"],
)
async def trigger_automation(
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Manually fire an automation.
Evaluates the automation's rules with its manual trigger satisfied — so it
"still checks all of the rules" under the automation's ``rule_logic`` — and,
if it should activate, applies its scene once. Independent of the ``enabled``
flag (that gates only the background evaluation loop).
"""
try:
automation = store.get_automation(automation_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
try:
status, errors = await engine.fire_manual_trigger(automation)
except Exception as e: # noqa: BLE001 — surface a structured error, never a bare 500
logger.error("Manual trigger failed for automation %s: %s", automation_id, e)
return AutomationTriggerResponse(status="error", errors=[str(e)])
return AutomationTriggerResponse(status=status, errors=errors)
+11
View File
@@ -96,13 +96,16 @@ def _device_to_response(device) -> DeviceResponse:
espnow_channel=device.espnow_channel,
hue_paired=bool(device.hue_username and device.hue_client_key),
hue_entertainment_group_id=device.hue_entertainment_group_id,
hue_gradient_mode=device.hue_gradient_mode,
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
wiz_min_interval_ms=device.wiz_min_interval_ms,
lifx_min_interval_ms=device.lifx_min_interval_ms,
lifx_per_zone=device.lifx_per_zone,
govee_min_interval_ms=device.govee_min_interval_ms,
opc_channel=device.opc_channel,
nanoleaf_paired=bool(device.nanoleaf_token),
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
nanoleaf_per_panel=device.nanoleaf_per_panel,
spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type,
@@ -261,6 +264,9 @@ async def create_device(
hue_username=device_data.hue_username or "",
hue_client_key=device_data.hue_client_key or "",
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
hue_gradient_mode=(
device_data.hue_gradient_mode if device_data.hue_gradient_mode is not None else True
),
yeelight_min_interval_ms=(
device_data.yeelight_min_interval_ms
if device_data.yeelight_min_interval_ms is not None
@@ -276,6 +282,7 @@ async def create_device(
if device_data.lifx_min_interval_ms is not None
else 50
),
lifx_per_zone=bool(device_data.lifx_per_zone),
govee_min_interval_ms=(
device_data.govee_min_interval_ms
if device_data.govee_min_interval_ms is not None
@@ -288,6 +295,7 @@ async def create_device(
if device_data.nanoleaf_min_interval_ms is not None
else 100
),
nanoleaf_per_panel=bool(device_data.nanoleaf_per_panel),
spi_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink",
@@ -631,13 +639,16 @@ async def update_device(
hue_username=update_data.hue_username,
hue_client_key=update_data.hue_client_key,
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
hue_gradient_mode=update_data.hue_gradient_mode,
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
lifx_per_zone=update_data.lifx_per_zone,
govee_min_interval_ms=update_data.govee_min_interval_ms,
opc_channel=update_data.opc_channel,
nanoleaf_token=update_data.nanoleaf_token,
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
nanoleaf_per_panel=update_data.nanoleaf_per_panel,
spi_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type,
@@ -17,6 +17,7 @@ from ledgrab.api.dependencies import (
get_database,
get_game_integration_store,
get_game_event_bus,
get_lol_poll_manager,
)
from ledgrab.api.schemas.game_integration import (
AdapterInfoResponse,
@@ -37,9 +38,16 @@ from ledgrab.api.schemas.game_integration import (
)
from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.events import GameEvent
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
from ledgrab.core.game_integration.runtime_state import (
cleanup_state as _cleanup_state,
get_prev_state as _get_prev_state,
get_stats as _get_stats,
record_events as _record_events,
set_prev_state as _set_prev_state,
)
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.game_integration import EventMapping
from ledgrab.storage.game_integration import _SECRET_CONFIG_KEYS, EventMapping
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.utils import get_logger
@@ -47,15 +55,10 @@ logger = get_logger(__name__)
router = APIRouter()
# ── Per-integration runtime state (in-memory, not persisted) ──────────────
_integration_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]] = {}
# 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.
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
@@ -150,59 +153,47 @@ def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
return fields
def _get_prev_state(integration_id: str) -> dict[str, Any]:
"""Get or create the prev_state dict for an integration."""
with _integration_state_lock:
if integration_id not in _prev_states:
_prev_states[integration_id] = {}
return _prev_states[integration_id]
def _set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
"""Update the prev_state dict for an integration."""
with _integration_state_lock:
_prev_states[integration_id] = state
def _record_events(integration_id: str, events: list[GameEvent]) -> None:
"""Record event stats for an integration."""
with _integration_state_lock:
if integration_id not in _integration_stats:
_integration_stats[integration_id] = {
"event_count": 0,
"event_counts_by_type": {},
"last_event_time": None,
}
stats = _integration_stats[integration_id]
for event in events:
stats["event_count"] += 1
stats["event_counts_by_type"][event.event_type] = (
stats["event_counts_by_type"].get(event.event_type, 0) + 1
)
stats["last_event_time"] = event.timestamp
def _get_stats(integration_id: str) -> dict[str, Any]:
"""Get runtime stats for an integration."""
with _integration_state_lock:
return _integration_stats.get(
integration_id,
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
)
def _cleanup_state(integration_id: str) -> None:
"""Remove runtime state for a deleted integration."""
with _integration_state_lock:
_prev_states.pop(integration_id, None)
_integration_stats.pop(integration_id, None)
# ── Helper: convert config to response ────────────────────────────────────
def _redact_secrets(adapter_config: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of *adapter_config* with secret values masked.
The adapter ``auth_token`` is a live shared secret (it authenticates the
ingest endpoint). It is encrypted at rest, but the response builder echoes
the in-memory *decrypted* config, so without masking any API caller
(loopback-anonymous by default) could read the cleartext token. We never
return the secret over the API — the edit form submits a blank value to
keep the existing secret (see ``_merge_preserved_secrets``).
"""
cfg = dict(adapter_config)
for key in _SECRET_CONFIG_KEYS:
if cfg.get(key):
cfg[key] = "" # mask — never echo the secret to the client
return cfg
def _merge_preserved_secrets(
incoming: dict[str, Any] | None, existing: Any
) -> dict[str, Any] | None:
"""Preserve a stored secret when an update submits a blank/absent one.
Because the API masks secrets in responses, the edit form re-submits a
blank value for an unchanged secret. Without this merge that blank would
overwrite (and destroy) the stored token. A non-empty incoming value is a
deliberate change and is kept as-is.
"""
if incoming is None:
return None
merged = dict(incoming)
for key in _SECRET_CONFIG_KEYS:
if not merged.get(key) and existing.adapter_config.get(key):
merged[key] = existing.adapter_config[key]
return merged
def _config_to_response(config: Any) -> GameIntegrationResponse:
"""Convert a GameIntegrationConfig to its API response."""
"""Convert a GameIntegrationConfig to its API response (secrets redacted)."""
from ledgrab.api.schemas.game_integration import EventMappingSchema
return GameIntegrationResponse(
@@ -210,7 +201,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
name=config.name,
adapter_type=config.adapter_type,
enabled=config.enabled,
adapter_config=config.adapter_config,
adapter_config=_redact_secrets(config.adapter_config),
event_mappings=[
EventMappingSchema(
event_type=m.event_type,
@@ -302,6 +293,7 @@ async def create_integration(
data: GameIntegrationCreate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Create a new game integration config."""
try:
@@ -330,6 +322,8 @@ async def create_integration(
)
fire_entity_event("game_integration", "created", config.id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
return _config_to_response(config)
except EntityNotFoundError as e:
@@ -369,6 +363,7 @@ async def update_integration(
data: GameIntegrationUpdate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Update a game integration config."""
try:
@@ -386,12 +381,20 @@ async def update_integration(
for m in data.event_mappings
]
# Preserve a stored secret when the update submits a blank token
# (the API masks secrets, so the edit form re-sends a blank value
# for an unchanged secret — see _merge_preserved_secrets).
adapter_config = data.adapter_config
if adapter_config is not None:
existing = store.get_integration(integration_id)
adapter_config = _merge_preserved_secrets(adapter_config, existing)
config = store.update_integration(
integration_id=integration_id,
name=data.name,
adapter_type=data.adapter_type,
enabled=data.enabled,
adapter_config=data.adapter_config,
adapter_config=adapter_config,
event_mappings=mappings,
description=data.description,
tags=data.tags,
@@ -400,6 +403,8 @@ async def update_integration(
)
fire_entity_event("game_integration", "updated", integration_id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
return _config_to_response(config)
except EntityNotFoundError as e:
@@ -420,11 +425,14 @@ async def delete_integration(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Delete a game integration config."""
try:
store.delete_integration(integration_id)
_cleanup_state(integration_id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
fire_entity_event("game_integration", "deleted", integration_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -479,9 +487,17 @@ async def ingest_event(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Adapter-level auth check
# Adapter-level auth check. Treat ANY exception from validate_auth as an
# auth failure (rate-limited + 403), never a 500 — a malformed/attacker-
# controlled token must not crash the handler nor bypass the brute-force
# lockout counter.
headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
try:
authed = adapter_cls.validate_auth(headers, payload.data, config.adapter_config)
except Exception as exc:
logger.warning("validate_auth raised for %s: %s", integration_id, exc)
authed = False
if not authed:
_record_auth_failure(client_ip)
raise HTTPException(status_code=403, detail="Adapter authentication failed")
+13
View File
@@ -42,6 +42,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
password_set=bool(source.password),
client_id=source.client_id,
base_topic=source.base_topic,
publish_ha_discovery=getattr(source, "publish_ha_discovery", False),
discovery_prefix=getattr(source, "discovery_prefix", "homeassistant"),
connected=runtime.is_connected if runtime else False,
description=source.description,
tags=source.tags,
@@ -90,6 +92,8 @@ async def create_mqtt_source(
password=data.password,
client_id=data.client_id,
base_topic=data.base_topic,
publish_ha_discovery=data.publish_ha_discovery,
discovery_prefix=data.discovery_prefix,
description=data.description,
tags=data.tags,
icon=data.icon,
@@ -97,6 +101,8 @@ async def create_mqtt_source(
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Publish HA discovery if the new source opted in.
await manager.sync_discovery(source.id)
fire_entity_event("mqtt_source", "created", source.id)
return _to_response(source, manager)
@@ -141,6 +147,8 @@ async def update_mqtt_source(
password=data.password,
client_id=data.client_id,
base_topic=data.base_topic,
publish_ha_discovery=data.publish_ha_discovery,
discovery_prefix=data.discovery_prefix,
description=data.description,
tags=data.tags,
icon=data.icon,
@@ -151,6 +159,8 @@ async def update_mqtt_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await manager.update_source(source_id)
# Reconcile HA discovery (publish if enabled, clear if turned off).
await manager.sync_discovery(source_id)
fire_entity_event("mqtt_source", "updated", source.id)
return _to_response(source, manager)
@@ -162,6 +172,9 @@ async def delete_mqtt_source(
store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager),
):
# Clear any HA discovery configs (needs the source still present to build
# the exact retained topics) before deleting the row.
await manager.disable_discovery(source_id)
try:
store.delete_source(source_id)
except EntityNotFoundError:
+73 -1
View File
@@ -36,7 +36,29 @@ class RuleSchema(BaseModel):
)
timezone: str | None = Field(
None,
description="IANA timezone for time_of_day rule (e.g. 'Europe/Berlin'). Empty = server local.",
description=(
"IANA timezone for time_of_day / solar rules (e.g. 'Europe/Berlin'). "
"Empty = server local."
),
)
# Solar rule fields (days_of_week / timezone above are shared with time_of_day)
start_event: str | None = Field(
None, description="'sunrise' or 'sunset' — window start anchor (for solar rule)"
)
start_offset_minutes: int | None = Field(
None, description="Minutes added to the start event, ±1439 (for solar rule)"
)
end_event: str | None = Field(
None, description="'sunrise' or 'sunset' — window end anchor (for solar rule)"
)
end_offset_minutes: int | None = Field(
None, description="Minutes added to the end event, ±1439 (for solar rule)"
)
latitude: float | None = Field(
None, description="Latitude for solar timing, -90..90 (for solar rule)"
)
longitude: float | None = Field(
None, description="Longitude for solar timing, -180..180 (for solar rule)"
)
# System idle rule fields
idle_minutes: int | None = Field(
@@ -86,6 +108,37 @@ class RuleSchema(BaseModel):
ConditionSchema = RuleSchema
class ActionSchema(BaseModel):
"""A single outbound action fired alongside scene activation/deactivation."""
action_type: str = Field(
max_length=32, description="Action type discriminator (e.g. 'webhook')"
)
# Webhook action fields
webhook_url: str | None = Field(
None, max_length=2048, description="Target URL for the webhook action"
)
method: str | None = Field(
None, max_length=8, description="'POST', 'PUT', or 'GET' (for webhook action)"
)
body_template: str | None = Field(
None,
max_length=8192,
description=(
"Request body template (for webhook action). Tokens: {{automation_name}}, "
"{{automation_id}}, {{event}}, {{timestamp}}."
),
)
content_type: str | None = Field(
None,
max_length=128,
description="Content-Type header for the webhook body (default application/json)",
)
fire_on: str | None = Field(
None, max_length=16, description="'activate', 'deactivate', or 'both' (for webhook action)"
)
class AutomationCreate(BaseModel):
"""Request to create an automation."""
@@ -101,6 +154,9 @@ class AutomationCreate(BaseModel):
None, description="Scene preset for fallback deactivation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
actions: List[ActionSchema] = Field(
default_factory=list, description="Outbound actions (e.g. webhooks)"
)
icon: str | None = Field(
None,
max_length=64,
@@ -126,6 +182,7 @@ class AutomationUpdate(BaseModel):
None, description="Scene preset for fallback deactivation"
)
tags: List[str] | None = None
actions: List[ActionSchema] | None = Field(None, description="Outbound actions (e.g. webhooks)")
icon: str | None = Field(
None,
max_length=64,
@@ -150,6 +207,9 @@ class AutomationResponse(BaseModel):
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
actions: List[ActionSchema] = Field(
default_factory=list, description="Outbound actions (e.g. webhooks)"
)
webhook_url: str | None = Field(
None, description="Webhook URL for the first webhook rule (if any)"
)
@@ -179,3 +239,15 @@ class AutomationListResponse(BaseModel):
automations: List[AutomationResponse] = Field(description="List of automations")
count: int = Field(description="Number of automations")
class AutomationTriggerResponse(BaseModel):
"""Result of manually triggering an automation."""
status: str = Field(
description="'triggered' (scene applied / nothing to apply), 'partial' "
"(applied with errors), 'skipped' (rules not satisfied), or 'error'."
)
errors: List[str] = Field(
default_factory=list, description="Per-target error messages, if any."
)
@@ -145,6 +145,10 @@ class EffectCSSResponse(_CSSResponseBase):
scale: Any = Field(description="Spatial scale")
mirror: bool = Field(description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool = Field(False, description="Modulate output by live audio loudness")
reactive_audio_source_id: str = Field("", description="AudioSource id driving reactivity")
reactive_mode: str = Field("brightness", description="brightness | saturation | both")
reactive_intensity: Any = Field(None, description="Reactive modulation strength (0-1)")
class CompositeCSSResponse(_CSSResponseBase):
@@ -332,6 +336,12 @@ class EffectCSSCreate(_CSSCreateBase):
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
reactive_mode: Literal["brightness", "saturation", "both"] | None = Field(
None, description="brightness | saturation | both"
)
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
class CompositeCSSCreate(_CSSCreateBase):
@@ -532,6 +542,12 @@ class EffectCSSUpdate(_CSSUpdateBase):
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
reactive_mode: Literal["brightness", "saturation", "both"] | None = Field(
None, description="brightness | saturation | both"
)
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
class CompositeCSSUpdate(_CSSUpdateBase):
+40
View File
@@ -59,6 +59,10 @@ class DeviceCreate(BaseModel):
hue_entertainment_group_id: str | None = Field(
None, description="Hue entertainment group/zone ID"
)
hue_gradient_mode: bool | None = Field(
None,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
# Yeelight fields
yeelight_min_interval_ms: int | None = Field(
None,
@@ -80,6 +84,10 @@ class DeviceCreate(BaseModel):
le=10000,
description="LIFX client-side rate limit between commands in ms (default 50)",
)
lifx_per_zone: bool | None = Field(
None,
description="Stream individual zones/tiles (multizone Z/Beam, Tile/Canvas) vs single colour",
)
# Govee fields
govee_min_interval_ms: int | None = Field(
None,
@@ -106,6 +114,9 @@ class DeviceCreate(BaseModel):
le=10000,
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
)
nanoleaf_per_panel: bool | None = Field(
None, description="Stream each panel individually via extControl UDP (vs single colour)"
)
# SPI Direct fields
spi_speed_hz: int | None = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
@@ -195,6 +206,10 @@ class DeviceUpdate(BaseModel):
hue_username: str | None = Field(None, description="Hue bridge username")
hue_client_key: str | None = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
hue_gradient_mode: bool | None = Field(
None,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
yeelight_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
)
@@ -204,6 +219,9 @@ class DeviceUpdate(BaseModel):
lifx_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
)
lifx_per_zone: bool | None = Field(
None, description="Stream individual zones/tiles (multizone/matrix) vs single colour"
)
govee_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
)
@@ -212,6 +230,9 @@ class DeviceUpdate(BaseModel):
nanoleaf_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
)
nanoleaf_per_panel: bool | None = Field(
None, description="Stream each panel individually via extControl UDP"
)
spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: str | None = Field(None, description="LED chipset type")
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
@@ -356,6 +377,14 @@ class Calibration(BaseModel):
roi_height: float = Field(
default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)"
)
linear_blend: bool = Field(
default=False,
description="Blend border pixels in linear light instead of sRGB (perceptually correct)",
)
dither: bool = Field(
default=False,
description="Spatio-temporally dither the final 8-bit output to reduce gradient banding",
)
class CalibrationTestModeRequest(BaseModel):
@@ -428,11 +457,19 @@ class DeviceResponse(BaseModel):
),
)
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
hue_gradient_mode: bool = Field(
default=True,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
yeelight_min_interval_ms: int = Field(
default=500, description="Yeelight client-side rate limit in ms"
)
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
lifx_per_zone: bool = Field(
default=False,
description="Stream individual zones/tiles (multizone/matrix) vs single colour",
)
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
nanoleaf_paired: bool = Field(
@@ -446,6 +483,9 @@ class DeviceResponse(BaseModel):
nanoleaf_min_interval_ms: int = Field(
default=100, description="Nanoleaf client-side rate limit in ms"
)
nanoleaf_per_panel: bool = Field(
default=False, description="Stream each panel individually via extControl UDP"
)
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
+20
View File
@@ -16,6 +16,15 @@ class MQTTSourceCreate(BaseModel):
password: str = Field(default="", description="Broker password (optional)")
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
publish_ha_discovery: bool = Field(
default=False, description="Publish Home Assistant MQTT auto-discovery configs"
)
discovery_prefix: str = Field(
default="homeassistant",
max_length=64,
pattern=r"^[A-Za-z0-9_\-/]+$",
description="HA MQTT discovery prefix (default 'homeassistant')",
)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: str | None = Field(
@@ -40,6 +49,15 @@ class MQTTSourceUpdate(BaseModel):
password: str | None = Field(None, description="Broker password")
client_id: str | None = Field(None, description="MQTT client ID")
base_topic: str | None = Field(None, description="Base topic prefix")
publish_ha_discovery: bool | None = Field(
None, description="Publish Home Assistant MQTT auto-discovery configs"
)
discovery_prefix: str | None = Field(
None,
max_length=64,
pattern=r"^[A-Za-z0-9_\-/]+$",
description="HA MQTT discovery prefix",
)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
@@ -65,6 +83,8 @@ class MQTTSourceResponse(BaseModel):
password_set: bool = Field(default=False, description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
publish_ha_discovery: bool = Field(default=False, description="HA MQTT discovery enabled")
discovery_prefix: str = Field(default="homeassistant", description="HA MQTT discovery prefix")
connected: bool = Field(default=False, description="Whether the broker connection is active")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -1,18 +1,31 @@
"""Actor context variable for the activity log.
``current_actor`` is set by ``api/auth.py:verify_api_key`` on every request so
that ``ActivityRecorder.record(...)`` can resolve the actor without requiring
every call site to pass it explicitly.
``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 guaranteed by ASGI's coroutine context: each request
runs in its own coroutine with its own copy of the context inherited from the
server's main task. The auth layer resets it on every request before the route
handler runs, so stale labels from a previous request cannot bleed into a new
one.
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
@@ -45,8 +45,14 @@ logger = get_logger(__name__)
def _new_id() -> str:
"""Generate a compact activity-log entry id: ``al_<8-hex-chars>``."""
return "al_" + uuid.uuid4().hex[:8]
"""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:
@@ -74,9 +74,17 @@ def sanitize_display(value: str | None, *, maxlen: int = 120) -> str:
# 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.
# 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:
# Reserve one character for the ellipsis so total length == maxlen.
cleaned = cleaned[: maxlen - 1] + ""
if maxlen == 1:
# No room for content + ellipsis; emit the ellipsis alone.
cleaned = ""
else:
# Reserve one character for the ellipsis so total length == maxlen.
cleaned = cleaned[: maxlen - 1] + ""
return cleaned
@@ -13,8 +13,10 @@ from ledgrab.storage.automation import (
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
ManualTriggerRule,
MQTTRule,
Rule,
SolarRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
@@ -23,6 +25,7 @@ from ledgrab.storage.automation import (
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset import ScenePreset
from ledgrab.utils import get_logger
from ledgrab.utils.solar import compute_solar_times, utc_offset_hours_for
logger = get_logger(__name__)
@@ -141,6 +144,11 @@ class AutomationEngine:
self._last_deactivated: Dict[str, datetime] = {}
# webhook_token → bool (volatile state set by webhook calls)
self._webhook_states: Dict[str, bool] = {}
# True only while a single automation is being manually fired
# (fire_manual_trigger). The background tick never sets it, so a
# ManualTriggerRule reads False during normal evaluation and a
# manual-trigger automation never activates on its own.
self._manual_fire_active: bool = False
# HA source IDs currently acquired by the engine
self._ha_acquired: Set[str] = set()
# MQTT source IDs currently acquired by the engine
@@ -369,6 +377,32 @@ class AutomationEngine:
display_state,
)
@staticmethod
def _detection_needs(rules) -> tuple[bool, bool, bool, bool, bool]:
"""Which platform-detection probes a set of rules requires.
Returns ``(needs_running, needs_topmost, needs_fullscreen, needs_idle,
needs_display_state)``. Shared by the background evaluation tick and the
one-shot manual-trigger path so both request the same detection set.
"""
match_types_used: set = set()
needs_idle = False
needs_display_state = False
for r in rules:
if isinstance(r, ApplicationRule):
match_types_used.add(r.match_type)
elif isinstance(r, SystemIdleRule):
needs_idle = True
elif isinstance(r, DisplayStateRule):
needs_display_state = True
return (
"running" in match_types_used,
bool(match_types_used & {"topmost", "topmost_fullscreen"}),
"fullscreen" in match_types_used,
needs_idle,
needs_display_state,
)
async def _evaluate_all_locked(self) -> None:
automations = self._store.get_all_automations()
if not automations:
@@ -377,23 +411,15 @@ class AutomationEngine:
await self._deactivate_automation(aid)
return
# Determine which detection methods are actually needed
match_types_used: set = set()
needs_idle = False
needs_display_state = False
for a in automations:
if a.enabled:
for r in a.rules:
if isinstance(r, ApplicationRule):
match_types_used.add(r.match_type)
elif isinstance(r, SystemIdleRule):
needs_idle = True
elif isinstance(r, DisplayStateRule):
needs_display_state = True
needs_running = "running" in match_types_used
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
needs_fullscreen = "fullscreen" in match_types_used
# Determine which detection methods are actually needed (across the
# rules of every *enabled* automation — disabled ones are skipped below).
(
needs_running,
needs_topmost,
needs_fullscreen,
needs_idle,
needs_display_state,
) = self._detection_needs([r for a in automations if a.enabled for r in a.rules])
# Single executor call for all platform detection
(
@@ -526,6 +552,9 @@ class AutomationEngine:
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_time_of_day(rule)
def _handle_solar(self, rule: SolarRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_solar(rule)
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_idle(rule, ctx.idle_seconds)
@@ -538,12 +567,40 @@ class AutomationEngine:
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
return self._webhook_states.get(rule.token, False)
def _handle_manual(self, rule: ManualTriggerRule, ctx: _RuleEvalContext) -> bool:
# True only while fire_manual_trigger is evaluating this one automation
# under the eval lock; always False during the background tick.
return self._manual_fire_active
def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_home_assistant(rule)
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_http_poll(rule)
@staticmethod
def _weekday_window_active(
current: int, start: int, end: int, weekday: int, days: list
) -> bool:
"""Is ``current`` (minutes-of-day) inside the [start, end] window?
Handles the overnight wrap (start > end): the after-midnight tail
belongs to the window's START day, so it's matched against the
previous weekday. ``days`` empty = every day of the week.
"""
if start <= end:
if not (start <= current <= end):
return False
return not days or weekday in days
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its
# START day, so the after-midnight tail is matched against yesterday.
if current >= start: # evening portion — today's window
return not days or weekday in days
if current <= end: # early-morning portion — yesterday's window
return not days or ((weekday - 1) % 7) in days
return False
@staticmethod
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
now = _now_in_tz(rule.timezone)
@@ -552,20 +609,34 @@ class AutomationEngine:
parts_e = rule.end_time.split(":")
start = int(parts_s[0]) * 60 + int(parts_s[1])
end = int(parts_e[0]) * 60 + int(parts_e[1])
days = rule.days_of_week
return AutomationEngine._weekday_window_active(
current, start, end, now.weekday(), rule.days_of_week
)
if start <= end:
if not (start <= current <= end):
return False
return not days or now.weekday() in days
@staticmethod
def _evaluate_solar(rule: SolarRule) -> bool:
# One ``now`` drives every read: day-of-year, the UTC offset for the
# solar math, the current-minute compare, and the weekday.
now = _now_in_tz(rule.timezone)
day_of_year = now.timetuple().tm_yday
utc_offset = utc_offset_hours_for(rule.timezone, now)
sunrise_h, sunset_h = compute_solar_times(
rule.latitude, rule.longitude, day_of_year, utc_offset
)
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its
# START day, so the after-midnight tail is matched against yesterday.
if current >= start: # evening portion — today's window
return not days or now.weekday() in days
if current <= end: # early-morning portion — yesterday's window
return not days or ((now.weekday() - 1) % 7) in days
return False
def _event_minutes(event: str) -> int:
hour = sunset_h if event == "sunset" else sunrise_h
return int(round(hour * 60))
# compute_solar_times clamps sunrise < sunset, so the only way to wrap
# past midnight is via the offsets — which ``_weekday_window_active``
# handles the same way it does an overnight time-of-day window.
start = (_event_minutes(rule.start_event) + rule.start_offset_minutes) % 1440
end = (_event_minutes(rule.end_event) + rule.end_offset_minutes) % 1440
current = now.hour * 60 + now.minute
return AutomationEngine._weekday_window_active(
current, start, end, now.weekday(), rule.days_of_week
)
@staticmethod
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
@@ -675,6 +746,62 @@ class AutomationEngine:
# Default: "running"
return any(app in running_procs for app in apps_lower)
def _audit_activation(self, automation: Automation) -> None:
"""Best-effort audit record for any successful automation activation.
Shared by both the normal scene path and the no-scene branch so an
activation is recorded uniformly regardless of whether a scene was
applied (mirrors the uniform recording on the deactivation side).
"""
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_name = sanitize_display(automation.name) if automation.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.activated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' activated",
)
except Exception:
pass
def _audit_manual_trigger(self, automation: Automation) -> None:
"""Best-effort audit record for a manual trigger.
Unlike :meth:`_audit_activation` this does NOT force ``actor='system'``
— the recorder resolves ``actor`` from the ``current_actor`` ContextVar
(set in ``verify_api_key``), so the run is attributed to the user who
pressed the button.
"""
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_name = sanitize_display(automation.name) if automation.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.triggered",
severity=ActivitySeverity.INFO,
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' manually triggered",
)
except Exception:
pass
async def _activate_automation(self, automation: Automation) -> None:
if not automation.scene_preset_id:
# No scene configured — just mark active (rules matched but nothing to do)
@@ -682,6 +809,11 @@ class AutomationEngine:
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "activated")
logger.info(f"Automation '{automation.name}' activated (no scene configured)")
# Record the activation too — a no-scene activation is still a
# successful activation and must appear in the audit log.
self._audit_activation(automation)
await self._fire_actions(automation, "activate")
await self._publish_mqtt_state(automation.id, True)
return
if not self._scene_preset_store or not self._target_store or not self._device_store:
@@ -726,28 +858,141 @@ class AutomationEngine:
else:
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
# Audit record — best-effort.
# 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 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",
)
if rec is None:
return
safe_name = sanitize_display(automation.name) if automation.name else automation.id
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.webhook_fired",
severity=ActivitySeverity.INFO if ok else ActivitySeverity.WARNING,
actor="system",
entity_type="automation",
entity_id=automation.id,
entity_name=safe_name,
message=(
f"Webhook for '{safe_name}' {'fired' if ok else 'failed'} on {event}"
+ ("" if ok else f" ({sanitize_display(err) if err else 'error'})")
),
)
except Exception:
pass
async def _apply_manual_scene(self, automation: Automation) -> tuple[str, list[str]]:
"""Apply the automation's scene once for a manual trigger.
Mirrors the scene-application core of :meth:`_activate_automation` but
does NOT enter the sticky ``_active_automations`` state or capture a
revert snapshot — a manual trigger is a one-shot apply, so the
background tick has nothing to reconcile away. Returns
``(status, errors)`` where ``status`` is ``"triggered"`` (applied, or no
scene configured), ``"partial"`` (applied with errors), or ``"error"``
(scene stores unavailable / preset missing).
"""
if not automation.scene_preset_id:
return ("triggered", [])
if not self._scene_preset_store or not self._target_store or not self._device_store:
logger.warning(
f"Automation '{automation.name}' triggered but scene stores not available"
)
return ("error", ["scene stores not available"])
try:
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
except ValueError:
logger.warning(
f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found"
)
return ("error", [f"scene preset {automation.scene_preset_id} not found"])
from ledgrab.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state(preset, self._target_store, self._manager)
if errors:
logger.warning(
f"Automation '{automation.name}' manually triggered with errors: {errors}"
)
else:
logger.info(
f"Automation '{automation.name}' manually triggered (scene '{preset.name}' applied)"
)
# apply_scene_state returns "activated"/"partial"; surface "triggered"
# for the happy path so the API status reads naturally.
return ("triggered" if status == "activated" else status, errors)
async def fire_manual_trigger(self, automation: Automation) -> tuple[str, list[str]]:
"""Manually fire an automation: evaluate its rules with the manual
trigger satisfied and, if it should activate, apply its scene once.
"Checks all of the rules": the automation's full rule set is evaluated
under its ``rule_logic`` with the ManualTriggerRule treated as True. The
``enabled`` flag is intentionally ignored — it gates only the background
tick; a manual trigger is an explicit user action. Returns
``(status, errors)``: ``"skipped"`` when the rules are not satisfied,
otherwise the result of :meth:`_apply_manual_scene`.
"""
async with self._eval_lock:
detection = await asyncio.to_thread(
self._detect_all_sync, *self._detection_needs(automation.rules)
)
# Force the manual term True for this one evaluation, then clear it
# before releasing the lock so the background tick never sees it.
self._manual_fire_active = True
try:
should_fire = (not automation.rules) or self._evaluate_rules(automation, *detection)
finally:
self._manual_fire_active = False
if not should_fire:
logger.info(
f"Automation '{automation.name}' manual trigger skipped (rules not satisfied)"
)
return ("skipped", [])
status, errors = await self._apply_manual_scene(automation)
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "triggered")
self._audit_manual_trigger(automation)
return (status, errors)
async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_automations.pop(automation_id, False)
if not was_active:
@@ -781,11 +1026,9 @@ class AutomationEngine:
rec = get_module_recorder()
if rec is not None:
_auto_name: str | None = None
try:
_auto_name = self._store.get_automation(automation_id).name
except Exception:
pass
# 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,
@@ -800,6 +1043,22 @@ class AutomationEngine:
except Exception:
pass
# Fire any outbound deactivate actions (best-effort). Skipped when the
# automation was since-deleted (no actions to read).
if automation is not None:
await self._fire_actions(automation, "deactivate")
await self._publish_mqtt_state(automation_id, False)
async def _publish_mqtt_state(self, automation_id: str, active: bool) -> None:
"""Best-effort publish of the automation's active state to HA discovery."""
mgr = self._mqtt_manager
if mgr is None or not hasattr(mgr, "publish_automation_state_all"):
return
try:
await mgr.publish_automation_state_all(automation_id, active)
except Exception: # noqa: BLE001 — never raise into the engine
pass
async def _deactivate_revert(self, automation_id: str) -> None:
"""Revert to pre-activation snapshot."""
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
@@ -904,10 +1163,12 @@ AutomationEngine._RULE_HANDLERS = {
StartupRule: AutomationEngine._handle_startup,
ApplicationRule: AutomationEngine._handle_application,
TimeOfDayRule: AutomationEngine._handle_time_of_day,
SolarRule: AutomationEngine._handle_solar,
SystemIdleRule: AutomationEngine._handle_system_idle,
DisplayStateRule: AutomationEngine._handle_display_state,
MQTTRule: AutomationEngine._handle_mqtt,
WebhookRule: AutomationEngine._handle_webhook,
ManualTriggerRule: AutomationEngine._handle_manual,
HomeAssistantRule: AutomationEngine._handle_home_assistant,
HTTPPollRule: AutomationEngine._handle_http_poll,
}
@@ -925,10 +1186,12 @@ def _assert_rule_handler_coverage() -> None:
StartupRule,
ApplicationRule,
TimeOfDayRule,
SolarRule,
SystemIdleRule,
DisplayStateRule,
MQTTRule,
WebhookRule,
ManualTriggerRule,
HomeAssistantRule,
HTTPPollRule,
}
@@ -0,0 +1,102 @@
"""Outbound webhook action firing for the automation engine.
When an automation activates or deactivates, any attached
:class:`~ledgrab.storage.automation.WebhookAction` performs a best-effort
outbound HTTP request (Discord / IFTTT / Zapier / Node-RED / Home Assistant
webhooks). Firing is fire-and-forget: a hung or failing endpoint is logged and
audited but never raises into the activation path.
Security: the target URL is SSRF-gated via :func:`validate_polling_url` (LAN
allowed so users can hit Node-RED / HA on their own network; loopback,
link-local / cloud-metadata, multicast and reserved ranges blocked) at **both**
save time (in the route) and fire time (here) — re-validating at fire time
closes the DNS-rebinding window. Redirects are not followed.
"""
from __future__ import annotations
from datetime import datetime, timezone
import httpx
from fastapi import HTTPException
from ledgrab.storage.automation import Automation, WebhookAction
from ledgrab.utils import get_logger
from ledgrab.utils.safe_source import validate_polling_url
logger = get_logger(__name__)
# A webhook must never stall the ~1 Hz evaluation loop.
_WEBHOOK_TIMEOUT_S = 5.0
def render_template(template: str, automation: Automation, event: str) -> str:
"""Substitute the supported ``{{token}}`` placeholders in *template*.
Tokens: ``{{automation_name}}``, ``{{automation_id}}``, ``{{event}}``
(``activate``/``deactivate``), ``{{timestamp}}`` (ISO-8601 UTC). Unknown
tokens are left untouched.
"""
replacements = {
"{{automation_name}}": automation.name,
"{{automation_id}}": automation.id,
"{{event}}": event,
"{{timestamp}}": datetime.now(timezone.utc).isoformat(),
}
out = template
for token, value in replacements.items():
out = out.replace(token, value)
return out
def should_fire(action: WebhookAction, event: str) -> bool:
"""Whether *action* fires for this transition (``activate``/``deactivate``)."""
return action.fire_on == event or action.fire_on == "both"
async def fire_webhook_action(
action: WebhookAction,
automation: Automation,
event: str,
) -> tuple[bool, str | None]:
"""Fire a single webhook action. Best-effort: never raises.
Returns ``(ok, error)`` where ``ok`` is True on a 2xx response and
``error`` is a short, secret-free reason on failure.
"""
url = action.webhook_url.strip()
if not url:
return False, "no URL configured"
# Re-validate at fire time (DNS-rebinding window). HTTPException carries a
# 4xx detail; surface a short reason rather than raising into the engine.
try:
validate_polling_url(url)
except HTTPException as exc:
logger.warning("Webhook for '%s' blocked by SSRF policy: %s", automation.name, exc.detail)
return False, "blocked by SSRF policy"
body = render_template(action.body_template, automation, event)
headers = {"Content-Type": action.content_type or "application/json"}
try:
async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT_S, follow_redirects=False) as client:
kwargs: dict = {"headers": headers}
# Only attach a body for write methods with content to send.
if action.method in ("POST", "PUT") and body:
kwargs["content"] = body.encode("utf-8")
response = await client.request(action.method, url, **kwargs)
except Exception as exc: # noqa: BLE001 — never propagate into activation
# Never log the rendered body or the exception repr (may carry the URL
# with embedded secrets) — the type name is enough to diagnose.
logger.warning("Webhook for '%s' failed: %s", automation.name, type(exc).__name__)
return False, f"request failed: {type(exc).__name__}"
ok = 200 <= response.status_code < 300
if not ok:
logger.warning("Webhook for '%s' returned HTTP %d", automation.name, response.status_code)
return False, f"HTTP {response.status_code}"
logger.info(
"Webhook for '%s' fired on %s (HTTP %d)", automation.name, event, response.status_code
)
return True, None
+42 -2
View File
@@ -120,6 +120,11 @@ class CalibrationConfig:
roi_y: float = 0.0
roi_width: float = 1.0
roi_height: float = 1.0
# Blend border pixels in linear light (perceptually correct averaging)
# instead of gamma-encoded sRGB. Off by default = unchanged behaviour.
linear_blend: bool = False
# Spatio-temporal dither the final 8-bit quantization to reduce banding.
dither: bool = False
@property
def has_roi(self) -> bool:
@@ -349,6 +354,8 @@ class PixelMapper:
"""
self.calibration = calibration
self.interpolation_mode = interpolation_mode
# Per-frame counter driving the temporal dither phase.
self._dither_frame = 0
# Validate calibration
self.calibration.validate()
@@ -430,7 +437,16 @@ class PixelMapper:
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
the shared kernel handles all allocations on first use.
"""
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, edge_name)
return average_edge_to_leds(
edge_pixels,
edge_name,
led_count,
self._edge_cache,
edge_name,
linear=self.calibration.linear_blend,
dither=self.calibration.dither,
frame_index=self._dither_frame,
)
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors.
@@ -449,6 +465,7 @@ class PixelMapper:
"""
led_array = self._led_buf
led_array[:] = 0
self._dither_frame += 1
# Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll)
for i, segment in enumerate(self.calibration.segments):
@@ -514,6 +531,7 @@ class AdvancedPixelMapper:
):
self.calibration = calibration
self.interpolation_mode = interpolation_mode
self._dither_frame = 0
calibration.validate()
if interpolation_mode == "average":
@@ -600,7 +618,16 @@ class AdvancedPixelMapper:
``cache_key`` is an integer (e.g. line index) so multiple per-line
edges can share the same ``self._edge_cache`` dict without colliding.
"""
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, cache_key)
return average_edge_to_leds(
edge_pixels,
edge_name,
led_count,
self._edge_cache,
cache_key,
linear=self.calibration.linear_blend,
dither=self.calibration.dither,
frame_index=self._dither_frame,
)
def _map_edge_fallback(
self,
@@ -622,6 +649,7 @@ class AdvancedPixelMapper:
"""
led_array = self._led_buf
led_array[:] = 0
self._dither_frame += 1
for i, line in enumerate(self.calibration.lines):
frame = frames.get(line.picture_source_id)
@@ -902,6 +930,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
offset=data.get("offset", 0),
skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0),
linear_blend=bool(data.get("linear_blend", False)),
dither=bool(data.get("dither", False)),
)
config.validate()
return config
@@ -931,6 +961,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
roi_y=data.get("roi_y", 0.0),
roi_width=data.get("roi_width", 1.0),
roi_height=data.get("roi_height", 1.0),
linear_blend=bool(data.get("linear_blend", False)),
dither=bool(data.get("dither", False)),
)
config.validate()
@@ -975,6 +1007,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["skip_leds_start"] = config.skip_leds_start
if config.skip_leds_end > 0:
result["skip_leds_end"] = config.skip_leds_end
if config.linear_blend:
result["linear_blend"] = True
if config.dither:
result["dither"] = True
return result
# Simple mode
@@ -1008,4 +1044,8 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["roi_y"] = config.roi_y
result["roi_width"] = config.roi_width
result["roi_height"] = config.roi_height
if config.linear_blend:
result["linear_blend"] = True
if config.dither:
result["dither"] = True
return result
@@ -233,8 +233,20 @@ class CalibrationSession:
self._last_activity = datetime.now(timezone.utc)
self._active = True
# Clear the device to black so the chase starts from a clean state
await manager.send_clear_pixels(device_id)
# Clear the device to black so the chase starts from a clean state.
# send_clear_pixels re-raises on a double send failure; a transient
# failure here must NOT strand the session with _active=True and no
# watchdog — log and continue so the idle-timeout watchdog still gets
# armed (mirrors the guarded clear in _teardown_locked).
try:
await manager.send_clear_pixels(device_id)
except Exception as exc:
logger.warning(
"CalibrationSession.start: failed to clear pixels on %s "
"before chase (continuing): %s",
device_id,
exc,
)
# Start idle-timeout watchdog
self._timeout_task = asyncio.ensure_future(self._idle_watchdog())
@@ -23,6 +23,9 @@ from typing import Any, Callable, Dict, Hashable, Tuple
import numpy as np
from ledgrab.utils.dither import ordered_dither_quantize
from ledgrab.utils.linear_light import linear_to_srgb_float, linear_to_srgb_uint8, srgb_to_linear
# Cache value layout — kept as a tuple for the small per-frame cost of
# tuple unpacking vs the readability of a dataclass. The first two entries
# are the (edge_len, led_count) signature used to detect a re-build.
@@ -75,6 +78,9 @@ def average_edge_to_leds(
led_count: int,
cache: Dict[Hashable, _CacheEntry],
cache_key: Hashable,
linear: bool = False,
dither: bool = False,
frame_index: int = 0,
) -> np.ndarray:
"""Vectorised average colour per LED segment.
@@ -82,6 +88,14 @@ def average_edge_to_leds(
over axis=0 (collapsing rows), then segment along the width; for
left/right edges we average over axis=1 then segment along the height.
When ``linear`` is True the pixels are decoded to linear light before
averaging and re-encoded to sRGB at the end — perceptually correct
blending at a small extra cost (a LUT decode of the input + an analytic
encode of the per-LED result).
When ``dither`` is True the final 8-bit quantization is spatio-temporally
dithered (using ``frame_index``) to suppress gradient banding.
Returns a view into the caller-owned cache's ``out_uint8`` buffer —
do NOT retain the result across calls without copying.
"""
@@ -110,8 +124,13 @@ def average_edge_to_leds(
out_uint8,
) = entry
# Decode to linear light first so both the row/column collapse and the
# per-segment mean happen in physically-linear space. ``src`` is float32
# in [0, 1] (linear) or the raw uint8 sRGB pixels otherwise.
src = srgb_to_linear(edge_pixels) if linear else edge_pixels
# Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
np.mean(src, axis=axis, out=edge_1d_buf)
# Cumulative sum so each LED segment's sum is two array lookups apart.
cumsum_buf[0] = 0
@@ -122,8 +141,16 @@ def average_edge_to_leds(
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
if dither:
# sums_buf is linear [0,1] or sRGB [0,255]; quantize with dithering.
srgb_f = linear_to_srgb_float(sums_buf) if linear else sums_buf
np.copyto(out_uint8, ordered_dither_quantize(srgb_f, frame_index))
elif linear:
# sums_buf holds linear [0, 1] averages — re-encode to sRGB uint8.
np.copyto(out_uint8, linear_to_srgb_uint8(sums_buf))
else:
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
@@ -19,6 +19,17 @@ logger = get_logger(__name__)
ARDUINO_RESET_DELAY = 2.0 # seconds to wait after opening serial for Arduino bootloader
# Settle time between sending the final black frame and closing the port.
# Closing the serial port deasserts DTR, which triggers the Arduino
# auto-reset on most Adalight boards. ``flush()`` only guarantees the bytes
# left the host UART — NOT that the board received the frame, parsed it, and
# ran the LED ``show()`` before the reset wipes its RAM. Without this pause
# the reset intermittently wins the race and the strip latches its last lit
# frame instead of going dark (observed as "the strip sometimes stays on
# after the target/automation stops"). 150 ms is far more than the few ms a
# board needs to paint one frame, and it's a one-shot cost on teardown.
BLACK_FRAME_SETTLE_DELAY = 0.15
def parse_adalight_url(url: str) -> Tuple[str, int]:
"""Backwards-compatible alias for :func:`parse_serial_url`."""
@@ -126,6 +137,10 @@ class AdalightClient(LEDClient):
)
await loop.run_in_executor(executor, self._serial.write, frame)
await loop.run_in_executor(executor, self._serial.flush)
# Let the board parse the frame and run show() before close()
# toggles DTR and resets it — otherwise the strip can latch its
# last lit frame instead of going dark. See BLACK_FRAME_SETTLE_DELAY.
await asyncio.sleep(BLACK_FRAME_SETTLE_DELAY)
logger.info(f"Adalight black frame sent and flushed: {self._port}")
except Exception as e:
logger.warning(f"Failed to send black frame on close: {e}")
@@ -79,6 +79,9 @@ class HueConfig(BaseDeviceConfig):
hue_username: str = ""
hue_client_key: str = ""
hue_entertainment_group_id: str = ""
# Map the strip across the entertainment configuration's channels
# (gradient-lightstrip segments) instead of one record per light.
hue_gradient_mode: bool = True
@dataclass(frozen=True)
@@ -115,6 +118,8 @@ class LIFXConfig(BaseDeviceConfig):
device_type: Literal["lifx"] = "lifx"
lifx_min_interval_ms: int = 50
# Per-zone/tile streaming (Z/Beam multizone, Tile/Canvas matrix) vs single colour.
lifx_per_zone: bool = False
@dataclass(frozen=True)
@@ -153,6 +158,8 @@ class NanoleafConfig(BaseDeviceConfig):
device_type: Literal["nanoleaf"] = "nanoleaf"
nanoleaf_token: str = ""
nanoleaf_min_interval_ms: int = 100
# Per-panel extControl UDP streaming (addresses each panel) vs single colour.
nanoleaf_per_panel: bool = False
@dataclass(frozen=True)
+91 -13
View File
@@ -9,6 +9,7 @@ from typing import List, Tuple
import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import resample_to_n
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -24,15 +25,40 @@ COLOR_SPACE_RGB = 0x00
HEADER_SIZE = 16
def parse_entertainment_channels(config_json: dict) -> List[int]:
"""Extract ordered channel ids from an entertainment_configuration GET.
Entertainment API v2 keys stream records by *channel*, not by light. A
plain bulb contributes one channel; a gradient lightstrip contributes up
to five (one per segment). We order channels left-to-right by their
spatial ``position`` (x then y) so a strip maps across the segments in
physical order. Channels without a position fall back to channel-id order.
"""
data = config_json.get("data") or []
if not data:
return []
channels = data[0].get("channels") or []
def _key(c: dict) -> tuple:
pos = c.get("position") or {}
return (pos.get("x", 0.0), pos.get("y", 0.0), c.get("channel_id", 0))
ordered = sorted(channels, key=_key)
return [int(c["channel_id"]) for c in ordered if "channel_id" in c]
def _build_entertainment_frame(
lights: List[Tuple[int, int, int]],
colors: List[Tuple[int, int, int]],
brightness: int = 255,
sequence: int = 0,
channel_ids: List[int] | None = None,
) -> bytes:
"""Build a Hue Entertainment API v2 UDP frame.
Each light gets 7 bytes: [light_id(2B)][R(2B)][G(2B)][B(2B)]
Colors are 16-bit (0-65535). We scale 8-bit RGB + brightness.
Each record is 7 bytes: [channel_id(1B)][R(2B)][G(2B)][B(2B)]. Colors are
16-bit (0-65535). Record ``i`` is keyed by ``channel_ids[i]`` when a
channel map is supplied (gradient-segment mode); otherwise it falls back
to the 0-based index (one light = one channel, the legacy behaviour).
"""
# Header
header = bytearray(HEADER_SIZE)
@@ -45,15 +71,15 @@ def _build_entertainment_frame(
header[14] = COLOR_SPACE_RGB
header[15] = 0x00 # reserved
# Light data
# Channel data
# Note: brightness already applied by processor loop (_cached_brightness)
data = bytearray()
for idx, (r, g, b) in enumerate(lights):
light_id = idx # 0-based light index in entertainment group
for idx, (r, g, b) in enumerate(colors):
channel_id = channel_ids[idx] if channel_ids and idx < len(channel_ids) else idx
r16 = int(r * 257) # scale 0-255 to 0-65535
g16 = int(g * 257)
b16 = int(b * 257)
data += struct.pack(">BHHH", light_id, r16, g16, b16)
data += struct.pack(">BHHH", channel_id & 0xFF, r16, g16, b16)
return bytes(header) + bytes(data)
@@ -62,7 +88,11 @@ class HueClient(LEDClient):
"""LED client for Philips Hue Entertainment API streaming.
Uses UDP (optionally DTLS) to stream color data at ~25 fps to a Hue
entertainment group. Each light in the group is treated as one "LED".
entertainment group. In ``gradient_mode`` (the default) the client
discovers the entertainment configuration's *channels* on connect and
maps the strip across them, so a gradient lightstrip shows a gradient
instead of a single averaged colour. With gradient mode off (or if
discovery fails) it falls back to one record per light by index.
"""
def __init__(
@@ -72,6 +102,7 @@ class HueClient(LEDClient):
hue_username: str = "",
hue_client_key: str = "",
hue_entertainment_group_id: str = "",
gradient_mode: bool = True,
**kwargs,
):
self._bridge_ip = url.replace("hue://", "").rstrip("/")
@@ -79,15 +110,55 @@ class HueClient(LEDClient):
self._username = hue_username
self._client_key = hue_client_key
self._group_id = hue_entertainment_group_id
self._gradient_mode = gradient_mode
self._channel_ids: List[int] = []
self._sock: socket.socket | None = None
self._connected = False
self._sequence = 0
self._dtls_sock = None
@property
def device_led_count(self) -> int | None:
# In gradient mode the discovered channel count is authoritative.
if self._gradient_mode and self._channel_ids:
return len(self._channel_ids)
return self._led_count or None
async def _fetch_channels(self) -> None:
"""Best-effort discovery of the entertainment configuration channels."""
import httpx
url = (
f"https://{self._bridge_ip}/clip/v2/resource/"
f"entertainment_configuration/{self._group_id}"
)
headers = {"hue-application-key": self._username}
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
self._channel_ids = parse_entertainment_channels(resp.json())
if self._channel_ids:
logger.info(
"Hue %s: mapped strip across %d channels", self._bridge_ip, len(self._channel_ids)
)
async def connect(self) -> bool:
# Activate entertainment streaming via REST API
await self._activate_streaming(True)
# Best-effort channel discovery for gradient-segment mapping. On any
# failure (old bridge, network) we degrade to legacy 1-light-1-record.
if self._gradient_mode:
try:
await self._fetch_channels()
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
logger.warning(
"Hue %s: channel discovery failed, using per-light mapping (%s)",
self._bridge_ip,
exc,
)
self._channel_ids = []
# Open UDP socket for entertainment streaming
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setblocking(False)
@@ -179,12 +250,19 @@ class HueClient(LEDClient):
if not self._connected:
return
if isinstance(pixels, np.ndarray):
light_colors = [tuple(pixels[i]) for i in range(min(len(pixels), self._led_count))]
else:
light_colors = pixels[: self._led_count]
# Resample the strip to the number of addressable elements: the
# discovered channel count in gradient mode, else the configured
# light count. ``resample_to_n`` spreads the strip spatially and
# handles both the np.ndarray and list-of-tuples inputs.
target = len(self._channel_ids) if self._channel_ids else self._led_count
colors = resample_to_n(pixels, target)
frame = _build_entertainment_frame(light_colors, brightness, self._sequence)
frame = _build_entertainment_frame(
colors,
brightness,
self._sequence,
channel_ids=self._channel_ids or None,
)
self._sequence = (self._sequence + 1) & 0xFF
try:
@@ -50,6 +50,7 @@ class HueDeviceProvider(LEDDeviceProvider):
hue_username=config.hue_username,
hue_client_key=config.hue_client_key,
hue_entertainment_group_id=config.hue_entertainment_group_id,
gradient_mode=getattr(config, "hue_gradient_mode", True),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
+238 -22
View File
@@ -29,6 +29,7 @@ import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.core.devices.pixel_reduce import resample_to_n
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -41,6 +42,21 @@ MSG_GET_SERVICE = 2
MSG_STATE_SERVICE = 3
MSG_SET_POWER = 21
MSG_SET_COLOR = 102
# Multizone (Z / Beam) and matrix (Tile / Canvas) per-pixel messages.
MSG_GET_COLOR_ZONES = 502
MSG_STATE_ZONE = 503
MSG_STATE_MULTIZONE = 506
MSG_SET_EXTENDED_COLOR_ZONES = 510
MSG_GET_DEVICE_CHAIN = 701
MSG_STATE_DEVICE_CHAIN = 702
MSG_SET_TILE_STATE_64 = 715
_EXT_ZONE_MAX = 82 # SetExtendedColorZones carries a fixed 82-slot HSBK array
_TILE_PIXELS = 64 # SetTileState64 always carries 64 HSBK
_TILE_STRUCT_SIZE = 55 # bytes per Tile struct in StateDeviceChain
_TILE_CHAIN_MAX = 16 # StateDeviceChain always lists 16 tile slots
# Apply field for zone writes: 0=NO_APPLY (buffer), 1=APPLY, 2=APPLY_ONLY
_ZONE_APPLY = 1
# Frame field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024
_FRAME_TAGGED = 0x3400
@@ -142,6 +158,105 @@ def _build_set_power_payload(on: bool, duration_ms: int = 0) -> bytes:
return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF)
def _build_get_color_zones_payload(start: int = 0, end: int = 255) -> bytes:
"""GetColorZones (502) payload: start_index(1) | end_index(1)."""
return struct.pack("<BB", start & 0xFF, end & 0xFF)
def _pack_hsbk_array(hsbk_list: List[Tuple[int, int, int, int]], slots: int) -> bytes:
"""Pack a fixed-length HSBK array, zero-padding unused slots.
Both SetExtendedColorZones (82 slots) and SetTileState64 (64 slots)
carry a fixed-size color array regardless of how many are in use.
"""
body = bytearray()
for h, s, b, k in hsbk_list[:slots]:
body += struct.pack("<HHHH", h & 0xFFFF, s & 0xFFFF, b & 0xFFFF, k & 0xFFFF)
used = min(len(hsbk_list), slots)
body += b"\x00" * (8 * (slots - used))
return bytes(body)
def _build_set_extended_color_zones_payload(
hsbk_list: List[Tuple[int, int, int, int]],
*,
duration_ms: int = 0,
apply: int = _ZONE_APPLY,
zone_index: int = 0,
) -> bytes:
"""SetExtendedColorZones (510): duration(4)|apply(1)|zone_index(2)|count(1)|82×HSBK."""
count = min(len(hsbk_list), _EXT_ZONE_MAX)
header = struct.pack(
"<IBHB", duration_ms & 0xFFFFFFFF, apply & 0xFF, zone_index & 0xFFFF, count & 0xFF
)
return header + _pack_hsbk_array(hsbk_list, _EXT_ZONE_MAX)
def _build_set_tile_state64_payload(
hsbk_list: List[Tuple[int, int, int, int]],
*,
tile_index: int = 0,
x: int = 0,
y: int = 0,
width: int = 8,
duration_ms: int = 0,
) -> bytes:
"""SetTileState64 (715): tile_index|length|reserved|x|y|width|duration(4)|64×HSBK."""
header = struct.pack(
"<BBBBBBI",
tile_index & 0xFF,
1, # length: number of tiles this packet addresses
0, # reserved
x & 0xFF,
y & 0xFF,
width & 0xFF,
duration_ms & 0xFFFFFFFF,
)
return header + _pack_hsbk_array(hsbk_list, _TILE_PIXELS)
def _parse_multizone_reply(raw: bytes) -> dict | None:
"""Parse StateZone (503) / StateMultiZone (506) → ``{"count", "index"}``.
``count`` is the device's *total* zone count; ``index`` is where this
packet's run starts. Returns ``None`` for any other message type.
"""
if len(raw) < 36 + 2:
return None
msg_type = struct.unpack_from("<H", raw, 32)[0]
if msg_type not in (MSG_STATE_ZONE, MSG_STATE_MULTIZONE):
return None
count, index = struct.unpack_from("<BB", raw, 36)
return {"count": int(count), "index": int(index)}
def _parse_state_device_chain(raw: bytes) -> dict | None:
"""Parse StateDeviceChain (702) → ``{"start_index", "tiles": [(w, h), ...]}``.
Payload: start_index(1) | 16 × Tile(55 bytes) | tile_devices_count(1).
Within each 55-byte Tile struct, ``width`` is byte 16 and ``height``
byte 17 (after accel int16×4 + user_x/user_y float32×2). Returns ``None``
for any other message type.
"""
min_len = 36 + 1 + _TILE_STRUCT_SIZE * _TILE_CHAIN_MAX + 1
if len(raw) < min_len:
return None
msg_type = struct.unpack_from("<H", raw, 32)[0]
if msg_type != MSG_STATE_DEVICE_CHAIN:
return None
payload_off = 36
start_index = raw[payload_off]
count_off = payload_off + 1 + _TILE_STRUCT_SIZE * _TILE_CHAIN_MAX
total = min(raw[count_off], _TILE_CHAIN_MAX)
tiles: list[tuple[int, int]] = []
for i in range(total):
base = payload_off + 1 + i * _TILE_STRUCT_SIZE
width = raw[base + 16]
height = raw[base + 17]
tiles.append((int(width), int(height)))
return {"start_index": int(start_index), "tiles": tiles}
def _parse_state_service_reply(raw: bytes) -> dict | None:
"""Parse a LIFX StateService (discovery) reply.
@@ -163,15 +278,25 @@ def _parse_state_service_reply(raw: bytes) -> dict | None:
class _LIFXProtocol(asyncio.DatagramProtocol):
"""Write-only datagram protocol. Inbound replies dropped silently."""
"""Datagram protocol that buffers inbound replies for zone/tile discovery.
The streaming hot path never reads replies, but per-zone setup needs the
StateMultiZone / StateDeviceChain answers. The buffer is bounded so a
chatty bulb can't grow it without limit during steady-state streaming.
"""
_MAX_BUFFER = 64
def __init__(self) -> None:
self.transport: asyncio.DatagramTransport | None = None
self.received: list[bytes] = []
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
# LIFX bulbs sometimes echo back state on broadcast. We don't need it
# for streaming ambilight — discard.
pass
if len(self.received) < self._MAX_BUFFER:
self.received.append(bytes(data))
def error_received(self, exc):
logger.debug("LIFX UDP error: %s", exc)
@@ -186,6 +311,7 @@ class LIFXClient(LEDClient):
led_count: int = 1,
*,
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
per_zone: bool = False,
):
host, port = parse_lifx_url(url)
self._host = host
@@ -197,6 +323,12 @@ class LIFXClient(LEDClient):
self._connected = False
self._next_tx_at: float = 0.0
self._sequence: int = 0
# Per-pixel state. ``_mode`` is "single" until connect() probes the
# device and finds multizone (Z/Beam) or tile (matrix) support.
self._per_zone = per_zone
self._mode = "single" # "single" | "multizone" | "tile"
self._zone_count = 0
self._tiles: list[tuple[int, int]] = []
@property
def host(self) -> str:
@@ -212,6 +344,12 @@ class LIFXClient(LEDClient):
@property
def device_led_count(self) -> int | None:
# In per-zone streaming the device's addressable element count is
# authoritative (zones for multizone, total pixels for tiles).
if self._mode == "multizone" and self._zone_count:
return self._zone_count
if self._mode == "tile" and self._tiles:
return sum(w * h for w, h in self._tiles)
return self._led_count or None
async def connect(self) -> bool:
@@ -227,9 +365,56 @@ class LIFXClient(LEDClient):
self._transport = transport
self._protocol = protocol # type: ignore[assignment]
self._connected = True
logger.info("LIFXClient connected to %s:%d", self._host, self._port)
if self._per_zone:
# Best-effort: probe for multizone / tile support. On timeout or
# an old single-colour bulb we silently stay in single-colour mode.
try:
await self._setup_zones()
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
logger.warning(
"LIFX %s: per-zone setup failed, using single colour (%s)", self._host, exc
)
self._mode = "single"
logger.info(
"LIFXClient connected to %s:%d (per_zone=%s, mode=%s)",
self._host,
self._port,
self._per_zone,
self._mode,
)
return True
async def _setup_zones(self) -> None:
"""Probe the device for multizone / tile support and pick a mode.
Sends GetColorZones + GetDeviceChain and waits briefly for whichever
reply the device returns (a strip answers StateMultiZone; a tile chain
answers StateDeviceChain). Leaves ``_mode`` at "single" if neither.
"""
if self._protocol is None:
return
self._protocol.received.clear()
self._send(MSG_GET_DEVICE_CHAIN, b"")
self._send(MSG_GET_COLOR_ZONES, _build_get_color_zones_payload())
loop = asyncio.get_running_loop()
deadline = loop.time() + 0.6
while loop.time() < deadline:
await asyncio.sleep(0.05)
for raw in list(self._protocol.received):
chain = _parse_state_device_chain(raw)
if chain and chain["tiles"]:
self._tiles = chain["tiles"]
self._mode = "tile"
logger.info("LIFX %s: tile chain (%d tiles)", self._host, len(self._tiles))
return
zones = _parse_multizone_reply(raw)
if zones and zones["count"] > 1:
self._zone_count = min(zones["count"], _EXT_ZONE_MAX)
self._mode = "multizone"
logger.info("LIFX %s: multizone (%d zones)", self._host, self._zone_count)
return
self._mode = "single"
async def close(self) -> None:
if self._transport is not None:
try:
@@ -255,25 +440,63 @@ class LIFXClient(LEDClient):
)
self._transport.sendto(packet)
def _hsbk_list(
self,
pixels: List[Tuple[int, int, int]] | np.ndarray,
n: int,
scale: float,
) -> List[Tuple[int, int, int, int]]:
"""Resample the strip to ``n`` pixels and convert each to HSBK."""
out: List[Tuple[int, int, int, int]] = []
for r, g, b in resample_to_n(pixels, n):
if scale != 1.0:
r, g, b = int(r * scale), int(g * scale), int(b * scale)
out.append(rgb_to_hsbk(r, g, b))
return out
def _emit_pixels(
self,
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int,
) -> None:
"""Build and send the packet(s) for the current mode (single/zone/tile)."""
scale = max(0, min(255, brightness)) / 255.0 if brightness < 255 else 1.0
if self._mode == "multizone" and self._zone_count > 0:
hsbk = self._hsbk_list(pixels, self._zone_count, scale)
self._send(MSG_SET_EXTENDED_COLOR_ZONES, _build_set_extended_color_zones_payload(hsbk))
return
if self._mode == "tile" and self._tiles:
total = sum(w * h for w, h in self._tiles)
full = self._hsbk_list(pixels, total, scale)
offset = 0
for ti, (w, h) in enumerate(self._tiles):
n = w * h
chunk = full[offset : offset + n]
offset += n
self._send(
MSG_SET_TILE_STATE_64,
_build_set_tile_state64_payload(chunk, tile_index=ti, width=w),
)
return
# Single-colour fallback (every non-multizone/tile bulb).
r, g, b = _average_color(pixels)
if scale != 1.0:
r, g, b = int(r * scale), int(g * scale), int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
async def send_pixels(
self,
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip → HSBK → SetColor."""
"""Stream per-zone/tile when detected, else average the strip → SetColor."""
if not self.is_connected:
raise RuntimeError("LIFXClient not connected")
now = time.monotonic()
if now < self._next_tx_at:
return True
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
self._emit_pixels(pixels, brightness)
self._next_tx_at = now + self._min_interval_s
return True
@@ -288,14 +511,7 @@ class LIFXClient(LEDClient):
now = time.monotonic()
if now < self._next_tx_at:
return
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
self._emit_pixels(pixels, brightness)
self._next_tx_at = now + self._min_interval_s
@property
@@ -51,6 +51,7 @@ class LIFXDeviceProvider(LEDDeviceProvider):
config.device_url,
led_count=config.led_count,
min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0),
per_zone=getattr(config, "lifx_per_zone", False),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
@@ -6,12 +6,13 @@ handshake: the user holds the controller's power button for 5 seconds to
open a 30-second pairing window, then we POST to ``/api/v1/new`` to claim
an auth token. The token is long-lived and gets stored on the device.
Once paired, color control is a simple ``PUT /api/v1/{token}/state`` with
HSBT (hue / saturation / brightness; kelvin only matters when sat=0).
LedGrab averages the incoming strip to one HSB triple. Per-panel streaming
mode (``extControl`` UDP, ~60 Hz, addresses each panel individually) is
documented but not implemented here — the MVP keeps the device acting as
a single-pixel target like Yeelight / Hue.
Two output modes:
* **Single-colour** (default): average the strip to one HSB triple and
``PUT /api/v1/{token}/state`` — matches every other consumer-bulb driver.
* **Per-panel** (``per_panel=True``): enable ``extControl`` v2 and stream a
UDP packet per frame to port 60222, addressing each panel individually
(resampled from the strip). Enabled when the device config opts in; falls
back to single-colour if the controller/firmware can't stream.
URL scheme: ``nanoleaf://<host>``. Port is fixed at 16021 on the protocol
side. The auth token is stored separately on the device config, not in
@@ -23,6 +24,8 @@ Reference: https://forum.nanoleaf.me/docs/openapi
from __future__ import annotations
import asyncio
import socket
import struct
from datetime import datetime, timezone
from typing import List, Tuple
from urllib.parse import urlparse
@@ -37,9 +40,62 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__)
NANOLEAF_PORT = 16021
# extControl v2 UDP streaming target port (fixed by the protocol).
NANOLEAF_STREAM_PORT = 60222
DEFAULT_MIN_INTERVAL_S = 0.1 # 10 Hz; HTTP per frame, plenty for averaged ambilight
def order_panels(position_data: List[dict]) -> List[int]:
"""Order panel IDs left-to-right, top-to-bottom from ``panelLayout`` data.
Each ``positionData`` entry has ``panelId``/``x``/``y``. We sort by (x, y)
so an ambient strip maps across the panels spatially. Entries without an
integer ``panelId`` (or the controller/rhythm panel, panelId 0) are dropped.
"""
panels = [
p for p in position_data if isinstance(p.get("panelId"), int) and p.get("panelId", 0) != 0
]
panels.sort(key=lambda p: (p.get("x", 0), p.get("y", 0)))
return [int(p["panelId"]) for p in panels]
def map_pixels_to_panels(
pixels: List[Tuple[int, int, int]] | np.ndarray,
panel_ids: List[int],
) -> List[Tuple[int, int, int, int]]:
"""Resample an N-pixel strip to one ``(panel_id, r, g, b)`` per panel.
Nearest-neighbour resample: panel ``i`` of ``M`` samples strip pixel
``floor(i * N / M)``. Returns black for an empty strip.
"""
arr = np.asarray(pixels, dtype=np.uint8).reshape(-1, 3)
n_pix = len(arr)
n_panels = max(1, len(panel_ids))
out: List[Tuple[int, int, int, int]] = []
for i, pid in enumerate(panel_ids):
if n_pix == 0:
out.append((pid, 0, 0, 0))
continue
idx = min(n_pix - 1, (i * n_pix) // n_panels)
px = arr[idx]
out.append((pid, int(px[0]), int(px[1]), int(px[2])))
return out
def build_extcontrol_v2_packet(panels: List[Tuple[int, int, int, int]]) -> bytes:
"""Build a Nanoleaf extControl **v2** UDP streaming packet.
Wire format (all multi-byte fields big-endian):
``uint16 nPanels`` then per panel
``uint16 panelId, uint8 R, uint8 G, uint8 B, uint8 W, uint16 transitionTime``
``transitionTime`` is in 100 ms units; 1 = a 100 ms ease for smooth motion.
"""
parts = [struct.pack(">H", len(panels))]
for pid, r, g, b in panels:
parts.append(struct.pack(">HBBBBH", pid & 0xFFFF, r & 0xFF, g & 0xFF, b & 0xFF, 0, 1))
return b"".join(parts)
def parse_nanoleaf_url(url: str) -> str:
"""Pull the host out of ``nanoleaf://host`` or accept a bare host.
@@ -131,6 +187,7 @@ class NanoleafClient(LEDClient):
auth_token: str = "",
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
request_timeout_s: float = 3.0,
per_panel: bool = False,
):
self._host = parse_nanoleaf_url(url)
self._token = auth_token
@@ -140,6 +197,11 @@ class NanoleafClient(LEDClient):
self._http: httpx.AsyncClient | None = None
self._connected = False
self._next_tx_at: float = 0.0
# Per-panel extControl streaming state.
self._per_panel = per_panel
self._streaming = False
self._panel_ids: List[int] = []
self._udp: socket.socket | None = None
@property
def host(self) -> str:
@@ -157,6 +219,9 @@ class NanoleafClient(LEDClient):
@property
def device_led_count(self) -> int | None:
# In per-panel streaming mode the panel count is authoritative.
if self._streaming and self._panel_ids:
return len(self._panel_ids)
return self._led_count or None
def _state_url(self) -> str:
@@ -169,9 +234,63 @@ class NanoleafClient(LEDClient):
raise RuntimeError("NanoleafClient requires an auth_token; pair the device first")
self._http = httpx.AsyncClient(timeout=self._request_timeout_s)
self._connected = True
logger.info("NanoleafClient connected to %s:%d", self._host, NANOLEAF_PORT)
if self._per_panel:
# Best-effort: if streaming can't be enabled (old firmware, network)
# we silently fall back to the single-colour HTTP path.
try:
await self._setup_streaming()
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
logger.warning(
"Nanoleaf %s: per-panel streaming unavailable, using single-colour (%s)",
self._host,
exc,
)
self._streaming = False
logger.info(
"NanoleafClient connected to %s:%d (per_panel=%s, streaming=%s)",
self._host,
NANOLEAF_PORT,
self._per_panel,
self._streaming,
)
return True
async def _setup_streaming(self) -> None:
"""Fetch the panel layout and enable extControl v2 UDP streaming."""
if self._http is None:
raise RuntimeError("NanoleafClient not connected")
base = f"http://{self._host}:{NANOLEAF_PORT}/api/v1/{self._token}"
# 1) Panel layout → ordered panel IDs.
resp = await self._http.get(f"{base}/panelLayout/layout")
resp.raise_for_status()
position_data = resp.json().get("positionData", [])
panel_ids = order_panels(position_data)
if not panel_ids:
raise RuntimeError("Nanoleaf reported no addressable panels")
# 2) Switch the controller into external (UDP) control, v2.
enable = await self._http.put(
f"{base}/effects",
json={
"write": {
"command": "display",
"animType": "extControl",
"extControlVersion": "v2",
}
},
)
if enable.status_code not in (200, 204):
raise RuntimeError(
f"Nanoleaf refused extControl enable ({enable.status_code}): {enable.text[:200]}"
)
# 3) Fire-and-forget UDP socket for the per-frame stream.
self._udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._udp.setblocking(False)
self._panel_ids = panel_ids
self._streaming = True
logger.info(
"Nanoleaf %s: extControl v2 streaming over %d panels", self._host, len(panel_ids)
)
async def close(self) -> None:
if self._http is not None:
try:
@@ -179,6 +298,13 @@ class NanoleafClient(LEDClient):
except (httpx.HTTPError, RuntimeError):
pass
self._http = None
if self._udp is not None:
try:
self._udp.close()
except OSError:
pass
self._udp = None
self._streaming = False
self._connected = False
async def _put_state(self, body: dict) -> None:
@@ -197,12 +323,28 @@ class NanoleafClient(LEDClient):
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip and PUT a single HSB state update."""
"""Stream per-panel (extControl) when enabled, else average to one HSB state."""
if not self.is_connected:
raise RuntimeError("NanoleafClient not connected")
loop_now = asyncio.get_event_loop().time()
loop_now = asyncio.get_running_loop().time()
if loop_now < self._next_tx_at:
return True
if self._streaming and self._udp is not None and self._panel_ids:
scale = max(0, min(255, brightness)) / 255.0 if brightness < 255 else 1.0
arr = np.asarray(pixels, dtype=np.float32).reshape(-1, 3)
if scale != 1.0:
arr = arr * scale
scaled = np.clip(arr, 0, 255).astype(np.uint8)
panels = map_pixels_to_panels(scaled, self._panel_ids)
packet = build_extcontrol_v2_packet(panels)
try:
self._udp.sendto(packet, (self._host, NANOLEAF_STREAM_PORT))
except OSError as exc:
logger.debug("Nanoleaf %s: UDP stream send failed (%s)", self._host, exc)
self._next_tx_at = loop_now + self._min_interval_s
return True
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
@@ -61,6 +61,7 @@ class NanoleafDeviceProvider(LEDDeviceProvider):
led_count=config.led_count,
auth_token=config.nanoleaf_token,
min_interval_s=max(0.0, config.nanoleaf_min_interval_ms / 1000.0),
per_panel=getattr(config, "nanoleaf_per_panel", False),
)
async def pair_device(self, url: str) -> dict:
@@ -40,3 +40,29 @@ def average_color(
total_b += b
n = len(pixels)
return total_r // n, total_g // n, total_b // n
def resample_to_n(
pixels: List[Tuple[int, int, int]] | np.ndarray,
n: int,
) -> List[Tuple[int, int, int]]:
"""Nearest-neighbour resample an N-pixel strip to exactly ``n`` pixels.
Output pixel ``i`` of ``n`` samples input pixel ``floor(i * N / n)``, so
the strip spreads spatially across a multi-element device (LIFX zones/
tiles, Nanoleaf panels, Hue segments). Black is returned for an empty
strip; ``n <= 0`` yields an empty list.
"""
if n <= 0:
return []
arr = np.asarray(pixels, dtype=np.uint8).reshape(-1, 3)
n_pix = len(arr)
out: List[Tuple[int, int, int]] = []
for i in range(n):
if n_pix == 0:
out.append((0, 0, 0))
continue
idx = min(n_pix - 1, (i * n_pix) // n)
px = arr[idx]
out.append((int(px[0]), int(px[1]), int(px[2])))
return out
@@ -262,8 +262,10 @@ class CS2Adapter(GameAdapter):
actual_token = auth_section.get("token", "")
if not actual_token:
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_token, expected_token)
# Constant-time comparison to avoid a timing oracle. Compare UTF-8 bytes
# so an attacker-controlled non-ASCII payload token returns False rather
# than raising TypeError out of secrets.compare_digest (→ 500).
return secrets.compare_digest(actual_token.encode("utf-8"), expected_token.encode("utf-8"))
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -179,8 +179,10 @@ class Dota2Adapter(GameAdapter):
actual_token = auth_section.get("token", "")
if not actual_token:
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_token, expected_token)
# Constant-time comparison to avoid a timing oracle. Compare UTF-8 bytes
# so an attacker-controlled non-ASCII payload token returns False rather
# than raising TypeError out of secrets.compare_digest (→ 500).
return secrets.compare_digest(actual_token.encode("utf-8"), expected_token.encode("utf-8"))
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -79,7 +79,12 @@ class GenericWebhookAdapter(GameAdapter):
return False
# Constant-time comparison to avoid a token-length/timing oracle.
return secrets.compare_digest(actual_value, expected_token)
# Compare UTF-8 byte encodings: secrets.compare_digest raises TypeError
# on non-ASCII str, and the header value is attacker-controlled
# (Starlette latin-1-decodes header bytes to a possibly-non-ASCII str).
# Byte comparison is well-defined for any input and stays constant-time,
# so a non-ASCII token cleanly returns False instead of raising a 500.
return secrets.compare_digest(actual_value.encode("utf-8"), expected_token.encode("utf-8"))
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -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
@@ -244,8 +244,12 @@ class MappingAdapter(GameAdapter):
actual_value = headers.get(header_name, "")
if not (expected_value and actual_value):
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_value, expected_value)
# Constant-time comparison to avoid a timing oracle. Compare UTF-8
# bytes so an attacker-controlled non-ASCII header value returns
# False rather than raising TypeError out of compare_digest (→ 500).
return secrets.compare_digest(
actual_value.encode("utf-8"), expected_value.encode("utf-8")
)
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
return False
@@ -0,0 +1,120 @@
"""Shared in-memory runtime state + payload processing for game integrations.
Both the HTTP ingest route (``api/routes/game_integration.py``) and the
poll-based LoL manager (``lol_poll_manager.py``) feed adapter payloads through
the SAME per-integration prev-state / stats here, so a polled integration's
status counters look identical to a pushed one's.
Lives in ``core/`` (not the route) so the poll manager can reuse it without a
route → core layering inversion.
"""
from __future__ import annotations
import threading
from typing import Any
from ledgrab.core.game_integration.events import GameEvent
from ledgrab.utils import get_logger
logger = get_logger(__name__)
_state_lock = threading.Lock()
# integration_id -> prev_state dict for diff-based trigger detection
_prev_states: dict[str, dict[str, Any]] = {}
# integration_id -> runtime stats
_integration_stats: dict[str, dict[str, Any]] = {}
def get_prev_state(integration_id: str) -> dict[str, Any]:
"""Get or create the prev_state dict for an integration."""
with _state_lock:
if integration_id not in _prev_states:
_prev_states[integration_id] = {}
return _prev_states[integration_id]
def set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
"""Update the prev_state dict for an integration."""
with _state_lock:
_prev_states[integration_id] = state
def record_events(integration_id: str, events: list[GameEvent]) -> None:
"""Record event stats for an integration."""
with _state_lock:
if integration_id not in _integration_stats:
_integration_stats[integration_id] = {
"event_count": 0,
"event_counts_by_type": {},
"last_event_time": None,
}
stats = _integration_stats[integration_id]
for event in events:
stats["event_count"] += 1
stats["event_counts_by_type"][event.event_type] = (
stats["event_counts_by_type"].get(event.event_type, 0) + 1
)
stats["last_event_time"] = event.timestamp
def get_stats(integration_id: str) -> dict[str, Any]:
"""Get a snapshot of runtime stats for an integration.
Returns a fresh copy (incl. a copied ``event_counts_by_type``) so the caller
can read/iterate it on the event loop while the poll thread keeps mutating
the live dict under the lock — otherwise a concurrent insert raises
``RuntimeError: dictionary changed size during iteration``.
"""
with _state_lock:
stats = _integration_stats.get(integration_id)
if stats is None:
return {"event_count": 0, "event_counts_by_type": {}, "last_event_time": None}
return {
"event_count": stats["event_count"],
"event_counts_by_type": dict(stats["event_counts_by_type"]),
"last_event_time": stats["last_event_time"],
}
def cleanup_state(integration_id: str) -> None:
"""Remove runtime state for a deleted integration."""
with _state_lock:
_prev_states.pop(integration_id, None)
_integration_stats.pop(integration_id, None)
def process_payload(
integration_id: str,
adapter_cls: Any,
adapter_config: dict[str, Any],
data: dict[str, Any],
event_bus: Any,
) -> list[GameEvent]:
"""Parse a raw adapter payload, publish the events, and record stats.
Mirrors the body of the HTTP ingest route so a polled payload produces
identical events. Unlike the route (which returns HTTP 400 on a bad
payload), parse failures here are logged and swallowed — a poll loop must
keep running across a transient malformed frame. Returns the events
published (empty on parse failure).
"""
prev_state = get_prev_state(integration_id)
try:
events, new_state = adapter_cls.parse_payload(data, adapter_config, prev_state)
except Exception:
logger.exception(
"Adapter %s failed to parse polled payload for %s",
getattr(adapter_cls, "ADAPTER_TYPE", "?"),
integration_id,
)
return []
set_prev_state(integration_id, new_state)
for event in events:
event_bus.publish(event)
if events:
record_events(integration_id, events)
return events
@@ -0,0 +1,141 @@
"""Home Assistant MQTT auto-discovery publisher.
When an :class:`~ledgrab.storage.mqtt_source.MQTTSource` has
``publish_ha_discovery`` enabled, this publishes retained
``<discovery_prefix>/<component>/<node_id>/<object_id>/config`` topics so an
MQTT-only Home Assistant install gets LedGrab entities automatically — no YAML.
Scope (read-only telemetry): a ``binary_sensor`` per automation (active /
inactive) plus a connectivity ``binary_sensor`` for LedGrab itself, all tied to
the broker's birth/will ``<base_topic>/status`` availability topic. This is
deliberately one-way (LedGrab → HA): there is no inbound command surface, so it
adds no new attack surface. Controllable ``light``/``switch`` entities (HA →
LedGrab) are a documented follow-up.
Cleanup: disabling discovery or deleting the source publishes an empty retained
payload to every previously-published config topic, so HA drops the entities
rather than leaving orphans behind forever.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.mqtt.mqtt_runtime import MQTTRuntime
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.mqtt_source import MQTTSource
logger = get_logger(__name__)
def _node_id(source_id: str) -> str:
"""Namespace every entity by source so multiple brokers don't collide."""
return f"ledgrab_{source_id}"
class HADiscoveryPublisher:
"""Builds and publishes HA discovery configs for one MQTT runtime."""
def __init__(
self,
runtime: "MQTTRuntime",
source: "MQTTSource",
automation_store: "AutomationStore",
version: str = "",
) -> None:
self._runtime = runtime
self._source = source
self._automation_store = automation_store
self._version = version
self._base = source.base_topic
self._prefix = source.discovery_prefix or "homeassistant"
# Config topics we have published, so remove_all() can clear exactly them.
self._published: set[str] = set()
# ── Config builders (pure — unit tested) ──────────────────────
def _device_block(self) -> dict:
return {
"identifiers": [_node_id(self._source.id)],
"name": "LedGrab",
"manufacturer": "LedGrab",
"model": "Ambient",
"sw_version": self._version or "unknown",
}
def _config_topic(self, component: str, object_id: str) -> str:
return f"{self._prefix}/{component}/{_node_id(self._source.id)}/{object_id}/config"
def build_connectivity_config(self) -> tuple[str, dict]:
"""A connectivity ``binary_sensor`` reflecting LedGrab's birth/will."""
sid = self._source.id
topic = self._config_topic("binary_sensor", "connectivity")
payload = {
"unique_id": f"ledgrab_{sid}_connectivity",
"name": "LedGrab",
"device_class": "connectivity",
"state_topic": f"{self._base}/status",
"payload_on": "online",
"payload_off": "offline",
"device": self._device_block(),
}
return topic, payload
def build_automation_config(self, automation) -> tuple[str, dict]:
"""A ``binary_sensor`` reflecting an automation's active state."""
sid = self._source.id
topic = self._config_topic("binary_sensor", f"automation_{automation.id}")
payload = {
"unique_id": f"ledgrab_{sid}_automation_{automation.id}",
"name": automation.name,
"state_topic": f"{self._base}/automation/{automation.id}/state",
"value_template": "{{ value_json.action }}",
"payload_on": "active",
"payload_off": "inactive",
"availability_topic": f"{self._base}/status",
"payload_available": "online",
"payload_not_available": "offline",
"device": self._device_block(),
}
return topic, payload
# ── Publish / remove ──────────────────────────────────────────
async def publish_all(self) -> None:
"""Publish (retained) every discovery config + an initial state snapshot."""
topic, payload = self.build_connectivity_config()
await self._runtime.publish(topic, json.dumps(payload), retain=True)
self._published.add(topic)
count = 0
for automation in self._automation_store.get_all():
topic, payload = self.build_automation_config(automation)
await self._runtime.publish(topic, json.dumps(payload), retain=True)
self._published.add(topic)
# Seed an initial "inactive" state; the engine flips it live on the
# next activate/deactivate transition.
await self._runtime.publish_automation_state(automation.id, "inactive")
count += 1
logger.info(
"HA discovery published for source %s: %d automation sensor(s) + connectivity",
self._source.id,
count,
)
async def remove_all(self) -> None:
"""Clear every previously-published config (empty retained payload)."""
for topic in list(self._published):
await self._runtime.publish(topic, "", retain=True)
# Also clear automations that may have been published in a prior run but
# since deleted — recompute the current set and clear those too.
topic, _ = self.build_connectivity_config()
await self._runtime.publish(topic, "", retain=True)
for automation in self._automation_store.get_all():
topic, _ = self.build_automation_config(automation)
await self._runtime.publish(topic, "", retain=True)
self._published.clear()
logger.info("HA discovery removed for source %s", self._source.id)
+99 -1
View File
@@ -22,11 +22,27 @@ class MQTTManager:
Multiple consumers share the same runtime via acquire/release.
"""
def __init__(self, store: MQTTSourceStore) -> None:
def __init__(
self,
store: MQTTSourceStore,
automation_store=None,
version: str = "",
) -> None:
self._store = store
# Optional deps for HA discovery publishing (injected from main.py).
self._automation_store = automation_store
self._version = version
# source_id -> (runtime, ref_count)
self._runtimes: Dict[str, tuple] = {}
# Sources for which we hold a discovery acquire() reference.
self._discovery_sources: set[str] = set()
self._lock = asyncio.Lock()
# Serializes the discovery reconcile (ensure/disable) check-then-acquire so
# two concurrent sync_discovery() calls for the same source can't both see
# "first time" and double-acquire (ref-count leak). Separate from _lock
# because ensure/disable call acquire()/release() which take _lock, and
# asyncio.Lock is not re-entrant.
self._discovery_lock = asyncio.Lock()
async def acquire(self, source_id: str) -> MQTTRuntime:
"""Get or create a runtime for the given MQTT source. Increments ref count."""
@@ -100,6 +116,88 @@ class MQTTManager:
except Exception as e:
logger.warning("Failed to update MQTT runtime %s: %s", source_id, e)
# ===== Home Assistant MQTT discovery =====
def _make_publisher(self, runtime: MQTTRuntime, source):
from ledgrab.core.mqtt.ha_discovery import HADiscoveryPublisher
return HADiscoveryPublisher(runtime, source, self._automation_store, version=self._version)
async def bootstrap_discovery(self) -> None:
"""On startup, ensure a runtime + publish discovery for every enabled source."""
if self._automation_store is None:
return
for source in self._store.get_all():
if getattr(source, "publish_ha_discovery", False):
try:
await self.ensure_discovery(source.id)
except Exception as exc: # noqa: BLE001 — best-effort bootstrap
logger.warning("HA discovery bootstrap failed for %s: %s", source.id, exc)
async def ensure_discovery(self, source_id: str) -> None:
"""Hold a runtime open for *source_id* and publish its discovery configs.
Idempotent: a second call re-publishes (configs are retained) without
leaking a second acquire reference.
"""
if self._automation_store is None:
return
try:
source = self._store.get(source_id)
except Exception:
return
if not getattr(source, "publish_ha_discovery", False):
return
async with self._discovery_lock:
first_time = source_id not in self._discovery_sources
if first_time:
runtime = await self.acquire(source_id)
self._discovery_sources.add(source_id)
else:
runtime = self.get_runtime(source_id)
if runtime is None:
return
await self._make_publisher(runtime, source).publish_all()
async def disable_discovery(self, source_id: str) -> None:
"""Clear a source's discovery configs and drop our runtime reference."""
async with self._discovery_lock:
if source_id not in self._discovery_sources:
return
self._discovery_sources.discard(source_id)
runtime = self.get_runtime(source_id)
if runtime is not None:
try:
source = self._store.get(source_id)
await self._make_publisher(runtime, source).remove_all()
except Exception as exc: # noqa: BLE001 — best-effort cleanup
logger.warning("HA discovery cleanup failed for %s: %s", source_id, exc)
await self.release(source_id)
async def sync_discovery(self, source_id: str) -> None:
"""Reconcile discovery state after a source is created/updated."""
try:
source = self._store.get(source_id)
except Exception:
return
if getattr(source, "publish_ha_discovery", False):
await self.ensure_discovery(source_id)
else:
await self.disable_discovery(source_id)
async def publish_automation_state_all(self, automation_id: str, active: bool) -> None:
"""Fan an automation's active state out to every discovery-enabled runtime."""
if not self._discovery_sources:
return
action = "active" if active else "inactive"
for source_id in list(self._discovery_sources):
runtime = self.get_runtime(source_id)
if runtime is not None:
try:
await runtime.publish_automation_state(automation_id, action)
except Exception: # noqa: BLE001 — never raise into the engine
pass
def get_connection_status(self) -> List[Dict[str, Any]]:
"""Get status of all active MQTT connections (for dashboard indicators)."""
result = []
@@ -0,0 +1,115 @@
"""AudioEnergyTap — a lightweight read-only tap on a shared audio capture.
Lets a non-audio stream (e.g. a procedural effect) react to live loudness
without owning audio capture. It resolves an ``audio_source_id`` to capture
params exactly like ``AudioColorStripStream`` does, acquires the SHARED stream
via the ``AudioCaptureManager`` (ref-counted, so it piggybacks on any audio
visualiser already capturing the same device), and exposes a smoothed 01
energy scalar.
"""
from __future__ import annotations
from typing import Any
from ledgrab.utils import get_logger
logger = get_logger(__name__)
# RMS of typical program audio sits around 0.050.3; scale so a moderately
# loud signal reaches ~1.0 before the per-effect intensity slider is applied.
_RMS_GAIN = 4.0
class AudioEnergyTap:
"""Resolve → acquire → read smoothed loudness from a shared audio capture."""
def __init__(
self,
audio_capture_manager: Any,
audio_source_store: Any = None,
audio_template_store: Any = None,
) -> None:
self._mgr = audio_capture_manager
self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store
self._stream = None
self._source_id = ""
self._device_index = -1
self._loopback = True
self._engine_type = None
self._engine_config = None
self._smoothed = 0.0
@property
def available(self) -> bool:
return self._mgr is not None
@property
def active(self) -> bool:
return self._stream is not None
def configure(self, audio_source_id: str) -> None:
"""Resolve capture params for ``audio_source_id`` (no acquire yet)."""
self._source_id = audio_source_id or ""
self._device_index = -1
self._loopback = True
self._engine_type = None
self._engine_config = None
if not self._source_id or not self._audio_source_store:
return
try:
resolved = self._audio_source_store.resolve_audio_source(self._source_id)
self._device_index = resolved.device_index
self._loopback = resolved.is_loopback
if resolved.audio_template_id and self._audio_template_store:
try:
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
self._engine_type = tpl.engine_type
self._engine_config = tpl.engine_config
except ValueError as e:
logger.warning(
"AudioEnergyTap: template %s missing: %s", resolved.audio_template_id, e
)
except ValueError as e:
logger.warning(
"AudioEnergyTap: failed to resolve audio source %s: %s", self._source_id, e
)
def start(self) -> None:
if self._mgr is None or self._stream is not None:
return
try:
self._stream = self._mgr.acquire(
self._device_index,
self._loopback,
engine_type=self._engine_type,
engine_config=self._engine_config,
)
except Exception as e: # acquisition is best-effort — never break the effect
logger.warning("AudioEnergyTap: failed to acquire audio capture: %s", e)
self._stream = None
def stop(self) -> None:
if self._mgr is not None and self._stream is not None:
try:
self._mgr.release(self._device_index, self._loopback, engine_type=self._engine_type)
except Exception: # release is best-effort
pass
self._stream = None
self._smoothed = 0.0
def energy(self, smoothing: float = 0.4) -> float:
"""Return smoothed loudness in [0, 1] (0 when no capture/analysis yet).
``smoothing`` is the inertia of the EMA: 0 = instantaneous, →1 = sluggish.
"""
if self._stream is None:
return 0.0
analysis = self._stream.get_latest_analysis()
raw = 0.0
if analysis is not None:
raw = min(1.0, max(0.0, float(getattr(analysis, "rms", 0.0)) * _RMS_GAIN))
a = max(0.0, min(0.99, smoothing))
self._smoothed = self._smoothed * a + raw * (1.0 - a)
return self._smoothed
@@ -246,6 +246,13 @@ class ColorStripStreamManager:
# Inject gradient store for palette resolution
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
css_stream.set_gradient_store(self._gradient_store)
# Inject audio capture deps for audio-reactive effects
if self._audio_capture_manager and hasattr(css_stream, "set_audio_capture"):
css_stream.set_audio_capture(
self._audio_capture_manager,
self._audio_source_store,
self._audio_template_store,
)
# Inject asset store for notification sound playback
if self._asset_store and hasattr(css_stream, "set_asset_store"):
css_stream.set_asset_store(self._asset_store)
@@ -10,7 +10,6 @@ to the user's location and the current season.
"""
import datetime
import math
import threading
import time
@@ -84,71 +83,16 @@ _daylight_lut: np.ndarray | None = None
# ── Solar position helpers ──────────────────────────────────────────────
def _compute_solar_times(
latitude: float,
longitude: float,
day_of_year: int,
utc_offset_hours: float = 0.0,
) -> tuple:
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time.
Uses simplified NOAA solar equations:
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
- hour angle: cos(ha) = -tan(lat) * tan(decl)
- solar noon (UTC): 12 - longitude/15
- wall-clock sunrise/sunset: solar_noon_utc + utc_offset ∓ ha/15
Polar day and polar night are clamped to visible ranges.
"""
deg2rad = math.pi / 180.0
decl_deg = 23.45 * math.sin(2.0 * math.pi * (284 + day_of_year) / 365.0)
decl_rad = decl_deg * deg2rad
lat_rad = latitude * deg2rad
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
solar_noon_utc = 12.0 - longitude / 15.0
solar_noon_local = solar_noon_utc + utc_offset_hours
if cos_ha <= -1.0:
# Polar day — sun never sets; fake a long visible window
sunrise = solar_noon_local - 9.0
sunset = solar_noon_local + 9.0
elif cos_ha >= 1.0:
# Polar night — sun never rises; collapse to noon
sunrise = solar_noon_local
sunset = solar_noon_local
else:
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
sunrise = solar_noon_local - ha_hours
sunset = solar_noon_local + ha_hours
# Clamp to a safe range the LUT builder can render. With reasonable
# tz/longitude pairs sunrise lands in (3..10) and sunset in (14..21);
# we widen the clamp so weird tz/lon combinations still produce a
# usable curve instead of dividing by zero.
sunrise = max(0.5, min(11.5, sunrise))
sunset = max(12.5, min(23.5, sunset))
return sunrise, sunset
def _utc_offset_hours_for(tz_name: str, when: datetime.datetime | None = None) -> float:
"""Return the UTC offset (in hours) for the given IANA timezone.
Empty/unknown tz falls back to the system local offset for ``when``.
"""
when = when or datetime.datetime.now()
if tz_name and ZoneInfo is not None:
try:
offset = when.replace(tzinfo=None).astimezone(ZoneInfo(tz_name)).utcoffset()
if offset is not None:
return offset.total_seconds() / 3600.0
except ZoneInfoNotFoundError:
pass
local_offset = when.astimezone().utcoffset()
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
#
# ``compute_solar_times`` / ``utc_offset_hours_for`` moved to
# ``ledgrab.utils.solar`` (pure math, no project imports) so the automation
# engine can reuse them without importing this processing module. Imported
# under their old private names here so the rest of this file (and
# ``value_stream``, which imports them from here) is unchanged.
from ledgrab.utils.solar import ( # noqa: E402
compute_solar_times as _compute_solar_times,
utc_offset_hours_for as _utc_offset_hours_for,
)
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
@@ -130,35 +130,41 @@ class DeviceHealthMixin:
"latency_ms": state.health.latency_ms,
}
)
# Audit record for device online/offline transition.
from ledgrab.core.activity_log.recorder import get_module_recorder
# 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},
)
rec = get_module_recorder()
if rec is not None:
is_online = state.health.online
# Best-effort name lookup from the device store.
device_name: str | None = None
try:
if self._device_store is not None:
device_name = self._device_store.get_device(device_id).name
except Exception:
pass
safe_name = sanitize_display(device_name) if device_name else None
display = safe_name or device_id
action = "device.online" if is_online else "device.offline"
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
status_word = "came online" if is_online else "went offline"
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=device_id,
entity_name=safe_name,
message=f"Device '{display}' {status_word}",
metadata={"latency_ms": state.health.latency_ms},
)
except Exception as e:
logger.debug("Device health: audit record failed for %s: %s", device_id, e)
# Auto-sync LED count
reported = state.health.device_led_count
@@ -304,6 +304,13 @@ class EffectColorStripStream(ColorStripStream):
# Sparkle rain state
self._sparkle_state: np.ndarray | None = None # per-LED brightness 0..1
self._gradient_store = None # injected by stream manager
# Audio-reactive modulation (tap injected by stream manager via
# set_audio_capture; defaults keep the effect working with no audio).
self._audio_tap = None
self._audio_reactive = False
self._reactive_mode = "brightness"
self._reactive_audio_source_id = ""
self._reactive_intensity = 0.7
self._update_from_source(source)
def set_gradient_store(self, gradient_store) -> None:
@@ -344,9 +351,36 @@ class EffectColorStripStream(ColorStripStream):
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
self._scale = bfloat(getattr(source, "scale", 1.0), 1.0)
self._mirror = bool(getattr(source, "mirror", False))
# Audio-reactive params
self._audio_reactive = bool(getattr(source, "audio_reactive", False))
self._reactive_mode = getattr(source, "reactive_mode", "brightness") or "brightness"
self._reactive_intensity = bfloat(getattr(source, "reactive_intensity", 0.7), 0.7)
self._reactive_audio_source_id = getattr(source, "reactive_audio_source_id", "") or ""
if self._audio_tap is not None:
self._audio_tap.configure(self._reactive_audio_source_id)
with self._colors_lock:
self._colors: np.ndarray | None = None
def set_audio_capture(
self, audio_capture_manager, audio_source_store=None, audio_template_store=None
) -> None:
"""Inject audio capture deps so the effect can react to loudness.
Called by the stream manager after construction (mirrors
``set_gradient_store``). Builds the tap and resolves the referenced
audio source; a missing manager just leaves the effect non-reactive.
"""
if audio_capture_manager is None:
return
from ledgrab.core.processing.audio_energy_tap import AudioEnergyTap
self._audio_tap = AudioEnergyTap(
audio_capture_manager, audio_source_store, audio_template_store
)
self._audio_tap.configure(self._reactive_audio_source_id)
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
@@ -369,6 +403,8 @@ class EffectColorStripStream(ColorStripStream):
def start(self) -> None:
if self._running:
return
if self._audio_reactive and self._audio_tap is not None:
self._audio_tap.start()
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
@@ -387,6 +423,8 @@ class EffectColorStripStream(ColorStripStream):
if self._thread.is_alive():
logger.warning("EffectColorStripStream animate thread did not terminate within 5s")
self._thread = None
if self._audio_tap is not None:
self._audio_tap.stop()
self._heat = None
self._heat_n = 0
logger.info("EffectColorStripStream stopped")
@@ -399,12 +437,39 @@ class EffectColorStripStream(ColorStripStream):
from ledgrab.storage.color_strip_source import EffectColorStripSource
if isinstance(source, EffectColorStripSource):
# Release the audio capture before reconfiguring so a changed
# audio source / loopback is re-acquired cleanly.
tap_was_active = self._audio_tap is not None and self._audio_tap.active
if tap_was_active:
self._audio_tap.stop()
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
if self._running and self._audio_reactive and self._audio_tap is not None:
self._audio_tap.start()
logger.info("EffectColorStripStream params updated in-place")
def _apply_audio_modulation(self, buf: np.ndarray) -> None:
"""Scale brightness and/or saturation of the rendered frame by loudness.
Quiet audio dims/desaturates toward ``1 - intensity``; loud audio drives
full brightness (and a saturation boost up to ``1 + intensity``).
"""
k = max(0.0, min(1.0, self.resolve("reactive_intensity", self._reactive_intensity)))
if k <= 0.0:
return
e = self._audio_tap.energy()
f = buf.astype(np.float32)
if self._reactive_mode in ("brightness", "both"):
f *= (1.0 - k) + k * e
if self._reactive_mode in ("saturation", "both"):
sat = (1.0 - k) + 2.0 * k * e
lum = (f[:, 0] * 0.299 + f[:, 1] * 0.587 + f[:, 2] * 0.114)[:, None]
f = lum + (f - lum) * sat
np.clip(f, 0.0, 255.0, out=f)
buf[:] = f.astype(np.uint8)
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock
@@ -476,6 +541,9 @@ class EffectColorStripStream(ColorStripStream):
continue
render_fn(self, buf, n, anim_time)
if self._audio_reactive and self._audio_tap is not None:
self._apply_audio_modulation(buf)
with self._colors_lock:
self._colors = buf
except Exception as e:
@@ -252,18 +252,24 @@ class PlaylistEngine:
order = self._resolve_order(playlist)
applied_any = False
for index, item in enumerate(order):
for orig_index, item in order:
duration = clamp_duration(item.duration_seconds)
if self._state is not None:
self._state.current_index = index
self._state.current_preset_id = item.scene_preset_id
self._state.step_started_at = datetime.now(timezone.utc)
self._state.step_duration = duration
applied = await self._apply_item(item.scene_preset_id)
if applied:
# Only advertise runtime state for a step we actually applied
# and are about to dwell on — a missing/skipped preset must not
# briefly show up in get_state(). ``current_index`` is the
# ORIGINAL persisted items[] index (not the shuffled position)
# so clients can correlate it back to the items array.
if self._state is not None:
self._state.current_index = orig_index
self._state.current_preset_id = item.scene_preset_id
self._state.step_started_at = datetime.now(timezone.utc)
self._state.step_duration = duration
applied_any = True
self._fire_event("advanced", index=index, preset_id=item.scene_preset_id)
self._fire_event("advanced", index=orig_index, preset_id=item.scene_preset_id)
# Only dwell on scenes we actually applied; skip missing ones
# immediately so the cycle doesn't stall on a dead reference.
await asyncio.sleep(duration)
@@ -271,11 +277,16 @@ class PlaylistEngine:
return applied_any
def _resolve_order(self, playlist: ScenePlaylist) -> List:
if playlist.shuffle and len(playlist.items) > 1:
shuffled = list(playlist.items)
random.shuffle(shuffled) # noqa: S311 - cosmetic ordering, not security
return shuffled
return list(playlist.items)
"""Return ``(orig_index, item)`` pairs, optionally shuffled.
The original persisted index travels with each item so ``current_index``
in the runtime state always maps back into ``playlist.items`` even when
shuffle reorders the cycle.
"""
indexed = list(enumerate(playlist.items))
if playlist.shuffle and len(indexed) > 1:
random.shuffle(indexed) # noqa: S311 - cosmetic ordering, not security
return indexed
async def _apply_item(self, preset_id: str) -> bool:
"""Apply one scene preset. Returns False if it could not be applied."""
+23 -1
View File
@@ -51,6 +51,7 @@ from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
from ledgrab.core.game_integration.community_loader import register_community_adapters
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
@@ -179,12 +180,16 @@ weather_manager = WeatherManager(weather_source_store)
ha_store = HomeAssistantStore(db)
ha_manager = HomeAssistantManager(ha_store)
mqtt_source_store = MQTTSourceStore(db)
mqtt_manager = MQTTManager(mqtt_source_store)
mqtt_manager = MQTTManager(
mqtt_source_store, automation_store=automation_store, version=__version__
)
http_endpoint_store = HTTPEndpointStore(db)
audio_processing_template_store = AudioProcessingTemplateStore(db)
game_integration_store = GameIntegrationStore(db)
pattern_template_store = PatternTemplateStore(db)
game_event_bus = GameEventBus()
# Owns LoL Live-Client-Data poll threads (League is poll-only, not GSI-push).
lol_poll_manager = LoLPollManager(game_event_bus)
register_community_adapters()
# Activity log repository — constructed at module level like other stores so
@@ -369,6 +374,7 @@ async def lifespan(app: FastAPI):
ha_manager=ha_manager,
game_integration_store=game_integration_store,
game_event_bus=game_event_bus,
lol_poll_manager=lol_poll_manager,
mqtt_store=mqtt_source_store,
mqtt_manager=mqtt_manager,
http_endpoint_store=http_endpoint_store,
@@ -420,6 +426,9 @@ async def lifespan(app: FastAPI):
# Start automation engine (evaluates conditions and activates scenes)
await automation_engine.start()
# Publish Home Assistant MQTT discovery for any source that opted in.
await mqtt_manager.bootstrap_discovery()
# Start auto-backup engine (periodic configuration backups)
await auto_backup_engine.start()
@@ -429,6 +438,13 @@ async def lifespan(app: FastAPI):
# Start update checker (periodic release polling)
await update_service.start()
# Start LoL Live-Client-Data pollers for any enabled League integrations
# (League is poll-only; GSI games push into the ingest endpoint instead).
try:
lol_poll_manager.sync(game_integration_store.get_all_integrations())
except Exception as e:
logger.error(f"Failed to start LoL pollers: {e}")
# Start OS notification listener (Windows toast → notification CSS streams)
os_notif_listener = OsNotificationListener(
color_strip_store=color_strip_store,
@@ -502,6 +518,12 @@ async def lifespan(app: FastAPI):
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
# Stop LoL poller threads so they stop publishing game events.
try:
lol_poll_manager.stop_all()
except Exception as e:
logger.error(f"Error stopping LoL pollers: {e}")
# Tear down any active calibration session BEFORE stop_all so the device
# isn't left stuck in the white-chase and its prior target is restored.
# stop() is a no-op when no session is active.
@@ -196,6 +196,37 @@
width: 100%;
}
/* ── Solar (sunrise/sunset) rule ───────────────────────────── */
.solar-event-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.solar-event-row select {
flex: 1 1 8rem;
min-width: 8rem;
}
.solar-event-row input[type="number"] {
width: 5rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.solar-offset-unit {
font-size: 0.8rem;
color: var(--text-muted);
}
.solar-coord-row {
display: flex;
gap: 10px;
}
.solar-coord-row .rule-field {
flex: 1;
}
.solar-coord-row input[type="number"] {
width: 100%;
}
.time-range-label {
font-size: 0.65rem;
font-weight: 700;
+55
View File
@@ -3261,6 +3261,60 @@
user-select: none;
}
/* Color-harmony generator (gradient editor) */
.gradient-harmony-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.gradient-harmony-row input[type="color"] {
width: 38px;
height: 30px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 6px;
background: none;
cursor: pointer;
flex-shrink: 0;
transition: box-shadow var(--duration-fast, 120ms) ease,
border-color var(--duration-fast, 120ms) ease;
}
.gradient-harmony-row input[type="color"]:hover,
.gradient-harmony-row input[type="color"]:focus-visible {
outline: none;
border-color: var(--primary-color, var(--border-color));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color, #4c8dff) 28%, transparent);
}
.gradient-harmony-types {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.gradient-harmony-btn {
/* padding inherited from .btn-sm; only the slightly smaller harmony size differs */
font-size: 0.75rem;
}
/* Linear-light toggle row (calibration editor) */
.calibration-linear-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
/* The toggle's text label — neutralize the global `label` bottom margin so it
stays vertically centered against the switch in this flex row. */
.calibration-linear-row label[for] {
margin: 0;
cursor: pointer;
}
.calibration-linear-row .input-hint {
flex-basis: 100%;
margin: 0;
}
#gradient-canvas,
#ge-gradient-canvas {
width: 100%;
@@ -4755,6 +4809,7 @@ body.composite-layer-dragging .composite-layer-drag-handle {
.ds-section[data-ds-key="filters"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="routing"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="output"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="power"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="filtering"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="broker"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="protocol"] { animation-delay: 0.10s; }
+4 -2
View File
@@ -107,7 +107,7 @@ import {
import {
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationRule,
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
toggleAutomationEnabled, triggerAutomationNow, cloneAutomation, deleteAutomation, copyWebhookUrl,
} from './features/automations.ts';
import {
showGameIntegrationEditor, saveGameIntegration, closeGameIntegrationModal,
@@ -150,7 +150,7 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
onCSSTypeChange, onEffectTypeChange, onEffectReactiveToggle, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
onAudioVizChange,
@@ -505,6 +505,7 @@ Object.assign(window, {
saveAutomationEditor,
addAutomationRule,
toggleAutomationEnabled,
triggerAutomationNow,
cloneAutomation,
deleteAutomation,
copyWebhookUrl,
@@ -586,6 +587,7 @@ Object.assign(window, {
deleteColorStrip,
onCSSTypeChange,
onEffectTypeChange,
onEffectReactiveToggle,
onEffectPaletteChange,
onCSSClockChange,
onAnimationTypeChange,
+15 -2
View File
@@ -7,6 +7,9 @@ import { IconSelect } from './icon-select.ts';
let currentLocale = 'en';
let _localeIconSelect: IconSelect | null = null;
let translations = {};
// English baseline kept in memory so a key missing from the active locale
// degrades to readable English rather than a raw dotted identifier.
let baseTranslations = {};
let _initialized = false;
const supportedLocales = {
@@ -37,9 +40,12 @@ export function t(key: string, params: Record<string, any> = {}) {
let text;
if ('count' in params) {
const form = getPluralForm(currentLocale, params.count);
text = translations[`${key}.${form}`] || translations[key] || fallbackTranslations[key] || key;
const enForm = getPluralForm('en', params.count);
text = translations[`${key}.${form}`] || translations[key]
|| baseTranslations[`${key}.${enForm}`] || baseTranslations[`${key}.${form}`]
|| baseTranslations[key] || fallbackTranslations[key] || key;
} else {
text = translations[key] || fallbackTranslations[key] || key;
text = translations[key] || baseTranslations[key] || fallbackTranslations[key] || key;
}
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
@@ -80,6 +86,13 @@ export async function setLocale(locale: string) {
}
translations = await loadTranslations(locale);
// Keep an English baseline so any key missing from a non-English locale
// resolves to English instead of a raw dotted key (see t()).
if (locale === 'en') {
baseTranslations = translations;
} else if (Object.keys(baseTranslations).length === 0) {
baseTranslations = await loadTranslations('en');
}
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
@@ -81,6 +81,11 @@ let _liveEventListener: ((e: Event) => void) | null = null;
let _loadingDelayTimer: ReturnType<typeof setTimeout> | null = null;
let _showSpinner = false;
let _hasLoadedOnce = false;
let _languageListenerAttached = false;
// Upper bound on live-prepended rows (array + DOM) so a long-lived session on a
// busy install doesn't grow without limit. Well above the load-more window.
const MAX_LIVE_ROWS = 200;
const _filters: ActiveFilters = {
categories: [],
@@ -178,14 +183,26 @@ export function localizeMessage(entry: ActivityEntry): string {
// ─── Build query string from active filters + cursor ────────
/**
* Convert a `datetime-local` value (`YYYY-MM-DDTHH:MM`, interpreted as the
* user's LOCAL wall-clock by both the picker and `new Date()`) into a real UTC
* ISO-8601 instant for the server. Stored `ts` values are UTC, so sending the
* naive local string would mis-filter by the user's UTC offset. Returns the
* input unchanged if it does not parse.
*/
function _localToUtcIso(localDatetime: string): string {
const d = new Date(localDatetime);
return isNaN(d.getTime()) ? localDatetime : d.toISOString();
}
function _buildQuery(beforeSeq: number | null = null): string {
const params = new URLSearchParams();
for (const cat of _filters.categories) params.append('categories', cat);
for (const sev of _filters.severities) params.append('severities', sev);
if (_filters.actor) params.set('actor', _filters.actor);
if (_filters.entity_type) params.set('entity_type', _filters.entity_type);
if (_filters.since) params.set('since', _filters.since);
if (_filters.until) params.set('until', _filters.until);
if (_filters.since) params.set('since', _localToUtcIso(_filters.since));
if (_filters.until) params.set('until', _localToUtcIso(_filters.until));
if (_filters.q) params.set('q', _filters.q);
if (beforeSeq != null) params.set('before_seq', String(beforeSeq));
params.set('limit', '50');
@@ -392,7 +409,7 @@ function _renderList(): string {
<span>${escapeHtml(t('activity_log.live'))}</span>
</div>
</div>
<div class="al-list" role="log" aria-label="${escapeHtml(t('activity_log.title'))}" aria-live="polite">
<div class="al-list" role="log" aria-label="${escapeHtml(t('activity_log.title'))}">
${rows}
</div>
${loadMore}`;
@@ -475,6 +492,17 @@ function _attachDelegatedClicks(): void {
if (entryId) activityLogToggleDetail(entryId);
}
});
// Dismiss the export dropdown on any click outside the export wrap (the
// panel-scoped handler above only fires for clicks inside the panel).
// Mirrors the document-level outside-click pattern used by mod-menu and the
// other dropdowns. The early-return for clicks inside .al-export-wrap avoids
// double-toggling with the in-panel toggle handler.
document.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('.al-export-wrap')) return;
_closeExportMenu();
});
}
// ─── Full panel render ───────────────────────────────────────
@@ -636,11 +664,24 @@ function _entryPassesFilters(entry: ActivityEntry): boolean {
function _prependLiveEntry(entry: ActivityEntry): void {
if (!_entryPassesFilters(entry)) return;
// Only prepend while the tab is actually visible. The global
// `server:activity_logged` event fires regardless of the active tab, and
// the panel persists when hidden (tab switch only toggles a CSS class), so
// without this guard a hidden tab would accumulate unbounded `_entries` and
// DOM rows on a busy install. The loader re-fetches a fresh page on re-entry.
const panel = document.getElementById('tab-activity_log');
if (!panel || !panel.classList.contains('active') || document.hidden) return;
_entries = [entry, ..._entries];
_total = _total + 1;
// Bound in-memory growth: keep the array within a generous cap above the
// load-more window so a long-lived session can't grow without limit.
if (_entries.length > MAX_LIVE_ROWS) {
_entries = _entries.slice(0, MAX_LIVE_ROWS);
}
// Prepend the row into the existing list (no full re-render for performance)
const list = document.getElementById('tab-activity_log')?.querySelector('.al-list');
const list = panel.querySelector('.al-list');
if (list) {
const html = _renderEntryRow(entry, true);
list.insertAdjacentHTML('afterbegin', html);
@@ -649,6 +690,9 @@ function _prependLiveEntry(entry: ActivityEntry): void {
if (firstRow) {
requestAnimationFrame(() => { firstRow.classList.add('al-entry-appear'); });
}
// Bound DOM growth: drop trailing rows beyond the cap.
const rowEls = list.querySelectorAll('.al-entry');
for (let i = MAX_LIVE_ROWS; i < rowEls.length; i++) rowEls[i].remove();
// Update count badge
const countEl = list.closest('.al-panel')?.querySelector('.al-count');
if (countEl) countEl.textContent = t('activity_log.n_entries', { n: _total });
@@ -683,7 +727,16 @@ export function activityLogToggleDetail(entryId: string): void {
if (!row) return;
const entry = _entries.find(e => e.id === entryId);
if (!entry) return;
// Was the keyboard focus inside the row we're about to replace? outerHTML
// destroys the focused .al-entry-row node, so focus would fall to <body>.
const wasFocused = row.contains(document.activeElement);
row.outerHTML = _renderEntryRow(entry, false);
if (wasFocused) {
// Restore focus to the recreated row so keyboard users keep their place.
const newRow = panel.querySelector<HTMLElement>(
`[data-al-id="${CSS.escape(entryId)}"] .al-entry-row[data-toggle-id]`);
newRow?.focus();
}
}
export function activityLogToggleCat(cat: string): void {
@@ -756,10 +809,13 @@ export function activityLogPreset(key: string): void {
switch (key) {
case 'today': {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
// datetime-local format: YYYY-MM-DDTHH:MM
_filters.since = todayStart.toISOString().slice(0, 16);
const d = new Date();
d.setHours(0, 0, 0, 0);
// Build a LOCAL-wall-clock datetime-local string (YYYY-MM-DDTHH:MM)
// so it matches the manual since/until pickers and renders correctly
// in the input. _buildQuery converts it to a UTC instant for the API.
const pad = (n: number) => String(n).padStart(2, '0');
_filters.since = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00`;
break;
}
case 'errors':
@@ -863,8 +919,13 @@ export async function loadActivityLog(): Promise<void> {
_startLiveUpdates();
ensureRelativeTimeTicker();
// Re-render on language change (baked-in t() calls)
document.addEventListener('languageChanged', _onLanguageChanged);
// Re-render on language change (baked-in t() calls). Guard so re-entering
// the tab (loadActivityLog runs on every tab activation) doesn't stack
// duplicate listeners.
if (!_languageListenerAttached) {
document.addEventListener('languageChanged', _onLanguageChanged);
_languageListenerAttached = true;
}
}
function _onLanguageChanged(): void {
@@ -490,13 +490,17 @@ export async function autoCalSweepForward(): Promise<void> {
_state.busy = true;
try {
await _setPosition(next);
// The user may have cancelled (unmount nulls _state) during the await.
if (!_state) return;
_state.currentIndex = next;
_state.errorMsg = '';
} catch (err: unknown) {
_state.errorMsg = _errMsg(err);
if (_state) _state.errorMsg = _errMsg(err);
} finally {
_state.busy = false;
_render();
if (_state) {
_state.busy = false;
_render();
}
}
}
@@ -511,13 +515,16 @@ export async function autoCalSweepBack(): Promise<void> {
_state.busy = true;
try {
await _setPosition(prev);
if (!_state) return;
_state.currentIndex = prev;
_state.errorMsg = '';
} catch (err: unknown) {
_state.errorMsg = _errMsg(err);
if (_state) _state.errorMsg = _errMsg(err);
} finally {
_state.busy = false;
_render();
if (_state) {
_state.busy = false;
_render();
}
}
}
@@ -530,12 +537,12 @@ export async function autoCalMarkCorner(): Promise<void> {
_state.busy = true;
try {
await _setPosition(next);
_state.currentIndex = next;
if (_state) _state.currentIndex = next;
} catch { /* best effort */ } finally {
_state.busy = false;
if (_state) _state.busy = false;
}
}
_render();
if (_state) _render();
}
export async function autoCalBackToDirection(): Promise<void> {
@@ -564,11 +571,14 @@ export async function autoCalSolve(): Promise<void> {
offset: 0,
}, { errorMessage: t('autocal.error.solve_failed') });
if (!_state) return;
_state.solved = solved;
// Stop the chase session — device restored to prior target
await _stopSession();
if (!_state) return;
_state.step = 'preview';
} catch (err: unknown) {
if (!_state) return;
_state.errorMsg = _errMsg(err);
_state.busy = false;
_render();
@@ -683,6 +693,8 @@ export async function autoCalSave(): Promise<void> {
if (onComplete) onComplete();
} catch (err: unknown) {
// The user may have cancelled (unmount nulls _state) during the await.
if (!_state) return;
_state.busy = false;
_state.errorMsg = _errMsg(err);
if (btn) btn.removeAttribute('disabled');
@@ -126,6 +126,10 @@ class AutomationEditorModal extends Modal {
deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value,
tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []),
actionUrl: (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value || '',
actionMethod: (document.getElementById('automation-action-method') as HTMLSelectElement)?.value || '',
actionFireOn: (document.getElementById('automation-action-fire-on') as HTMLSelectElement)?.value || '',
actionBody: (document.getElementById('automation-action-body') as HTMLTextAreaElement)?.value || '',
};
}
}
@@ -344,6 +348,7 @@ type RuleChipBuilder = (c: any) => ModChipOpts;
the scene activation. */
const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }),
manual_trigger: () => ({ icon: _icon(P.zap), text: t('automations.rule.manual_trigger') }),
application: (c) => {
const apps = (c.apps || []).join(', ') || '—';
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
@@ -358,6 +363,22 @@ const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
if (c.timezone) text += ` · ${c.timezone}`;
return { icon: ICON_CLOCK, text, title: t('automations.rule.time_of_day') };
},
solar: (c) => {
const days: number[] = Array.isArray(c.days_of_week) ? c.days_of_week : [];
const evt = (event: any, offset: any, dflt: string): string => {
const e = event === 'sunrise' || event === 'sunset' ? event : dflt;
const label = t('automations.rule.solar.event.' + e);
const off = Number(offset) || 0;
if (!off) return label;
return `${label} ${off > 0 ? '+' : ''}${Math.abs(off)}m`;
};
let text = `${evt(c.start_event, c.start_offset_minutes, 'sunset')} ${evt(c.end_event, c.end_offset_minutes, 'sunrise')}`;
if (days.length && days.length < 7) {
text += ` · ${[...days].sort((a, b) => a - b).map((d) => t('weekday.short.' + d)).join(' ')}`;
}
if (c.timezone) text += ` · ${c.timezone}`;
return { icon: _icon(P.sun), text, title: t('automations.rule.solar') };
},
system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
@@ -516,7 +537,18 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
});
}
const chipsHtml = `<div class="mod-chips">${ruleChain}${_chainArrow('→')}${sceneChipHtml}${deactivationHtml}</div>`;
// ── Optional webhook-action chip — shows when an outbound webhook fires. ──
let actionHtml = '';
const _webhook = (automation.actions || []).find((a: any) => a.action_type === 'webhook');
if (_webhook && _webhook.webhook_url) {
actionHtml = _chainArrow('→') + _chipHtml({
icon: ICON_WEB,
text: t('automations.action.webhook'),
title: t(`automations.action.fire_on.${_webhook.fire_on || 'activate'}`),
});
}
const chipsHtml = `<div class="mod-chips">${ruleChain}${_chainArrow('→')}${sceneChipHtml}${deactivationHtml}${actionHtml}</div>`;
// ── State surfaces: LED + patch indicator ──
// Active = blink (live signal); Enabled-but-idle = off (waiting);
@@ -544,6 +576,8 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
// "AUTO · 07" pattern (last 2 hex chars, uppercase). ──
const shortId = (automation.id || '').replace(/^auto_/i, '').slice(-2).toUpperCase() || 'NA';
const hasManual = (automation.rules || []).some((r: any) => r.rule_type === 'manual_trigger');
const mod: ModCardOpts = {
running: automation.is_active,
head: {
@@ -564,6 +598,15 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
foot: {
patchState,
patchLabel,
primaryAction: hasManual
? {
label: t('automations.action.trigger'),
icon: ICON_START,
onclick: `triggerAutomationNow('${automation.id}')`,
title: t('automations.trigger.tooltip'),
variant: 'go',
}
: undefined,
secondaryActions: [
automation.enabled
? { label: t('automations.action.disable'), icon: ICON_PAUSE, onclick: `toggleAutomationEnabled('${automation.id}', false)`, variant: 'stop' }
@@ -605,6 +648,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
_ensureRuleLogicIconSelect();
_ensureDeactivationModeIconSelect();
_ensureActionIconSelects();
// Fetch scenes for selector
try {
@@ -615,6 +659,8 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = 'none';
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
(document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = 'none';
// Reset webhook action fields (overwritten below for edit/clone).
_loadAutomationAction([]);
let _editorTags: any[] = [];
@@ -642,6 +688,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
_loadAutomationAction(automation.actions || []);
_editorTags = automation.tags || [];
} catch (e: any) {
showToast(e.message, 'error');
@@ -670,6 +717,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
_loadAutomationAction(cloneData.actions || []);
_editorTags = cloneData.tags || [];
} else {
titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
@@ -684,6 +732,8 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
// Wire up deactivation mode change
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).onchange = _onDeactivationModeChange;
// Wire up webhook URL → show/hide the rest of the action fields
(document.getElementById('automation-action-webhook-url') as HTMLInputElement).oninput = _onActionUrlChange;
// Auto-name wiring
_autoNameManuallyEdited = !!(automationId || cloneData);
@@ -777,6 +827,56 @@ function _ensureDeactivationModeIconSelect() {
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
}
// ── Webhook action selectors (method + fire_on) ──
let _actionMethodIconSelect: any = null;
let _actionFireOnIconSelect: any = null;
const _ACTION_METHOD_ICONS: any = { POST: P.send, PUT: P.code, GET: P.globe };
const _ACTION_FIRE_ON_ICONS: any = { activate: P.play, deactivate: P.undo2, both: P.zap };
function _ensureActionIconSelects() {
const methodSel = document.getElementById('automation-action-method');
if (methodSel && !_actionMethodIconSelect) {
const items = ['POST', 'PUT', 'GET'].map(k => ({
value: k,
icon: _icon(_ACTION_METHOD_ICONS[k]),
label: k,
}));
_actionMethodIconSelect = new IconSelect({ target: methodSel, items, columns: 3 } as any);
}
const fireSel = document.getElementById('automation-action-fire-on');
if (fireSel && !_actionFireOnIconSelect) {
const items = ['activate', 'deactivate', 'both'].map(k => ({
value: k,
icon: _icon(_ACTION_FIRE_ON_ICONS[k]),
label: t(`automations.action.fire_on.${k}`),
}));
_actionFireOnIconSelect = new IconSelect({ target: fireSel, items, columns: 3 } as any);
}
}
// Show the method/fire_on/body fields only once a webhook URL is present.
function _onActionUrlChange() {
const url = (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value.trim() || '';
const fields = document.getElementById('automation-action-fields');
if (fields) (fields as HTMLElement).style.display = url ? '' : 'none';
}
// Populate the webhook action fields from an automation's first webhook action.
function _loadAutomationAction(actions: any[]) {
const webhook = (actions || []).find((a: any) => a.action_type === 'webhook') || null;
const urlEl = document.getElementById('automation-action-webhook-url') as HTMLInputElement;
const methodEl = document.getElementById('automation-action-method') as HTMLSelectElement;
const fireEl = document.getElementById('automation-action-fire-on') as HTMLSelectElement;
const bodyEl = document.getElementById('automation-action-body') as HTMLTextAreaElement;
if (urlEl) urlEl.value = webhook?.webhook_url || '';
if (methodEl) methodEl.value = webhook?.method || 'POST';
if (_actionMethodIconSelect) _actionMethodIconSelect.setValue(webhook?.method || 'POST');
if (fireEl) fireEl.value = webhook?.fire_on || 'activate';
if (_actionFireOnIconSelect) _actionFireOnIconSelect.setValue(webhook?.fire_on || 'activate');
if (bodyEl) bodyEl.value = webhook?.body_template || '';
_onActionUrlChange();
}
// ===== Condition editor =====
export function addAutomationRule() {
@@ -784,10 +884,10 @@ export function addAutomationRule() {
_autoGenerateAutomationName();
}
const RULE_TYPE_KEYS: RuleType[] = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant', 'http_poll'];
const RULE_TYPE_KEYS: RuleType[] = ['startup', 'manual_trigger', 'application', 'time_of_day', 'solar', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant', 'http_poll'];
const RULE_TYPE_ICONS = {
startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
startup: P.power, manual_trigger: P.zap, application: P.smartphone,
time_of_day: P.clock, solar: P.sun, system_idle: P.moon, display_state: P.monitor,
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
http_poll: P.globe,
};
@@ -885,6 +985,10 @@ function _renderStartupFields(container: HTMLElement, _data: any): void {
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
}
function _renderManualTriggerFields(container: HTMLElement, _data: any): void {
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.manual_trigger.hint')}</small>`;
}
function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
const startTime = data.start_time || '00:00';
const endTime = data.end_time || '23:59';
@@ -936,6 +1040,68 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
});
}
function _renderSolarFields(container: HTMLElement, data: any): void {
const startEvent = data.start_event === 'sunrise' ? 'sunrise' : 'sunset';
const endEvent = data.end_event === 'sunset' ? 'sunset' : 'sunrise';
const startOff = Number.isFinite(data.start_offset_minutes) ? data.start_offset_minutes : 0;
const endOff = Number.isFinite(data.end_offset_minutes) ? data.end_offset_minutes : 0;
const lat = Number.isFinite(data.latitude) ? data.latitude : 50;
const lon = Number.isFinite(data.longitude) ? data.longitude : 0;
const days: number[] = Array.isArray(data.days_of_week) ? data.days_of_week : [];
const tz: string = data.timezone || '';
const dayChips = [0, 1, 2, 3, 4, 5, 6]
.map((d) => `<button type="button" class="weekday-chip${days.includes(d) ? ' active' : ''}" data-day="${d}">${t('weekday.short.' + d)}</button>`)
.join('');
const eventOpts = (sel: string) => `
<option value="sunrise" ${sel === 'sunrise' ? 'selected' : ''}>${t('automations.rule.solar.event.sunrise')}</option>
<option value="sunset" ${sel === 'sunset' ? 'selected' : ''}>${t('automations.rule.solar.event.sunset')}</option>`;
container.innerHTML = `
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.solar.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.solar.start')}</label>
<div class="solar-event-row">
<select class="rule-solar-start-event">${eventOpts(startEvent)}</select>
<input type="number" class="rule-solar-start-offset" min="-1439" max="1439" step="5" value="${startOff}" title="${escapeHtml(t('automations.rule.solar.offset'))}">
<span class="solar-offset-unit">${t('automations.rule.solar.offset.unit')}</span>
</div>
</div>
<div class="rule-field">
<label>${t('automations.rule.solar.end')}</label>
<div class="solar-event-row">
<select class="rule-solar-end-event">${eventOpts(endEvent)}</select>
<input type="number" class="rule-solar-end-offset" min="-1439" max="1439" step="5" value="${endOff}" title="${escapeHtml(t('automations.rule.solar.offset'))}">
<span class="solar-offset-unit">${t('automations.rule.solar.offset.unit')}</span>
</div>
</div>
<div class="solar-coord-row">
<div class="rule-field">
<label>${t('automations.rule.solar.latitude')}</label>
<input type="number" class="rule-solar-latitude" min="-90" max="90" step="0.0001" value="${lat}">
</div>
<div class="rule-field">
<label>${t('automations.rule.solar.longitude')}</label>
<input type="number" class="rule-solar-longitude" min="-180" max="180" step="0.0001" value="${lon}">
</div>
</div>
<small class="rule-hint-desc">${t('automations.rule.solar.location_hint')}</small>
<div class="rule-weekday-block">
<span class="rule-field-label">${t('automations.rule.time_of_day.days')}</span>
<div class="weekday-chips">${dayChips}</div>
<small class="rule-hint-desc">${t('automations.rule.time_of_day.days_hint')}</small>
</div>
<div class="rule-tz-block">
<label class="rule-field-label">${t('automations.rule.time_of_day.timezone')}</label>
<input type="text" class="rule-timezone" placeholder="${escapeHtml(t('automations.rule.time_of_day.timezone.placeholder'))}" value="${escapeHtml(tz)}">
</div>
</div>`;
enhanceMiniSelects(container, 'select.rule-solar-start-event');
enhanceMiniSelects(container, 'select.rule-solar-end-event');
container.querySelectorAll('.weekday-chip').forEach((chip) => {
chip.addEventListener('click', () => chip.classList.toggle('active'));
});
}
function _renderSystemIdleFields(container: HTMLElement, data: any): void {
const idleMinutes = data.idle_minutes ?? 5;
const whenIdle = data.when_idle ?? true;
@@ -1254,8 +1420,10 @@ function _renderApplicationFields(container: HTMLElement, data: any): void {
const RULE_FIELD_RENDERERS: Record<RuleType, RuleFieldRenderer> = {
startup: _renderStartupFields,
manual_trigger: _renderManualTriggerFields,
application: _renderApplicationFields,
time_of_day: _renderTimeOfDayFields,
solar: _renderSolarFields,
system_idle: _renderSystemIdleFields,
display_state: _renderDisplayStateFields,
mqtt: _renderMqttFields,
@@ -1340,6 +1508,7 @@ type RuleCollector = (row: Element) => Record<string, any>;
const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
startup: () => ({ rule_type: 'startup' }),
manual_trigger: () => ({ rule_type: 'manual_trigger' }),
time_of_day: (row) => ({
rule_type: 'time_of_day',
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
@@ -1348,6 +1517,22 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
.map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)),
timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(),
}),
solar: (row) => {
const lat = parseFloat((row.querySelector('.rule-solar-latitude') as HTMLInputElement).value);
const lon = parseFloat((row.querySelector('.rule-solar-longitude') as HTMLInputElement).value);
return {
rule_type: 'solar',
start_event: (row.querySelector('.rule-solar-start-event') as HTMLSelectElement).value === 'sunrise' ? 'sunrise' : 'sunset',
start_offset_minutes: parseInt((row.querySelector('.rule-solar-start-offset') as HTMLInputElement).value, 10) || 0,
end_event: (row.querySelector('.rule-solar-end-event') as HTMLSelectElement).value === 'sunset' ? 'sunset' : 'sunrise',
end_offset_minutes: parseInt((row.querySelector('.rule-solar-end-offset') as HTMLInputElement).value, 10) || 0,
latitude: Number.isFinite(lat) ? lat : 50,
longitude: Number.isFinite(lon) ? lon : 0,
days_of_week: Array.from(row.querySelectorAll('.weekday-chip.active'))
.map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)),
timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(),
};
},
system_idle: (row) => ({
rule_type: 'system_idle',
idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5,
@@ -1449,6 +1634,15 @@ export async function saveAutomationEditor() {
return;
}
const actionUrl = (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value.trim() || '';
const actions = actionUrl ? [{
action_type: 'webhook',
webhook_url: actionUrl,
method: (document.getElementById('automation-action-method') as HTMLSelectElement)?.value || 'POST',
fire_on: (document.getElementById('automation-action-fire-on') as HTMLSelectElement)?.value || 'activate',
body_template: (document.getElementById('automation-action-body') as HTMLTextAreaElement)?.value || '',
}] : [];
const body = {
name,
enabled: enabledInput.checked,
@@ -1458,6 +1652,7 @@ export async function saveAutomationEditor() {
deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null,
tags: _automationTagsInput ? _automationTagsInput.getValue() : [],
actions,
};
const automationId = idInput.value;
@@ -1494,6 +1689,27 @@ export async function toggleAutomationEnabled(automationId: any, enable: any) {
}
}
export async function triggerAutomationNow(automationId: any) {
try {
const r = await apiPost<{ status: string; errors: string[] }>(
`/automations/${automationId}/trigger`, undefined, {
errorMessage: t('automations.error.trigger_failed'),
});
if (r.status === 'skipped') {
showToast(t('automations.trigger.skipped'), 'warning');
} else if (r.errors && r.errors.length) {
showToast(`${t('automations.trigger.partial')} (${r.errors.length})`, 'warning');
} else {
showToast(t('automations.triggered'), 'success');
}
automationsCacheObj.invalidate();
loadAutomations();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t('automations.error.trigger_failed'), 'error');
}
}
export function copyWebhookUrl(btn: any) {
const input = btn.closest('.webhook-url-row').querySelector('.rule-webhook-url') as HTMLInputElement;
if (!input || !input.value) return;
@@ -936,6 +936,10 @@ function _populateRoiInputs(calibration: any): void {
set('cal-roi-y', pct(calibration.roi_y, 0));
set('cal-roi-width', pct(calibration.roi_width, 1));
set('cal-roi-height', pct(calibration.roi_height, 1));
const linEl = document.getElementById('cal-linear-blend') as HTMLInputElement | null;
if (linEl) linEl.checked = !!calibration.linear_blend;
const ditherEl = document.getElementById('cal-dither') as HTMLInputElement | null;
if (ditherEl) ditherEl.checked = !!calibration.dither;
}
export async function saveCalibration() {
@@ -996,6 +1000,8 @@ export async function saveCalibration() {
roi_y: (parseFloat((document.getElementById('cal-roi-y') as HTMLInputElement).value) || 0) / 100,
roi_width: (parseFloat((document.getElementById('cal-roi-width') as HTMLInputElement).value) || 100) / 100,
roi_height: (parseFloat((document.getElementById('cal-roi-height') as HTMLInputElement).value) || 100) / 100,
linear_blend: (document.getElementById('cal-linear-blend') as HTMLInputElement)?.checked || false,
dither: (document.getElementById('cal-dither') as HTMLInputElement)?.checked || false,
};
try {
@@ -13,6 +13,7 @@ import { TagInput, renderTagChips } from '../../core/tag-input.ts';
import { escapeHtml } from '../../core/api.ts';
import {
gradientInit, gradientRenderAll, getGradientStops, gradientSetIdPrefix,
gradientWireHarmony,
} from '../css-gradient-editor.ts';
/* ── Helpers ──────────────────────────────────────────────────── */
@@ -180,6 +181,7 @@ export async function showGradientModal(editId: string | null = null, cloneData:
requestAnimationFrame(() => {
gradientSetIdPrefix('ge-');
gradientInit(stops);
gradientWireHarmony();
gradientEditorModal.snapshot();
});
}
@@ -22,6 +22,9 @@ import type { ColorStripSource } from '../../types.ts';
import { TagInput } from '../../core/tag-input.ts';
import { IconSelect, showTypePicker, type IconSelectItem } from '../../core/icon-select.ts';
import { EntitySelect } from '../../core/entity-palette.ts';
import { enhanceMiniSelects } from '../../core/mini-select.ts';
import { audioSourcesCache } from '../../core/state.ts';
import { getAudioSourceIcon } from '../../core/icons.ts';
import { BindableScalarWidget } from '../../core/bindable-scalar.ts';
import { BindableColorWidget } from '../../core/bindable-color.ts';
import { getBaseOrigin } from '../settings.ts';
@@ -568,6 +571,8 @@ let _animationTypeIconSelect: any = null;
let _interpolationIconSelect: any = null;
let _effectTypeIconSelect: any = null;
let _effectPaletteEntitySelect: EntitySelect | null = null;
let _effectReactiveSourceEntitySelect: EntitySelect | null = null;
let _effectReactiveModeEnhanced = false;
let _gradientPresetEntitySelect: EntitySelect | null = null;
let _gradientEasingIconSelect: any = null;
let _candleTypeIconSelect: any = null;
@@ -637,6 +642,50 @@ function _ensureEffectIntensityWidget(): BindableScalarWidget {
return _effectIntensityWidget;
}
/* ── Effect audio-reactive controls ───────────────────────────── */
/** Show/hide the reactive options group and lazily populate the source list. */
export function onEffectReactiveToggle() {
const on = (document.getElementById('css-editor-effect-audio-reactive') as HTMLInputElement)?.checked;
const group = document.getElementById('css-editor-effect-reactive-group');
if (group) group.style.display = on ? '' : 'none';
if (on) {
void _populateEffectReactiveSource();
const modeSel = document.getElementById('css-editor-effect-reactive-mode');
if (modeSel && !_effectReactiveModeEnhanced) {
enhanceMiniSelects(modeSel.parentElement || document, '#css-editor-effect-reactive-mode');
_effectReactiveModeEnhanced = true;
}
}
}
/** Build the EntitySelect for the reactive audio-source picker from the cache.
* Pass `desired` to select a specific source after (re)building the options. */
async function _populateEffectReactiveSource(desired?: string) {
const select = document.getElementById('css-editor-effect-reactive-source') as HTMLSelectElement | null;
if (!select) return;
try {
const sources: any[] = await audioSourcesCache.fetch();
const target = desired !== undefined ? desired : select.value;
select.innerHTML = sources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
if (target) select.value = target;
if (_effectReactiveSourceEntitySelect) { _effectReactiveSourceEntitySelect.destroy(); _effectReactiveSourceEntitySelect = null; }
if (sources.length > 0) {
_effectReactiveSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => sources.map(s => ({
value: s.id, label: s.name,
icon: getAudioSourceIcon(s.source_type), desc: s.source_type,
})),
placeholder: t('palette.search'),
});
if (target) _effectReactiveSourceEntitySelect.setValue(target);
}
} catch {
select.innerHTML = '';
}
}
function _ensureEffectScaleWidget(): BindableScalarWidget {
if (!_effectScaleWidget) {
_effectScaleWidget = new BindableScalarWidget({
@@ -1041,6 +1090,23 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
_ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
_ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
// Audio reactivity
const reactive = !!css.audio_reactive;
(document.getElementById('css-editor-effect-audio-reactive') as HTMLInputElement).checked = reactive;
(document.getElementById('css-editor-effect-reactive-mode') as HTMLSelectElement).value = css.reactive_mode || 'brightness';
const rIntensity = typeof css.reactive_intensity === 'object' && css.reactive_intensity !== null
? (css.reactive_intensity.value ?? 0.7) : (css.reactive_intensity ?? 0.7);
(document.getElementById('css-editor-effect-reactive-intensity') as HTMLInputElement).value = String(rIntensity);
const rGroup = document.getElementById('css-editor-effect-reactive-group');
if (rGroup) rGroup.style.display = reactive ? '' : 'none';
if (reactive) {
void _populateEffectReactiveSource(css.reactive_audio_source_id || '');
const modeSel = document.getElementById('css-editor-effect-reactive-mode');
if (modeSel && !_effectReactiveModeEnhanced) {
enhanceMiniSelects(modeSel.parentElement || document, '#css-editor-effect-reactive-mode');
_effectReactiveModeEnhanced = true;
}
}
onEffectPaletteChange();
},
reset() {
@@ -1051,6 +1117,11 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
_ensureEffectIntensityWidget().setValue(1.0);
_ensureEffectScaleWidget().setValue(1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
(document.getElementById('css-editor-effect-audio-reactive') as HTMLInputElement).checked = false;
(document.getElementById('css-editor-effect-reactive-mode') as HTMLSelectElement).value = 'brightness';
(document.getElementById('css-editor-effect-reactive-intensity') as HTMLInputElement).value = '0.7';
const rGroup = document.getElementById('css-editor-effect-reactive-group');
if (rGroup) rGroup.style.display = 'none';
},
getPayload(name) {
const payload: any = {
@@ -1060,6 +1131,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
intensity: _ensureEffectIntensityWidget().getValue(),
scale: _ensureEffectScaleWidget().getValue(),
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
audio_reactive: (document.getElementById('css-editor-effect-audio-reactive') as HTMLInputElement).checked,
reactive_audio_source_id: (document.getElementById('css-editor-effect-reactive-source') as HTMLSelectElement).value || '',
reactive_mode: (document.getElementById('css-editor-effect-reactive-mode') as HTMLSelectElement).value || 'brightness',
reactive_intensity: parseFloat((document.getElementById('css-editor-effect-reactive-intensity') as HTMLInputElement).value) || 0.7,
};
if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) {
payload.color = _ensureEffectColorWidget().getValue();
@@ -212,6 +212,112 @@ export function applyGradientPreset(key: string): void {
gradientInit(GRADIENT_PRESETS[key]);
}
/* ── Color harmony generator ──────────────────────────────────── */
export type HarmonyType =
| 'complementary' | 'analogous' | 'triadic'
| 'split_complementary' | 'tetradic' | 'monochromatic';
export const HARMONY_TYPES: HarmonyType[] = [
'complementary', 'analogous', 'triadic',
'split_complementary', 'tetradic', 'monochromatic',
];
/** RGB (0255) → HSV with h in [0,360), s/v in [0,1]. */
function _rgbToHsv(rgb: number[]): [number, number, number] {
const r = rgb[0] / 255, g = rgb[1] / 255, b = rgb[2] / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
const s = max === 0 ? 0 : d / max;
return [h, s, max];
}
/** HSV (h in [0,360), s/v in [0,1]) → RGB (0255). */
function _hsvToRgb(h: number, s: number, v: number): number[] {
h = ((h % 360) + 360) % 360;
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
return [r, g, b].map(ch => Math.round((ch + m) * 255));
}
const _HARMONY_ROTATIONS: Partial<Record<HarmonyType, number[]>> = {
complementary: [0, 180],
analogous: [-30, 0, 30],
triadic: [0, 120, 240],
split_complementary: [0, 150, 210],
tetradic: [0, 90, 180, 270],
};
/**
* Generate evenly-spaced gradient stops from a base color using classic
* color-theory relationships. Monochromatic ramps the value of a single hue;
* every other type rotates the hue by fixed angles and keeps S/V.
*/
export function generateHarmonyStops(baseRgb: number[], type: HarmonyType): GradientPresetStop[] {
const [h, s, v] = _rgbToHsv(baseRgb);
let colors: number[][];
if (type === 'monochromatic') {
const steps = 5;
colors = Array.from({ length: steps }, (_, i) =>
_hsvToRgb(h, Math.min(1, s + 0.05 * (steps - 1 - i)), 0.3 + (0.7 * i) / (steps - 1)),
);
} else {
const rotations = _HARMONY_ROTATIONS[type] || [0, 180];
colors = rotations.map(r => _hsvToRgb(h + r, s, v));
}
const n = colors.length;
return colors.map((color, i) => ({
position: n > 1 ? Math.round((i / (n - 1)) * 100) / 100 : 0,
color,
}));
}
/** Replace the current gradient with a harmony generated from `baseRgb`. */
export function applyColorHarmony(baseRgb: number[], type: HarmonyType): void {
gradientInit(generateHarmonyStops(baseRgb, type));
}
/**
* Wire the harmony controls in the prefixed editor scope (base color input +
* one button per harmony type). Idempotent safe to call on every modal open.
*/
export function gradientWireHarmony(): void {
const container = _el('gradient-harmony-types');
const baseInput = _el('gradient-harmony-base') as HTMLInputElement | null;
if (!container || !baseInput) return;
// Seed the base picker from the current gradient's first stop.
if (_gradientStops.length > 0) {
baseInput.value = rgbArrayToHex(_gradientStops[0].color);
}
if ((container as any)._harmonyBound) return;
(container as any)._harmonyBound = true;
container.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('[data-harmony]') as HTMLElement | null;
if (!btn) return;
const type = btn.dataset.harmony as HarmonyType;
if (!HARMONY_TYPES.includes(type)) return;
applyColorHarmony(hexToRgbArray(baseInput.value), type);
});
}
/* ── Render ───────────────────────────────────────────────────── */
export function gradientRenderAll(): void {
@@ -792,10 +792,22 @@ function _reconcileDynamicSections(dynamic: HTMLElement, newHtml: string): void
// If the new HTML contains non-section top-level nodes (e.g. the
// `.dashboard-no-targets` placeholder shown when there are no entities),
// fall back to a simple innerHTML swap — this path is rare and the
// no-entities state doesn't have live widgets worth preserving.
// fall back to an innerHTML swap. BUT the empty-entities (first-run) state
// DOES include a live widget — the recent-activity section — and the fresh
// HTML always carries its loading-spinner placeholder. A blind swap on every
// 2s poll would wipe the populated list back to the spinner and re-fetch
// /activity-log endlessly. So preserve a live, populated recent-activity
// node by grafting it into the new HTML before swapping.
const totalTopLevel = scratch.children.length;
if (totalTopLevel !== incoming.length) {
const liveRa = dynamic.querySelector<HTMLElement>('#dashboard-recent-activity-list.dal-list');
if (liveRa) {
const scratchRa = scratch.querySelector<HTMLElement>('#dashboard-recent-activity-list');
if (scratchRa) scratchRa.replaceWith(liveRa.cloneNode(true));
const merged = scratch.innerHTML;
if (dynamic.innerHTML !== merged) dynamic.innerHTML = merged;
return;
}
if (dynamic.innerHTML !== newHtml) dynamic.innerHTML = newHtml;
return;
}
@@ -1010,6 +1010,8 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
if (lmi && cloneData.lifx_min_interval_ms != null) {
lmi.value = String(cloneData.lifx_min_interval_ms);
}
const lpz = document.getElementById('device-lifx-per-zone') as HTMLInputElement | null;
if (lpz) lpz.checked = !!cloneData.lifx_per_zone;
}
// Prefill Nanoleaf fields (clone only carries the rate limit — the
// token is not exposed in /devices responses, so a cloned device
@@ -1019,6 +1021,8 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
if (nmi && cloneData.nanoleaf_min_interval_ms != null) {
nmi.value = String(cloneData.nanoleaf_min_interval_ms);
}
const pp = document.getElementById('device-nanoleaf-per-panel') as HTMLInputElement | null;
if (pp) pp.checked = !!cloneData.nanoleaf_per_panel;
}
// Prefill Govee fields
if (isGoveeDevice(presetType)) {
@@ -1229,6 +1233,7 @@ export async function handleAddDevice(event: any) {
body.hue_username = (document.getElementById('device-hue-username') as HTMLInputElement)?.value || '';
body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || '';
body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || '';
body.hue_gradient_mode = (document.getElementById('device-hue-gradient-mode') as HTMLInputElement | null)?.checked ?? true;
}
if (isYeelightDevice(deviceType)) {
const raw = (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value;
@@ -1244,6 +1249,7 @@ export async function handleAddDevice(event: any) {
const raw = (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '50', 10);
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
body.lifx_per_zone = (document.getElementById('device-lifx-per-zone') as HTMLInputElement | null)?.checked || false;
}
if (isGoveeDevice(deviceType)) {
const raw = (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value;
@@ -1254,6 +1260,7 @@ export async function handleAddDevice(event: any) {
const raw = (document.getElementById('device-nanoleaf-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '100', 10);
body.nanoleaf_min_interval_ms = Number.isFinite(parsed) ? parsed : 100;
body.nanoleaf_per_panel = (document.getElementById('device-nanoleaf-per-panel') as HTMLInputElement)?.checked || false;
}
if (isBleDevice(deviceType)) {
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
@@ -1611,7 +1618,7 @@ function _showEspnowFields(show: boolean) {
}
function _showHueFields(show: boolean) {
const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group'];
const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group', 'device-hue-gradient-mode-group'];
ids.forEach(id => {
const el = document.getElementById(id) as HTMLElement;
if (el) el.style.display = show ? '' : 'none';
@@ -1636,6 +1643,8 @@ function _showWizFields(show: boolean) {
function _showLifxFields(show: boolean) {
const el = document.getElementById('device-lifx-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none';
const pz = document.getElementById('device-lifx-per-zone-group') as HTMLElement | null;
if (pz) pz.style.display = show ? '' : 'none';
}
function _showGoveeFields(show: boolean) {
@@ -1654,6 +1663,8 @@ function _showGoveeFields(show: boolean) {
function _showNanoleafFields(show: boolean) {
const el = document.getElementById('device-nanoleaf-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none';
const ppEl = document.getElementById('device-nanoleaf-per-panel-group') as HTMLElement | null;
if (ppEl) ppEl.style.display = show ? '' : 'none';
const submitBtn = document.getElementById('add-device-submit-btn') as HTMLButtonElement | null;
if (submitBtn) {
if (show) {
@@ -701,10 +701,14 @@ export async function showSettings(deviceId: any) {
// (HSBK averaged from the strip). LIFX recommends ≤20 cmd/sec per
// device; default 50 ms matches that ceiling.
const lifxMinIntervalGroup = document.getElementById('settings-lifx-min-interval-group');
const lifxPerZoneGroup = document.getElementById('settings-lifx-per-zone-group');
if (isLifxDevice(device.device_type)) {
if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = '';
const lmi = device.lifx_min_interval_ms ?? 50;
(document.getElementById('settings-lifx-min-interval') as HTMLInputElement).value = String(lmi);
if (lifxPerZoneGroup) (lifxPerZoneGroup as HTMLElement).style.display = '';
const lpzEl = document.getElementById('settings-lifx-per-zone') as HTMLInputElement | null;
if (lpzEl) lpzEl.checked = !!device.lifx_per_zone;
// Relabel URL field as IP Address (same pattern as WiZ/Yeelight/DMX/DDP)
const urlLabel6 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
const urlHint6 = urlGroup.querySelector('.input-hint') as HTMLElement | null;
@@ -713,6 +717,7 @@ export async function showSettings(deviceId: any) {
urlInput.placeholder = t('device.lifx.url.placeholder') || '192.168.1.50';
} else {
if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = 'none';
if (lifxPerZoneGroup) (lifxPerZoneGroup as HTMLElement).style.display = 'none';
}
// Govee-specific fields — 2023+ LAN API over UDP fire-and-forget
@@ -746,6 +751,10 @@ export async function showSettings(deviceId: any) {
if (nanoleafMinIntervalGroup) (nanoleafMinIntervalGroup as HTMLElement).style.display = '';
const nmi = device.nanoleaf_min_interval_ms ?? 100;
(document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement).value = String(nmi);
const perPanelGroup = document.getElementById('settings-nanoleaf-per-panel-group');
if (perPanelGroup) (perPanelGroup as HTMLElement).style.display = '';
const perPanelEl = document.getElementById('settings-nanoleaf-per-panel') as HTMLInputElement | null;
if (perPanelEl) perPanelEl.checked = !!device.nanoleaf_per_panel;
if (nanoleafPairedGroup) {
(nanoleafPairedGroup as HTMLElement).style.display = device.nanoleaf_paired ? '' : 'none';
}
@@ -923,6 +932,7 @@ export async function saveDeviceSettings() {
const raw = (document.getElementById('settings-lifx-min-interval') as HTMLInputElement | null)?.value;
const parsed = parseInt(raw || '50', 10);
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
body.lifx_per_zone = (document.getElementById('settings-lifx-per-zone') as HTMLInputElement | null)?.checked || false;
}
if (isGoveeDevice(settingsModal.deviceType)) {
const raw = (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value;
@@ -933,6 +943,7 @@ export async function saveDeviceSettings() {
const raw = (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement | null)?.value;
const parsed = parseInt(raw || '100', 10);
body.nanoleaf_min_interval_ms = Number.isFinite(parsed) ? parsed : 100;
body.nanoleaf_per_panel = (document.getElementById('settings-nanoleaf-per-panel') as HTMLInputElement | null)?.checked || false;
// Intentionally do NOT include nanoleaf_token here — the token
// is set once at pair time, encrypted at rest, and never
// re-emitted from the settings modal. Re-pairing means
@@ -56,6 +56,8 @@ class MQTTSourceModal extends Modal {
base_topic: (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value,
description: (document.getElementById('mqtt-source-description') as HTMLInputElement).value,
tags: JSON.stringify(_mqttTagsInput ? _mqttTagsInput.getValue() : []),
haDiscovery: (document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement)?.checked.toString() || 'false',
discoveryPrefix: (document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement)?.value || '',
};
}
}
@@ -79,6 +81,8 @@ export async function showMQTTSourceModal(editData: MQTTSource | null = null): P
(document.getElementById('mqtt-source-password') as HTMLInputElement).value = ''; // never expose
(document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = editData.client_id || 'ledgrab';
(document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = editData.base_topic || 'ledgrab';
(document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked = !!editData.publish_ha_discovery;
(document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value = editData.discovery_prefix || 'homeassistant';
(document.getElementById('mqtt-source-description') as HTMLInputElement).value = editData.description || '';
} else {
(document.getElementById('mqtt-source-name') as HTMLInputElement).value = '';
@@ -88,9 +92,20 @@ export async function showMQTTSourceModal(editData: MQTTSource | null = null): P
(document.getElementById('mqtt-source-password') as HTMLInputElement).value = '';
(document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = 'ledgrab';
(document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = 'ledgrab';
(document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked = false;
(document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value = 'homeassistant';
(document.getElementById('mqtt-source-description') as HTMLInputElement).value = '';
}
// Show the discovery-prefix field only while discovery is enabled.
const _discoveryToggle = document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement;
const _syncDiscoveryPrefix = () => {
const grp = document.getElementById('mqtt-source-discovery-prefix-group');
if (grp) (grp as HTMLElement).style.display = _discoveryToggle?.checked ? '' : 'none';
};
if (_discoveryToggle) _discoveryToggle.onchange = _syncDiscoveryPrefix;
_syncDiscoveryPrefix();
// Tags
if (_mqttTagsInput) { _mqttTagsInput.destroy(); _mqttTagsInput = null; }
_mqttTagsInput = new TagInput(document.getElementById('mqtt-source-tags-container'), { placeholder: t('tags.placeholder') });
@@ -125,6 +140,8 @@ export async function saveMQTTSource(): Promise<void> {
const password = (document.getElementById('mqtt-source-password') as HTMLInputElement).value;
const client_id = (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value.trim() || 'ledgrab';
const base_topic = (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value.trim() || 'ledgrab';
const publish_ha_discovery = (document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked;
const discovery_prefix = (document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value.trim() || 'homeassistant';
const description = (document.getElementById('mqtt-source-description') as HTMLInputElement).value.trim() || null;
if (!name) {
@@ -138,6 +155,7 @@ export async function saveMQTTSource(): Promise<void> {
const payload: Record<string, any> = {
name, broker_port, username, client_id, base_topic, description,
publish_ha_discovery, discovery_prefix,
tags: _mqttTagsInput ? _mqttTagsInput.getValue() : [],
};
if (broker_host) payload.broker_host = broker_host;
@@ -146,7 +146,7 @@ registerIconEntityType('gradient', makeSimpleIconAdapter<any>({
endpointPrefix: '/gradients',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.gradient',
typeLabelFallback: 'Gradient',
typeLabelFallback: 'Palette',
cardSelectors: (id) => [`[data-card-section="gradients"] [data-id="${CSS.escape(id)}"]`],
}));
+2
View File
@@ -213,6 +213,7 @@ interface Window {
cloneAutomation: (...args: any[]) => any;
deleteAutomation: (...args: any[]) => any;
copyWebhookUrl: (...args: any[]) => any;
triggerAutomationNow: (...args: any[]) => any;
// ─── Scene Presets ───
openScenePresetCapture: (...args: any[]) => any;
@@ -275,6 +276,7 @@ startTargetOverlay: (...args: any[]) => any;
deleteColorStrip: (...args: any[]) => any;
onCSSTypeChange: (...args: any[]) => any;
onEffectTypeChange: (...args: any[]) => any;
onEffectReactiveToggle: (...args: any[]) => any;
onCSSClockChange: (...args: any[]) => any;
onAnimationTypeChange: (...args: any[]) => any;
onDaylightRealTimeChange: (...args: any[]) => any;
@@ -6,9 +6,11 @@
*/
export type RuleType =
| 'application' | 'time_of_day' | 'system_idle'
| 'application' | 'time_of_day' | 'solar' | 'system_idle'
| 'display_state' | 'mqtt' | 'webhook' | 'startup'
| 'home_assistant' | 'http_poll';
| 'home_assistant' | 'http_poll' | 'manual_trigger';
export type SolarEvent = 'sunrise' | 'sunset';
export type HTTPPollOperator =
| 'equals' | 'not_equals' | 'contains' | 'regex'
@@ -20,6 +22,16 @@ export interface AutomationRule {
match_type?: string;
start_time?: string;
end_time?: string;
/** time_of_day + solar rules */
days_of_week?: number[];
timezone?: string;
/** solar rule */
start_event?: SolarEvent;
start_offset_minutes?: number;
end_event?: SolarEvent;
end_offset_minutes?: number;
latitude?: number;
longitude?: number;
idle_minutes?: number;
when_idle?: boolean;
state?: string;
@@ -36,6 +48,15 @@ export interface AutomationRule {
value?: string;
}
export interface AutomationAction {
action_type: 'webhook';
webhook_url?: string;
method?: 'POST' | 'PUT' | 'GET';
body_template?: string;
content_type?: string;
fire_on?: 'activate' | 'deactivate' | 'both';
}
export interface Automation {
id: string;
name: string;
@@ -46,6 +67,7 @@ export interface Automation {
deactivation_mode: 'none' | 'revert' | 'fallback_scene';
deactivation_scene_preset_id?: string;
tags: string[];
actions?: AutomationAction[];
webhook_url?: string;
is_active: boolean;
last_activated_at?: string;
@@ -38,12 +38,15 @@ export interface Device {
espnow_channel: number;
hue_paired: boolean;
hue_entertainment_group_id: string;
hue_gradient_mode?: boolean;
yeelight_min_interval_ms: number;
wiz_min_interval_ms: number;
lifx_min_interval_ms: number;
lifx_per_zone?: boolean;
govee_min_interval_ms: number;
nanoleaf_paired: boolean;
nanoleaf_min_interval_ms: number;
nanoleaf_per_panel?: boolean;
spi_speed_hz: number;
spi_led_type: string;
chroma_device_type: string;
@@ -12,6 +12,8 @@ export interface MQTTSource {
password_set: boolean;
client_id: string;
base_topic: string;
publish_ha_discovery?: boolean;
discovery_prefix?: string;
connected: boolean;
description?: string;
tags: string[];
+90 -24
View File
@@ -75,6 +75,8 @@
"activity_log.msg.audit_log.disabled": "Activity logging disabled",
"activity_log.msg.automation.activated": "Automation '{name}' activated",
"activity_log.msg.automation.deactivated": "Automation '{name}' deactivated",
"activity_log.msg.automation.webhook_fired": "Webhook for '{name}' fired",
"activity_log.msg.automation.triggered": "Automation '{name}' manually triggered",
"activity_log.msg.server.shutting_down": "Server shutting down",
"activity_log.msg.server.restarting": "Server restart requested",
"activity_log.msg.server.shutdown_requested": "Server shutdown requested",
@@ -96,7 +98,7 @@
"activity_log.entity_type.scene_playlist": "Scene Playlist",
"activity_log.entity_type.sync_clock": "Sync Clock",
"activity_log.entity_type.template": "Template",
"activity_log.entity_type.gradient": "Gradient",
"activity_log.entity_type.gradient": "Palette",
"activity_log.entity_type.cspt": "Processing Template",
"activity_log.entity_type.audio_template": "Audio Template",
"activity_log.entity_type.audio_processing_template": "Audio Processing Template",
@@ -336,6 +338,7 @@
"automation.disabled": "Automation disabled",
"automation.enabled": "Automation enabled",
"automations.action.disable": "Disable",
"automations.action.trigger": "Trigger",
"automations.add": "Add Automation",
"automations.created": "Automation created",
"automations.deactivation_mode": "Deactivation:",
@@ -360,6 +363,7 @@
"automations.error.name_required": "Name is required",
"automations.error.save_failed": "Failed to save automation",
"automations.error.toggle_failed": "Failed to toggle automation",
"automations.error.trigger_failed": "Failed to trigger automation",
"automations.last_activated": "Last activated",
"automations.logic.all": "ALL",
"automations.logic.and": " AND ",
@@ -427,6 +431,9 @@
"automations.rule.http_poll.value": "Value",
"automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.value_source": "HTTP Value Source",
"automations.rule.manual_trigger": "Manual Trigger",
"automations.rule.manual_trigger.desc": "Run from a button",
"automations.rule.manual_trigger.hint": "Activates only when you press the Trigger button on the automation card (the automation's other rules are still checked).",
"automations.rule.mqtt": "MQTT",
"automations.rule.mqtt.desc": "MQTT message",
"automations.rule.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
@@ -447,6 +454,18 @@
"automations.rule.system_idle.when_active.desc": "Fires while the user is actively using the system",
"automations.rule.system_idle.when_idle": "When idle",
"automations.rule.system_idle.when_idle.desc": "Fires once the user has been idle past the timeout",
"automations.rule.solar": "Sun (Sunrise / Sunset)",
"automations.rule.solar.desc": "Relative to sunrise/sunset",
"automations.rule.solar.hint": "Activate during a window relative to sunrise and sunset at your location. The default — sunset to sunrise — covers \"active at night\".",
"automations.rule.solar.start": "Window opens at",
"automations.rule.solar.end": "Window closes at",
"automations.rule.solar.event.sunrise": "Sunrise",
"automations.rule.solar.event.sunset": "Sunset",
"automations.rule.solar.offset": "Offset in minutes (negative = before the event, positive = after)",
"automations.rule.solar.offset.unit": "min",
"automations.rule.solar.latitude": "Latitude",
"automations.rule.solar.longitude": "Longitude",
"automations.rule.solar.location_hint": "Set your latitude and longitude so sunrise and sunset are computed for your location.",
"automations.rule.time_of_day": "Time of Day",
"automations.rule.time_of_day.days": "Active days",
"automations.rule.time_of_day.days_hint": "Leave all unselected for every day. Overnight windows count toward the day they start on.",
@@ -480,11 +499,27 @@
"automations.scene.search_placeholder": "Search scenes...",
"automations.section.action": "Action",
"automations.section.deactivation": "Deactivation",
"automations.section.action": "Webhook",
"automations.action.webhook": "Webhook",
"automations.action.webhook_url": "Webhook URL:",
"automations.action.webhook_url.hint": "Optional. POST to a Discord / IFTTT / Zapier / Node-RED URL when this automation fires. LAN addresses are allowed; loopback and cloud-metadata are blocked. Leave empty for no webhook.",
"automations.action.method": "Method:",
"automations.action.fire_on": "Fire on:",
"automations.action.fire_on.activate": "When activated",
"automations.action.fire_on.deactivate": "When deactivated",
"automations.action.fire_on.both": "Both",
"automations.action.body_template": "Body template:",
"automations.action.body_template.hint": "JSON body for POST/PUT. Tokens: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.",
"automations.error.invalid_webhook_url": "Invalid or blocked webhook URL.",
"automations.section.triggers": "Triggers",
"automations.status.active": "Active",
"automations.status.disabled": "Disabled",
"automations.status.inactive": "Inactive",
"automations.title": "Automations",
"automations.trigger.partial": "Triggered with errors",
"automations.trigger.skipped": "Conditions not met — automation not triggered",
"automations.trigger.tooltip": "Run this automation now (its rules are still checked)",
"automations.triggered": "Automation triggered",
"automations.updated": "Automation updated",
"bg.anim.toggle": "Toggle ambient background",
"bindable.none": "None (static value)",
@@ -524,6 +559,10 @@
"calibration.advanced.switch_to_simple": "Switch to Simple",
"calibration.advanced.title": "Advanced Calibration",
"calibration.border_width": "Border (px):",
"calibration.linear_blend": "Linear-light blending",
"calibration.linear_blend.hint": "Average border pixels in linear light for perceptually correct, brighter colour mixing.",
"calibration.dither": "Dithering",
"calibration.dither.hint": "Spatio-temporal dithering reduces visible banding on smooth gradients.",
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
"calibration.button.cancel": "Cancel",
"calibration.button.save": "Save",
@@ -764,6 +803,14 @@
"color_strip.effect.meteor": "Meteor",
"color_strip.effect.meteor.desc": "Bright head travels along the strip with an exponential-decay tail",
"color_strip.effect.mirror": "Mirror:",
"color_strip.effect.reactive": "Audio reactive:",
"color_strip.effect.reactive.hint": "Modulate this effect's brightness and/or saturation with live audio loudness.",
"color_strip.effect.reactive.source": "Audio source:",
"color_strip.effect.reactive.mode": "Modulate:",
"color_strip.effect.reactive.mode.brightness": "Brightness",
"color_strip.effect.reactive.mode.saturation": "Saturation",
"color_strip.effect.reactive.mode.both": "Both",
"color_strip.effect.reactive.intensity": "Strength:",
"color_strip.effect.mirror.hint": "Bounce mode — the meteor reverses direction at strip ends instead of wrapping.",
"color_strip.effect.noise": "Noise",
"color_strip.effect.noise.desc": "Scrolling fractal value noise mapped to a palette",
@@ -812,8 +859,8 @@
"color_strip.gradient.easing.linear.desc": "Constant-rate blending between stops",
"color_strip.gradient.easing.step": "Step",
"color_strip.gradient.easing.step.desc": "Hard jumps between colors with no blending",
"color_strip.gradient.error.no_gradient": "Please select a gradient",
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops",
"color_strip.gradient.error.no_gradient": "Please select a palette",
"color_strip.gradient.min_stops": "Palette must have at least 2 stops",
"color_strip.gradient.position": "Position (0.01.0)",
"color_strip.gradient.preset": "Preset:",
"color_strip.gradient.preset.apply": "Apply",
@@ -837,8 +884,17 @@
"color_strip.gradient.preset.warm": "Warm",
"color_strip.gradient.preview": "Gradient:",
"color_strip.gradient.preview.hint": "Visual preview. Click the marker track below to add a stop. Drag markers to reposition.",
"color_strip.gradient.select": "Gradient:",
"color_strip.gradient.select.hint": "Select a gradient from the library. Create and edit gradients in the Gradients tab.",
"color_strip.gradient.select": "Palette:",
"color_strip.gradient.select.hint": "Select a palette from the library. Create and edit palettes in the Palettes tab.",
"color_strip.gradient.harmony": "Color harmony:",
"color_strip.gradient.harmony.hint": "Pick a base color, then generate a harmonious set of stops from classic color-theory relationships.",
"color_strip.gradient.harmony.base": "Base color",
"color_strip.gradient.harmony.complementary": "Complementary",
"color_strip.gradient.harmony.analogous": "Analogous",
"color_strip.gradient.harmony.triadic": "Triadic",
"color_strip.gradient.harmony.split_complementary": "Split",
"color_strip.gradient.harmony.tetradic": "Tetradic",
"color_strip.gradient.harmony.monochromatic": "Mono",
"color_strip.gradient.stops": "Color Stops:",
"color_strip.gradient.stops.hint": "Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.",
"color_strip.gradient.stops_count": "stops",
@@ -1321,6 +1377,8 @@
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
"device.hue_gradient_mode": "Map across segments:",
"device.hue_gradient_mode.hint": "Spread the strip across a gradient lightstrip's segments (channels) instead of one averaged colour per light. Auto-detected on connect; plain bulbs are unaffected.",
"device.hue.url": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:",
@@ -1371,7 +1429,7 @@
"device.icon.entity.cspt": "Color-strip processing template",
"device.icon.entity.device": "Device",
"device.icon.entity.game_integration": "Game integration",
"device.icon.entity.gradient": "Gradient",
"device.icon.entity.gradient": "Palette",
"device.icon.entity.ha_light_target": "HA light target",
"device.icon.entity.ha_source": "Home Assistant source",
"device.icon.entity.http_endpoint": "HTTP endpoint",
@@ -1470,6 +1528,8 @@
"device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "Min Update Interval:",
"device.lifx_min_interval.hint": "Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.",
"device.lifx_per_zone": "Per-zone streaming:",
"device.lifx_per_zone.hint": "Address individual zones (Z/Beam multizone) or pixels (Tile/Canvas matrix) instead of one averaged colour. Auto-detected on connect; older bulbs fall back to single colour.",
"device.metrics.actual_fps": "Actual FPS",
"device.metrics.current_fps": "Current FPS",
"device.metrics.device_fps": "Device refresh rate",
@@ -1498,6 +1558,8 @@
"device.nanoleaf.url.placeholder": "192.168.1.50",
"device.nanoleaf_min_interval": "Min Update Interval:",
"device.nanoleaf_min_interval.hint": "Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.",
"device.nanoleaf_per_panel": "Per-panel streaming:",
"device.nanoleaf_per_panel.hint": "Stream each panel individually via extControl UDP instead of one averaged colour. Requires a recent controller firmware.",
"device.opc.url": "IP Address:",
"device.opc.url.hint": "OPC receiver address. TCP port defaults to 7890.",
"device.opc.url.placeholder": "192.168.1.50",
@@ -1806,26 +1868,26 @@
"game_integration.test.timeout": "No events received within timeout period.",
"game_integration.test.waiting": "Waiting for events from game...",
"game_integration.updated": "Game integration updated",
"gradient.add": "Add Gradient",
"gradient.add": "Add Palette",
"gradient.builtin": "Built-in",
"gradient.cloned": "Gradient cloned",
"gradient.confirm_delete": "Delete gradient \"{name}\"?",
"gradient.create_name": "New gradient name:",
"gradient.created": "Gradient created",
"gradient.deleted": "Gradient deleted",
"gradient.cloned": "Palette cloned",
"gradient.confirm_delete": "Delete palette \"{name}\"?",
"gradient.create_name": "New palette name:",
"gradient.created": "Palette created",
"gradient.deleted": "Palette deleted",
"gradient.description": "Description:",
"gradient.description.hint": "Optional description for this gradient.",
"gradient.edit": "Edit Gradient",
"gradient.edit_name": "Rename gradient:",
"gradient.error.delete_failed": "Failed to delete gradient",
"gradient.description.hint": "Optional description for this palette.",
"gradient.edit": "Edit Palette",
"gradient.edit_name": "Rename palette:",
"gradient.error.delete_failed": "Failed to delete palette",
"gradient.error.min_stops": "At least 2 color stops are required",
"gradient.error.name_required": "Name is required",
"gradient.error.save_failed": "Failed to save gradient",
"gradient.group.title": "Gradients",
"gradient.error.save_failed": "Failed to save palette",
"gradient.group.title": "Palettes",
"gradient.name": "Name:",
"gradient.name.hint": "A descriptive name for this gradient.",
"gradient.name.hint": "A descriptive name for this palette.",
"gradient.stops_label": "stops",
"gradient.updated": "Gradient updated",
"gradient.updated": "Palette updated",
"graph.action.connect": "Connect",
"graph.action.disconnect": "Disconnect",
"graph.action.move": "Move node",
@@ -2044,6 +2106,9 @@
"mqtt_source.add": "Add MQTT Source",
"mqtt_source.base_topic": "Base Topic:",
"mqtt_source.base_topic.hint": "Prefix for status and state topics, e.g. ledgrab/status",
"mqtt_source.ha_discovery": "Home Assistant discovery:",
"mqtt_source.ha_discovery.hint": "Publish homeassistant/.../config topics so MQTT-only Home Assistant installs get LedGrab automation + connectivity entities automatically.",
"mqtt_source.discovery_prefix": "Discovery prefix:",
"mqtt_source.broker_host": "Broker Host:",
"mqtt_source.broker_host.hint": "MQTT broker hostname or IP address, e.g. 192.168.1.100",
"mqtt_source.broker_port": "Port:",
@@ -2323,7 +2388,7 @@
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
"section.empty.devices": "No devices yet. Click + to add one.",
"section.empty.game_integrations": "No game integrations yet. Click + to create one.",
"section.empty.gradients": "No gradients yet",
"section.empty.gradients": "No palettes yet",
"section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
"section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.",
"section.empty.http_endpoints": "No HTTP endpoints yet. Click + to add one.",
@@ -2581,7 +2646,7 @@
"settings.section.file": "File",
"settings.section.filtering": "Filtering",
"settings.section.filters": "Filters",
"settings.section.gradient": "Gradient",
"settings.section.gradient": "Palette",
"settings.section.hardware": "Hardware",
"settings.section.history": "History",
"settings.section.identity": "Identity",
@@ -2597,6 +2662,7 @@
"settings.section.notif_permission": "OS Permission",
"settings.section.offsets": "Offsets",
"settings.section.output": "Output",
"settings.section.power": "Power",
"settings.section.preview": "Preview",
"settings.section.protocol": "Protocol",
"settings.section.provider": "Provider",
@@ -2674,7 +2740,7 @@
"streams.group.color_strip": "Color Strips",
"streams.group.css_processing": "Processing Templates",
"streams.group.game": "Game Integration",
"streams.group.gradients": "Gradients",
"streams.group.gradients": "Palettes",
"streams.group.home_assistant": "Home Assistant",
"streams.group.http": "HTTP",
"streams.group.mqtt": "MQTT",
@@ -2970,7 +3036,7 @@
"tour.src.static": "Static Image — test your setup with image files instead of live capture.",
"tour.src.sync": "Sync Clocks — shared timers that synchronize animations across multiple sources.",
"tour.src.templates": "Capture Templates — reusable capture configurations (resolution, FPS, crop).",
"tour.src.value": "Value Sources — dynamic numbers or colors driven by time of day, audio, system metrics, Home Assistant entities, gradients, or schedules. Used to animate effects, modulate color strips, and trigger automations.",
"tour.src.value": "Value Sources — dynamic numbers or colors driven by time of day, audio, system metrics, Home Assistant entities, palettes, or schedules. Used to animate effects, modulate color strips, and trigger automations.",
"tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.",
"tour.tgt.css": "Color Strips — define how screen regions map to LED segments.",
"tour.tgt.devices": "Devices — your LED controllers discovered on the network.",
+78 -9
View File
@@ -40,7 +40,10 @@
"activity_log.filter.since": "С",
"activity_log.filter.title": "Фильтры",
"activity_log.filter.until": "По",
"activity_log.live": "Live",
"activity_log.live": "В эфире",
"z2m_light.bulbs.one": "{count} лампа",
"z2m_light.bulbs.few": "{count} лампы",
"z2m_light.bulbs.many": "{count} ламп",
"activity_log.load_more": "Загрузить ещё",
"activity_log.loading": "Загрузка журнала…",
"activity_log.n_entries": "Записей: {n}",
@@ -75,6 +78,8 @@
"activity_log.msg.audit_log.disabled": "Запись активности отключена",
"activity_log.msg.automation.activated": "Автоматизация '{name}' активирована",
"activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована",
"activity_log.msg.automation.webhook_fired": "Вебхук для '{name}' отправлен",
"activity_log.msg.automation.triggered": "Автоматизация '{name}' запущена вручную",
"activity_log.msg.server.shutting_down": "Сервер выключается",
"activity_log.msg.server.restarting": "Запрошен перезапуск сервера",
"activity_log.msg.server.shutdown_requested": "Запрошено выключение сервера",
@@ -96,7 +101,7 @@
"activity_log.entity_type.scene_playlist": "Плейлист сцен",
"activity_log.entity_type.sync_clock": "Синх-часы",
"activity_log.entity_type.template": "Шаблон",
"activity_log.entity_type.gradient": "Градиент",
"activity_log.entity_type.gradient": "Палитра",
"activity_log.entity_type.cspt": "Шаблон обработки",
"activity_log.entity_type.audio_template": "Аудиошаблон",
"activity_log.entity_type.audio_processing_template": "Шаблон аудиообработки",
@@ -284,8 +289,8 @@
"auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к LED Grab.",
"auth.placeholder": "Введите ваш API ключ...",
"auth.please_login": "Пожалуйста, войдите для просмотра",
"auth.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"auth.prompt_enter": "Введите ваш API-ключ:",
"auth.prompt_update": "API-ключ уже задан. Введите новый ключ для замены или оставьте поле пустым, чтобы удалить:",
"auth.session_expired": "Ваша сессия истекла или API ключ недействителен. Пожалуйста, войдите снова.",
"auth.success": "Вход выполнен успешно!",
"auth.title": "Вход в LED Grab",
@@ -336,6 +341,7 @@
"automation.disabled": "Автоматизация выключена",
"automation.enabled": "Автоматизация включена",
"automations.action.disable": "Отключить",
"automations.action.trigger": "Запустить",
"automations.add": "Добавить автоматизацию",
"automations.created": "Автоматизация создана",
"automations.deactivation_mode": "Деактивация:",
@@ -360,6 +366,7 @@
"automations.error.name_required": "Введите название",
"automations.error.save_failed": "Не удалось сохранить автоматизацию",
"automations.error.toggle_failed": "Не удалось переключить автоматизацию",
"automations.error.trigger_failed": "Не удалось запустить автоматизацию",
"automations.last_activated": "Последняя активация",
"automations.logic.all": "ВСЕ",
"automations.logic.and": " И ",
@@ -417,6 +424,9 @@
"automations.rule.http_poll.value": "Значение",
"automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.value_source": "HTTP источник-значения",
"automations.rule.manual_trigger": "Ручной запуск",
"automations.rule.manual_trigger.desc": "Запуск по кнопке",
"automations.rule.manual_trigger.hint": "Активируется только при нажатии кнопки «Запустить» на карточке автоматизации (остальные правила автоматизации по-прежнему проверяются).",
"automations.rule.mqtt": "MQTT",
"automations.rule.mqtt.desc": "MQTT сообщение",
"automations.rule.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
@@ -437,6 +447,18 @@
"automations.rule.system_idle.when_active.desc": "Срабатывает, пока пользователь активно работает с системой",
"automations.rule.system_idle.when_idle": "При бездействии",
"automations.rule.system_idle.when_idle.desc": "Срабатывает, когда пользователь не активен дольше тайм-аута",
"automations.rule.solar": "Солнце (восход / закат)",
"automations.rule.solar.desc": "Относительно восхода/заката",
"automations.rule.solar.hint": "Активируется в окне относительно восхода и заката в вашем расположении. По умолчанию — от заката до восхода — это режим «активно ночью».",
"automations.rule.solar.start": "Окно открывается в",
"automations.rule.solar.end": "Окно закрывается в",
"automations.rule.solar.event.sunrise": "Восход",
"automations.rule.solar.event.sunset": "Закат",
"automations.rule.solar.offset": "Смещение в минутах (отрицательное — до события, положительное — после)",
"automations.rule.solar.offset.unit": "мин",
"automations.rule.solar.latitude": "Широта",
"automations.rule.solar.longitude": "Долгота",
"automations.rule.solar.location_hint": "Укажите широту и долготу, чтобы восход и закат вычислялись для вашего расположения.",
"automations.rule.time_of_day": "Время суток",
"automations.rule.time_of_day.days": "Активные дни",
"automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.",
@@ -470,11 +492,27 @@
"automations.scene.search_placeholder": "Поиск сцен...",
"automations.section.action": "Действие",
"automations.section.deactivation": "Деактивация",
"automations.section.action": "Вебхук",
"automations.action.webhook": "Вебхук",
"automations.action.webhook_url": "URL вебхука:",
"automations.action.webhook_url.hint": "Необязательно. Отправлять POST-запрос на URL Discord / IFTTT / Zapier / Node-RED при срабатывании автоматизации. Локальные (LAN) адреса разрешены; loopback и метаданные облака заблокированы. Оставьте пустым, чтобы отключить вебхук.",
"automations.action.method": "Метод:",
"automations.action.fire_on": "Срабатывать при:",
"automations.action.fire_on.activate": "При активации",
"automations.action.fire_on.deactivate": "При деактивации",
"automations.action.fire_on.both": "В обоих случаях",
"automations.action.body_template": "Шаблон тела:",
"automations.action.body_template.hint": "Тело JSON для POST/PUT. Токены: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.",
"automations.error.invalid_webhook_url": "Недопустимый или заблокированный URL вебхука.",
"automations.section.triggers": "Триггеры",
"automations.status.active": "Активна",
"automations.status.disabled": "Отключена",
"automations.status.inactive": "Неактивна",
"automations.title": "Автоматизации",
"automations.trigger.partial": "Запущено с ошибками",
"automations.trigger.skipped": "Условия не выполнены — автоматизация не запущена",
"automations.trigger.tooltip": "Запустить эту автоматизацию сейчас (правила по-прежнему проверяются)",
"automations.triggered": "Автоматизация запущена",
"automations.updated": "Автоматизация обновлена",
"bg.anim.toggle": "Анимированный фон",
"bindable.none": "Нет (статическое значение)",
@@ -516,6 +554,10 @@
"calibration.advanced.switch_to_simple": "Простой режим",
"calibration.advanced.title": "Расширенная калибровка",
"calibration.border_width": "Граница (px):",
"calibration.linear_blend": "Смешивание в линейном свете",
"calibration.linear_blend.hint": "Усреднять пиксели границы в линейном свете для перцептивно корректного, более яркого смешивания цветов.",
"calibration.dither": "Дизеринг",
"calibration.dither.hint": "Пространственно-временной дизеринг уменьшает заметные полосы на плавных градиентах.",
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
"calibration.button.cancel": "Отмена",
"calibration.button.save": "Сохранить",
@@ -728,6 +770,14 @@
"color_strip.effect.meteor": "Метеор",
"color_strip.effect.meteor.desc": "Яркая точка движется по ленте с экспоненциально затухающим хвостом",
"color_strip.effect.mirror": "Отражение:",
"color_strip.effect.reactive": "Реакция на звук:",
"color_strip.effect.reactive.hint": "Модулировать яркость и/или насыщенность этого эффекта по громкости звука в реальном времени.",
"color_strip.effect.reactive.source": "Источник звука:",
"color_strip.effect.reactive.mode": "Модуляция:",
"color_strip.effect.reactive.mode.brightness": "Яркость",
"color_strip.effect.reactive.mode.saturation": "Насыщенность",
"color_strip.effect.reactive.mode.both": "Обе",
"color_strip.effect.reactive.intensity": "Сила:",
"color_strip.effect.mirror.hint": "Режим отскока — метеор меняет направление у краёв ленты вместо переноса.",
"color_strip.effect.noise": "Шум",
"color_strip.effect.noise.desc": "Прокручиваемый фрактальный шум, отображённый на палитру",
@@ -760,7 +810,7 @@
"color_strip.gamma.hint": "Гамма-коррекция (1=без коррекции, \u003c1=ярче средние тона, \u003e1=темнее средние тона)",
"color_strip.gradient.add_stop": "+ Добавить",
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок",
"color_strip.gradient.min_stops": "Палитра должна содержать не менее 2 остановок",
"color_strip.gradient.position": "Позиция (0.01.0)",
"color_strip.gradient.preset": "Пресет:",
"color_strip.gradient.preset.apply": "Применить",
@@ -784,6 +834,15 @@
"color_strip.gradient.preset.warm": "Тёплый",
"color_strip.gradient.preview": "Градиент:",
"color_strip.gradient.preview.hint": "Предпросмотр градиента. Нажмите на дорожку маркеров чтобы добавить остановку. Перетащите маркеры для изменения позиции.",
"color_strip.gradient.harmony": "Цветовая гармония:",
"color_strip.gradient.harmony.hint": "Выберите базовый цвет, затем создайте гармоничный набор точек на основе классических цветовых сочетаний.",
"color_strip.gradient.harmony.base": "Базовый цвет",
"color_strip.gradient.harmony.complementary": "Контрастная",
"color_strip.gradient.harmony.analogous": "Аналоговая",
"color_strip.gradient.harmony.triadic": "Триада",
"color_strip.gradient.harmony.split_complementary": "Разделённая",
"color_strip.gradient.harmony.tetradic": "Тетрада",
"color_strip.gradient.harmony.monochromatic": "Моно",
"color_strip.gradient.stops": "Цветовые остановки:",
"color_strip.gradient.stops.hint": "Каждая остановка задаёт цвет в относительной позиции (0.0 = начало, 1.0 = конец). Кнопка ↔ добавляет цвет справа для создания резкого перехода.",
"color_strip.gradient.stops_count": "остановок",
@@ -1240,6 +1299,11 @@
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
"device.hue_gradient_mode": "Распределить по сегментам:",
"device.hue_gradient_mode.hint": "Распределить ленту по сегментам (каналам) градиентной ленты вместо одного усреднённого цвета на лампу. Определяется автоматически при подключении; обычные лампы не затрагиваются.",
"mqtt_source.ha_discovery": "Обнаружение Home Assistant:",
"mqtt_source.ha_discovery.hint": "Публиковать топики homeassistant/.../config, чтобы установки Home Assistant только с MQTT автоматически получали сущности автоматизаций и подключения LedGrab.",
"mqtt_source.discovery_prefix": "Префикс обнаружения:",
"device.hue.url": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:",
@@ -1290,7 +1354,7 @@
"device.icon.entity.cspt": "Шаблон обработки полоски",
"device.icon.entity.device": "Устройство",
"device.icon.entity.game_integration": "Игровая интеграция",
"device.icon.entity.gradient": "Градиент",
"device.icon.entity.gradient": "Палитра",
"device.icon.entity.ha_light_target": "HA-светильник",
"device.icon.entity.ha_source": "Источник Home Assistant",
"device.icon.entity.http_endpoint": "HTTP-эндпоинт",
@@ -1389,6 +1453,8 @@
"device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "Мин. интервал обновления:",
"device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.",
"device.lifx_per_zone": "Потоковая передача по зонам:",
"device.lifx_per_zone.hint": "Адресовать отдельные зоны (мультизона Z/Beam) или пиксели (матрица Tile/Canvas) вместо одного усреднённого цвета. Определяется автоматически при подключении; старые лампы переходят на одиночный цвет.",
"device.metrics.actual_fps": "Факт. FPS",
"device.metrics.current_fps": "Текущ. FPS",
"device.metrics.device_fps": "Частота обновления устройства",
@@ -1417,6 +1483,8 @@
"device.nanoleaf.url.placeholder": "192.168.1.50",
"device.nanoleaf_min_interval": "Мин. интервал обновления:",
"device.nanoleaf_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 100 мс ≈ 10 Гц; накладные расходы HTTP ограничивают практический максимум ~20 Гц.",
"device.nanoleaf_per_panel": "Потоковая передача по панелям:",
"device.nanoleaf_per_panel.hint": "Передавать цвет каждой панели отдельно через extControl UDP вместо одного усреднённого цвета. Требуется недавняя прошивка контроллера.",
"device.opc.url": "IP-адрес:",
"device.opc.url.hint": "Адрес приёмника OPC. TCP-порт по умолчанию 7890.",
"device.opc.url.placeholder": "192.168.1.50",
@@ -1706,8 +1774,8 @@
"game_integration.test.timeout": "События не получены за отведённое время.",
"game_integration.test.waiting": "Ожидание событий от игры...",
"game_integration.updated": "Игровая интеграция обновлена",
"gradient.error.delete_failed": "Не удалось удалить градиент",
"gradient.error.save_failed": "Не удалось сохранить градиент",
"gradient.error.delete_failed": "Не удалось удалить палитру",
"gradient.error.save_failed": "Не удалось сохранить палитру",
"graph.action.connect": "Соединить",
"graph.action.disconnect": "Отсоединить",
"graph.action.move": "Переместить узел",
@@ -2403,7 +2471,7 @@
"settings.section.file": "Файл",
"settings.section.filtering": "Фильтрация",
"settings.section.filters": "Фильтры",
"settings.section.gradient": "Градиент",
"settings.section.gradient": "Палитра",
"settings.section.hardware": "Оборудование",
"settings.section.history": "История",
"settings.section.identity": "Идентификация",
@@ -2419,6 +2487,7 @@
"settings.section.notif_permission": "Разрешение ОС",
"settings.section.offsets": "Смещения",
"settings.section.output": "Вывод",
"settings.section.power": "Питание",
"settings.section.preview": "Превью",
"settings.section.protocol": "Протокол",
"settings.section.provider": "Провайдер",
+76 -8
View File
@@ -41,6 +41,8 @@
"activity_log.filter.title": "过滤",
"activity_log.filter.until": "至",
"activity_log.live": "实时",
"z2m_light.bulbs.one": "{count} 个灯泡",
"z2m_light.bulbs.other": "{count} 个灯泡",
"activity_log.load_more": "加载更多",
"activity_log.loading": "正在加载活动日志…",
"activity_log.n_entries": "{n} 条记录",
@@ -75,6 +77,8 @@
"activity_log.msg.audit_log.disabled": "活动记录已禁用",
"activity_log.msg.automation.activated": "自动化 '{name}' 已激活",
"activity_log.msg.automation.deactivated": "自动化 '{name}' 已停用",
"activity_log.msg.automation.webhook_fired": "已为 '{name}' 发送 webhook",
"activity_log.msg.automation.triggered": "已手动触发自动化 '{name}'",
"activity_log.msg.server.shutting_down": "服务器正在关闭",
"activity_log.msg.server.restarting": "已请求服务器重启",
"activity_log.msg.server.shutdown_requested": "已请求服务器关闭",
@@ -96,7 +100,7 @@
"activity_log.entity_type.scene_playlist": "场景播放列表",
"activity_log.entity_type.sync_clock": "同步时钟",
"activity_log.entity_type.template": "模板",
"activity_log.entity_type.gradient": "渐变",
"activity_log.entity_type.gradient": "调色板",
"activity_log.entity_type.cspt": "处理模板",
"activity_log.entity_type.audio_template": "音频模板",
"activity_log.entity_type.audio_processing_template": "音频处理模板",
@@ -284,8 +288,8 @@
"auth.message": "请输入 API 密钥以进行身份验证并访问 LED Grab。",
"auth.placeholder": "输入您的 API 密钥...",
"auth.please_login": "请先登录",
"auth.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"auth.prompt_enter": "请输入您的 API 密钥:",
"auth.prompt_update": "已设置 API 密钥。输入新密钥以更新,或留空以移除:",
"auth.session_expired": "会话已过期或 API 密钥无效,请重新登录。",
"auth.success": "登录成功!",
"auth.title": "登录 LED Grab",
@@ -336,6 +340,7 @@
"automation.disabled": "自动化已禁用",
"automation.enabled": "自动化已启用",
"automations.action.disable": "禁用",
"automations.action.trigger": "触发",
"automations.add": "添加自动化",
"automations.created": "自动化已创建",
"automations.deactivation_mode": "停用方式:",
@@ -360,6 +365,7 @@
"automations.error.name_required": "名称为必填项",
"automations.error.save_failed": "保存自动化失败",
"automations.error.toggle_failed": "切换自动化失败",
"automations.error.trigger_failed": "触发自动化失败",
"automations.last_activated": "上次激活",
"automations.logic.all": "全部",
"automations.logic.and": " 与 ",
@@ -417,6 +423,9 @@
"automations.rule.http_poll.value": "值",
"automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.value_source": "HTTP 值源",
"automations.rule.manual_trigger": "手动触发",
"automations.rule.manual_trigger.desc": "通过按钮运行",
"automations.rule.manual_trigger.hint": "仅当您点击自动化卡片上的“触发”按钮时激活(仍会检查该自动化的其他规则)。",
"automations.rule.mqtt": "MQTT",
"automations.rule.mqtt.desc": "MQTT 消息",
"automations.rule.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
@@ -437,6 +446,18 @@
"automations.rule.system_idle.when_active.desc": "当用户正在使用系统时触发",
"automations.rule.system_idle.when_idle": "空闲时",
"automations.rule.system_idle.when_idle.desc": "当用户空闲时间超过超时阈值后触发",
"automations.rule.solar": "太阳(日出 / 日落)",
"automations.rule.solar.desc": "相对于日出/日落",
"automations.rule.solar.hint": "在所在位置的日出和日落相关的时间窗口内激活。默认从日落到日出,即「夜间生效」。",
"automations.rule.solar.start": "窗口开始于",
"automations.rule.solar.end": "窗口结束于",
"automations.rule.solar.event.sunrise": "日出",
"automations.rule.solar.event.sunset": "日落",
"automations.rule.solar.offset": "偏移分钟数(负值=事件之前,正值=事件之后)",
"automations.rule.solar.offset.unit": "分钟",
"automations.rule.solar.latitude": "纬度",
"automations.rule.solar.longitude": "经度",
"automations.rule.solar.location_hint": "请设置纬度和经度,以便按所在位置计算日出和日落。",
"automations.rule.time_of_day": "时段",
"automations.rule.time_of_day.days": "生效日期",
"automations.rule.time_of_day.days_hint": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。",
@@ -470,11 +491,27 @@
"automations.scene.search_placeholder": "搜索场景...",
"automations.section.action": "动作",
"automations.section.deactivation": "停用",
"automations.section.action": "Webhook",
"automations.action.webhook": "Webhook",
"automations.action.webhook_url": "Webhook URL",
"automations.action.webhook_url.hint": "可选。当此自动化触发时向 Discord / IFTTT / Zapier / Node-RED 的 URL 发送 POST。允许局域网地址;回环和云元数据被阻止。留空则不发送 webhook。",
"automations.action.method": "方法:",
"automations.action.fire_on": "触发时机:",
"automations.action.fire_on.activate": "激活时",
"automations.action.fire_on.deactivate": "停用时",
"automations.action.fire_on.both": "两者",
"automations.action.body_template": "正文模板:",
"automations.action.body_template.hint": "POST/PUT 的 JSON 正文。令牌:{{automation_name}}、{{automation_id}}、{{event}}、{{timestamp}}。",
"automations.error.invalid_webhook_url": "无效或被阻止的 webhook URL。",
"automations.section.triggers": "触发器",
"automations.status.active": "活动",
"automations.status.disabled": "已禁用",
"automations.status.inactive": "非活动",
"automations.title": "自动化",
"automations.trigger.partial": "触发时出现错误",
"automations.trigger.skipped": "条件不满足 — 未触发自动化",
"automations.trigger.tooltip": "立即运行此自动化(仍会检查其规则)",
"automations.triggered": "已触发自动化",
"automations.updated": "自动化已更新",
"bg.anim.toggle": "切换动态背景",
"bindable.none": "无(静态值)",
@@ -514,6 +551,10 @@
"calibration.advanced.switch_to_simple": "切换到简单模式",
"calibration.advanced.title": "高级校准",
"calibration.border_width": "边框(像素):",
"calibration.linear_blend": "线性光混合",
"calibration.linear_blend.hint": "在线性光空间中对边框像素求平均,以获得感知正确、更明亮的颜色混合。",
"calibration.dither": "抖动",
"calibration.dither.hint": "时空抖动可减少平滑渐变上可见的色带。",
"calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100",
"calibration.button.cancel": "取消",
"calibration.button.save": "保存",
@@ -726,6 +767,14 @@
"color_strip.effect.meteor": "流星",
"color_strip.effect.meteor.desc": "明亮头部沿灯带移动,带指数衰减的尾迹",
"color_strip.effect.mirror": "镜像:",
"color_strip.effect.reactive": "音频反应:",
"color_strip.effect.reactive.hint": "根据实时音频响度调节此效果的亮度和/或饱和度。",
"color_strip.effect.reactive.source": "音频源:",
"color_strip.effect.reactive.mode": "调节:",
"color_strip.effect.reactive.mode.brightness": "亮度",
"color_strip.effect.reactive.mode.saturation": "饱和度",
"color_strip.effect.reactive.mode.both": "两者",
"color_strip.effect.reactive.intensity": "强度:",
"color_strip.effect.mirror.hint": "反弹模式 — 流星在灯带末端反转方向而不是循环。",
"color_strip.effect.noise": "噪声",
"color_strip.effect.noise.desc": "滚动的分形值噪声映射到调色板",
@@ -758,7 +807,7 @@
"color_strip.gamma.hint": "伽马校正(1=无,\u003c1=更亮的中间调,\u003e1=更暗的中间调)",
"color_strip.gradient.add_stop": "+ 添加色标",
"color_strip.gradient.bidir.hint": "在此色标右侧添加第二种颜色以在渐变中创建硬边。",
"color_strip.gradient.min_stops": "渐变至少需要 2 个色标",
"color_strip.gradient.min_stops": "调色板至少需要 2 个色标",
"color_strip.gradient.position": "位置(0.0-1.0",
"color_strip.gradient.preset": "预设:",
"color_strip.gradient.preset.apply": "应用",
@@ -782,6 +831,15 @@
"color_strip.gradient.preset.warm": "暖色",
"color_strip.gradient.preview": "渐变:",
"color_strip.gradient.preview.hint": "可视预览。点击下方标记轨道添加色标。拖动标记重新定位。",
"color_strip.gradient.harmony": "配色和谐:",
"color_strip.gradient.harmony.hint": "选择一个基色,然后根据经典配色关系生成一组和谐的色标。",
"color_strip.gradient.harmony.base": "基色",
"color_strip.gradient.harmony.complementary": "互补色",
"color_strip.gradient.harmony.analogous": "邻近色",
"color_strip.gradient.harmony.triadic": "三色",
"color_strip.gradient.harmony.split_complementary": "分裂互补",
"color_strip.gradient.harmony.tetradic": "四色",
"color_strip.gradient.harmony.monochromatic": "单色",
"color_strip.gradient.stops": "色标:",
"color_strip.gradient.stops.hint": "每个色标在相对位置定义一种颜色(0.0 = 起始,1.0 = 结束)。↔ 按钮添加右侧颜色以在该色标处创建硬边。",
"color_strip.gradient.stops_count": "个色标",
@@ -1238,6 +1296,11 @@
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
"device.hue_gradient_mode": "跨分段映射:",
"device.hue_gradient_mode.hint": "将灯带分布到渐变灯带的各分段(通道),而不是每个灯具一个平均色。连接时自动检测;普通灯泡不受影响。",
"mqtt_source.ha_discovery": "Home Assistant 发现:",
"mqtt_source.ha_discovery.hint": "发布 homeassistant/.../config 主题,使仅使用 MQTT 的 Home Assistant 安装自动获得 LedGrab 的自动化和连接实体。",
"mqtt_source.discovery_prefix": "发现前缀:",
"device.hue.url": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:",
@@ -1288,7 +1351,7 @@
"device.icon.entity.cspt": "色带处理模板",
"device.icon.entity.device": "设备",
"device.icon.entity.game_integration": "游戏集成",
"device.icon.entity.gradient": "渐变",
"device.icon.entity.gradient": "调色板",
"device.icon.entity.ha_light_target": "HA 灯目标",
"device.icon.entity.ha_source": "Home Assistant 源",
"device.icon.entity.http_endpoint": "HTTP 端点",
@@ -1387,6 +1450,8 @@
"device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "最小更新间隔:",
"device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。",
"device.lifx_per_zone": "逐区流式传输:",
"device.lifx_per_zone.hint": "单独寻址各个分区(Z/Beam 多区)或像素(Tile/Canvas 矩阵),而不是单一平均色。连接时自动检测;较旧的灯具回退为单色。",
"device.metrics.actual_fps": "实际 FPS",
"device.metrics.current_fps": "当前 FPS",
"device.metrics.device_fps": "设备刷新率",
@@ -1415,6 +1480,8 @@
"device.nanoleaf.url.placeholder": "192.168.1.50",
"device.nanoleaf_min_interval": "最小更新间隔:",
"device.nanoleaf_min_interval.hint": "客户端命令速率限制(毫秒)。默认 100 毫秒 ≈ 10 Hz;HTTP 请求开销将实际上限限制在约 20 Hz。",
"device.nanoleaf_per_panel": "逐面板流式传输:",
"device.nanoleaf_per_panel.hint": "通过 extControl UDP 单独传输每个面板的颜色,而不是单一平均色。需要较新的控制器固件。",
"device.opc.url": "IP 地址:",
"device.opc.url.hint": "OPC 接收器地址。TCP 端口默认为 7890。",
"device.opc.url.placeholder": "192.168.1.50",
@@ -1704,8 +1771,8 @@
"game_integration.test.timeout": "在超时期间内未收到事件。",
"game_integration.test.waiting": "等待游戏事件...",
"game_integration.updated": "游戏集成已更新",
"gradient.error.delete_failed": "删除渐变失败",
"gradient.error.save_failed": "保存渐变失败",
"gradient.error.delete_failed": "删除调色板失败",
"gradient.error.save_failed": "保存调色板失败",
"graph.action.connect": "连接",
"graph.action.disconnect": "断开连接",
"graph.action.move": "移动节点",
@@ -2397,7 +2464,7 @@
"settings.section.file": "文件",
"settings.section.filtering": "过滤",
"settings.section.filters": "过滤器",
"settings.section.gradient": "渐变",
"settings.section.gradient": "调色板",
"settings.section.hardware": "硬件",
"settings.section.history": "历史",
"settings.section.identity": "标识",
@@ -2413,6 +2480,7 @@
"settings.section.notif_permission": "系统权限",
"settings.section.offsets": "偏移",
"settings.section.output": "输出",
"settings.section.power": "电源",
"settings.section.preview": "预览",
"settings.section.protocol": "协议",
"settings.section.provider": "提供商",
@@ -20,7 +20,7 @@ Design notes
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
from typing import Iterator
from ledgrab.storage.activity_log import ActivityLogEntry, ActivityLogFilters
@@ -32,6 +32,21 @@ logger = get_logger(__name__)
_TABLE = "activity_log"
def _to_utc_iso(dt: datetime) -> str:
"""Serialise *dt* to a canonical UTC ISO-8601 string for ``ts`` comparison.
Stored ``ts`` values are always written UTC-aware (``+00:00``). An incoming
filter datetime may be naive (e.g. a ``datetime-local`` value with no
offset interpreted here as UTC) or carry a non-UTC offset. Both must be
normalised to UTC so the lexicographic ``ts >= ?`` / ``ts <= ?`` string
comparison SQLite performs on the TEXT column is chronologically correct;
otherwise boundary rows and offset-shifted rows are mis-classified.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat()
def _build_filter_clause(
filters: ActivityLogFilters,
params: list,
@@ -77,11 +92,11 @@ def _build_filter_clause(
if filters.since is not None:
conditions.append("ts >= ?")
params.append(filters.since.isoformat())
params.append(_to_utc_iso(filters.since))
if filters.until is not None:
conditions.append("ts <= ?")
params.append(filters.until.isoformat())
params.append(_to_utc_iso(filters.until))
if filters.message_like is not None:
# Escape LIKE special characters in the user-supplied substring so that
@@ -149,6 +164,42 @@ class ActivityLogRepository:
# -- Read ----------------------------------------------------------------
def query_with_seq(
self,
filters: ActivityLogFilters,
*,
before_seq: int | None = None,
limit: int = 50,
) -> list[tuple[int, ActivityLogEntry]]:
"""Like :meth:`query` but returns ``(seq, entry)`` tuples.
Lets the list endpoint read the keyset cursor (``next_before_seq``)
straight from the oldest in-page row instead of issuing a second
``get_seq_for_id`` round-trip the ``seq`` is already fetched here.
"""
params: list = []
keyset = "seq < ?" if before_seq is not None else None
if before_seq is not None:
params.append(before_seq)
where_fragment = _build_filter_clause(filters, params, extra_where=keyset)
where_clause = f"WHERE {where_fragment}" if where_fragment else ""
params.append(limit)
sql = (
f"SELECT seq, id, ts, category, action, severity, actor, "
f"entity_type, entity_id, entity_name, message, metadata "
f"FROM {_TABLE} "
f"{where_clause} "
f"ORDER BY seq DESC "
f"LIMIT ?"
)
cursor = self._db.execute(sql, tuple(params))
rows = cursor.fetchall()
# Reverse to return chronological order within the page
return [(int(row["seq"]), ActivityLogEntry.from_row(dict(row))) for row in reversed(rows)]
def query(
self,
filters: ActivityLogFilters,
@@ -173,28 +224,10 @@ class ActivityLogRepository:
limit:
Maximum number of entries to return.
"""
params: list = []
keyset = "seq < ?" if before_seq is not None else None
if before_seq is not None:
params.append(before_seq)
where_fragment = _build_filter_clause(filters, params, extra_where=keyset)
where_clause = f"WHERE {where_fragment}" if where_fragment else ""
params.append(limit)
sql = (
f"SELECT seq, id, ts, category, action, severity, actor, "
f"entity_type, entity_id, entity_name, message, metadata "
f"FROM {_TABLE} "
f"{where_clause} "
f"ORDER BY seq DESC "
f"LIMIT ?"
)
cursor = self._db.execute(sql, tuple(params))
rows = cursor.fetchall()
# Reverse to return chronological order within the page
return [ActivityLogEntry.from_row(dict(row)) for row in reversed(rows)]
return [
entry
for _seq, entry in self.query_with_seq(filters, before_seq=before_seq, limit=limit)
]
def count(self, filters: ActivityLogFilters | None = None) -> int:
"""Return the number of entries matching *filters* (or all entries)."""
@@ -233,7 +266,7 @@ class ActivityLogRepository:
if before_ts is not None:
cursor = self._db.execute(
f"DELETE FROM {_TABLE} WHERE ts < ?",
(before_ts.isoformat(),),
(_to_utc_iso(before_ts),),
)
deleted += cursor.rowcount
@@ -321,8 +354,7 @@ class ActivityLogRepository:
)
# Hold the lock only for the bounded fetchall; release before yielding.
with self._db._lock: # noqa: SLF001 — internal access; no public cursor API
rows = self._db._conn.execute(sql, tuple(params)).fetchall() # noqa: SLF001
rows = self._db.fetch_under_lock(sql, tuple(params))
if not rows:
break
+182
View File
@@ -102,6 +102,80 @@ class TimeOfDayRule(Rule):
)
@dataclass
class SolarRule(Rule):
"""Activate during a window defined relative to sunrise / sunset.
The window runs from ``start_event`` (+``start_offset_minutes``) to
``end_event`` (+``end_offset_minutes``), where each event is either
``"sunrise"`` or ``"sunset"`` for the rule's location and the current day.
The default sunset sunrise is the common "active at night" case.
Offsets may be negative (before the event) or positive (after), clamped to
±1439 minutes. ``latitude``/``longitude`` are per-rule (mirrors the
daylight value source); ``timezone`` is an IANA name (empty = server local,
same as :class:`TimeOfDayRule`). ``days_of_week`` (0=Mon..6=Sun, empty =
every day) restricts which days the window is active, with the same
overnight attribution as ``TimeOfDayRule`` (a wrapped early-morning tail
belongs to the day the window started on).
"""
rule_type: str = "solar"
start_event: str = "sunset" # "sunrise" | "sunset"
start_offset_minutes: int = 0
end_event: str = "sunrise" # "sunrise" | "sunset"
end_offset_minutes: int = 0
latitude: float = 50.0
longitude: float = 0.0
days_of_week: List[int] = field(default_factory=list) # 0=Mon..6=Sun; empty=all days
timezone: str = "" # IANA tz name; empty = server local time
def to_dict(self) -> dict:
d = super().to_dict()
d["start_event"] = self.start_event
d["start_offset_minutes"] = self.start_offset_minutes
d["end_event"] = self.end_event
d["end_offset_minutes"] = self.end_offset_minutes
d["latitude"] = self.latitude
d["longitude"] = self.longitude
d["days_of_week"] = self.days_of_week
d["timezone"] = self.timezone
return d
@classmethod
def from_dict(cls, data: dict) -> "SolarRule":
def _event(key: str, default: str) -> str:
v = data.get(key, default)
return v if v in ("sunrise", "sunset") else default
def _offset(key: str) -> int:
try:
return max(-1439, min(1439, int(data.get(key, 0))))
except (TypeError, ValueError):
return 0
def _coord(key: str, lo: float, hi: float, default: float) -> float:
try:
return max(lo, min(hi, float(data.get(key, default))))
except (TypeError, ValueError):
return default
raw_days = data.get("days_of_week") or []
days = sorted(
{int(d) for d in raw_days if isinstance(d, (int, float)) and 0 <= int(d) <= 6}
)
return cls(
start_event=_event("start_event", "sunset"),
start_offset_minutes=_offset("start_offset_minutes"),
end_event=_event("end_event", "sunrise"),
end_offset_minutes=_offset("end_offset_minutes"),
latitude=_coord("latitude", -90.0, 90.0, 50.0),
longitude=_coord("longitude", -180.0, 180.0, 0.0),
days_of_week=days,
timezone=data.get("timezone", "") or "",
)
@dataclass
class SystemIdleRule(Rule):
"""Activate based on system idle time (keyboard/mouse inactivity)."""
@@ -199,6 +273,23 @@ class StartupRule(Rule):
return cls()
@dataclass
class ManualTriggerRule(Rule):
"""Activate via an explicit manual trigger (UI "Trigger" button / API call).
Zero-config, like ``StartupRule``. It evaluates to True only while an
automation is being manually fired (see ``AutomationEngine.fire_manual_trigger``);
during the background evaluation tick it always reads False, so a
manual-trigger automation never activates on its own.
"""
rule_type: str = "manual_trigger"
@classmethod
def from_dict(cls, data: dict) -> "ManualTriggerRule":
return cls()
@dataclass
class HomeAssistantRule(Rule):
"""Activate based on a Home Assistant entity state."""
@@ -273,11 +364,13 @@ class HTTPPollRule(Rule):
_RULE_MAP: Dict[str, Type[Rule]] = {
"application": ApplicationRule,
"time_of_day": TimeOfDayRule,
"solar": SolarRule,
"system_idle": SystemIdleRule,
"display_state": DisplayStateRule,
"mqtt": MQTTRule,
"webhook": WebhookRule,
"startup": StartupRule,
"manual_trigger": ManualTriggerRule,
"home_assistant": HomeAssistantRule,
"http_poll": HTTPPollRule,
# Legacy: "always" maps to StartupRule for migration
@@ -285,6 +378,81 @@ _RULE_MAP: Dict[str, Type[Rule]] = {
}
# ── Actions ──────────────────────────────────────────────────────────────
# Rules decide WHEN an automation is active; actions are extra side effects
# fired alongside scene activation (e.g. an outbound webhook to Discord /
# IFTTT / Zapier / Node-RED). Polymorphic via the same registry pattern as
# Rule, so new action types can be added without touching the engine wiring.
@dataclass
class Action:
"""Base action — polymorphic via the ``action_type`` discriminator."""
action_type: str
def to_dict(self) -> dict:
return {"action_type": self.action_type}
@classmethod
def from_dict(cls, data: dict) -> "Action":
at = data.get("action_type", "")
subcls = _ACTION_MAP.get(at)
if subcls is None:
raise ValueError(f"Unknown action type: {at}")
return subcls.from_dict(data)
@dataclass
class WebhookAction(Action):
"""POST/PUT/GET an outbound HTTP request when the automation fires.
``fire_on`` selects which transition triggers the call: ``"activate"``,
``"deactivate"``, or ``"both"``. ``body_template`` supports the tokens
``{{automation_name}}``, ``{{automation_id}}``, ``{{event}}`` and
``{{timestamp}}``, substituted server-side at fire time. The URL is
SSRF-gated (LAN allowed, loopback / cloud-metadata / link-local blocked)
at both save and fire time.
"""
action_type: str = "webhook"
webhook_url: str = ""
method: str = "POST" # POST | PUT | GET
body_template: str = ""
content_type: str = "application/json"
fire_on: str = "activate" # activate | deactivate | both
def to_dict(self) -> dict:
d = super().to_dict()
d["webhook_url"] = self.webhook_url
d["method"] = self.method
d["body_template"] = self.body_template
d["content_type"] = self.content_type
d["fire_on"] = self.fire_on
return d
@classmethod
def from_dict(cls, data: dict) -> "WebhookAction":
method = str(data.get("method", "POST")).upper()
if method not in ("POST", "PUT", "GET"):
method = "POST"
fire_on = data.get("fire_on", "activate")
if fire_on not in ("activate", "deactivate", "both"):
fire_on = "activate"
return cls(
webhook_url=data.get("webhook_url", ""),
method=method,
body_template=data.get("body_template", ""),
content_type=data.get("content_type", "") or "application/json",
fire_on=fire_on,
)
_ACTION_MAP: Dict[str, Type[Action]] = {
"webhook": WebhookAction,
}
# ── Backward-compatible aliases (for imports in other modules during transition) ──
Condition = Rule
ApplicationCondition = ApplicationRule
@@ -313,6 +481,8 @@ class Automation:
created_at: datetime
updated_at: datetime
tags: List[str] = field(default_factory=list)
# Outbound side-effects fired alongside scene activation/deactivation.
actions: List[Action] = field(default_factory=list)
# Custom card icon (frontend display only)
icon: str = ""
icon_color: str = ""
@@ -348,6 +518,10 @@ class Automation:
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
# Only persist actions when present — keeps existing rows byte-identical
# and tolerates loading automations saved before actions existed.
if self.actions:
d["actions"] = [a.to_dict() for a in self.actions]
if self.icon:
d["icon"] = self.icon
if self.icon_color:
@@ -370,6 +544,13 @@ class Automation:
except ValueError as e:
logger.warning("Skipping unknown rule type on load: %s", e)
actions: List[Action] = []
for a_data in data.get("actions") or []:
try:
actions.append(Action.from_dict(a_data))
except ValueError as e:
logger.warning("Skipping unknown action type on load: %s", e)
return cls(
id=data["id"],
name=data["name"],
@@ -380,6 +561,7 @@ class Automation:
deactivation_mode=data.get("deactivation_mode", "none"),
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
tags=data.get("tags", []),
actions=actions,
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat(
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime, timezone
from typing import List
from ledgrab.storage.automation import Automation, Rule
from ledgrab.storage.automation import Action, Automation, Rule
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
@@ -34,6 +34,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
deactivation_mode: str = "none",
deactivation_scene_preset_id: str | None = None,
tags: List[str] | None = None,
actions: List[Action] | None = None,
icon: str | None = None,
icon_color: str | None = None,
# Legacy parameter aliases
@@ -65,6 +66,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
created_at=now,
updated_at=now,
tags=tags or [],
actions=actions or [],
icon=icon or "",
icon_color=icon_color or "",
)
@@ -85,6 +87,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
deactivation_mode: str | None = None,
deactivation_scene_preset_id: str = "__unset__",
tags: List[str] | None = None,
actions: List[Action] | None = None,
icon: str | None = None,
icon_color: str | None = None,
# Legacy parameter aliases
@@ -118,6 +121,8 @@ class AutomationStore(BaseSqliteStore[Automation]):
)
if tags is not None:
automation.tags = tags
if actions is not None:
automation.actions = actions
if icon is not None:
automation.icon = icon or ""
if icon_color is not None:
@@ -544,6 +544,12 @@ class EffectColorStripSource(ColorStripSource):
scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
mirror: bool = False # bounce mode (meteor/comet)
custom_palette: list | None = None # legacy [[pos, R, G, B], ...] custom palette stops
# Audio-reactive modulation: live loudness from a referenced AudioSource
# modulates the rendered output (brightness and/or saturation).
audio_reactive: bool = False
reactive_audio_source_id: str = "" # references an AudioSource for loudness
reactive_mode: str = "brightness" # brightness | saturation | both
reactive_intensity: BindableFloat = field(default_factory=lambda: BindableFloat(0.7))
def to_dict(self) -> dict:
d = super().to_dict()
@@ -555,6 +561,10 @@ class EffectColorStripSource(ColorStripSource):
d["scale"] = self.scale.to_dict()
d["mirror"] = self.mirror
d["custom_palette"] = self.custom_palette
d["audio_reactive"] = self.audio_reactive
d["reactive_audio_source_id"] = self.reactive_audio_source_id
d["reactive_mode"] = self.reactive_mode
d["reactive_intensity"] = self.reactive_intensity.to_dict()
return d
@classmethod
@@ -571,6 +581,10 @@ class EffectColorStripSource(ColorStripSource):
scale=BindableFloat.from_raw(data.get("scale"), default=1.0),
mirror=bool(data.get("mirror", False)),
custom_palette=data.get("custom_palette"),
audio_reactive=bool(data.get("audio_reactive", False)),
reactive_audio_source_id=data.get("reactive_audio_source_id") or "",
reactive_mode=data.get("reactive_mode") or "brightness",
reactive_intensity=BindableFloat.from_raw(data.get("reactive_intensity"), default=0.7),
)
@classmethod
@@ -593,6 +607,10 @@ class EffectColorStripSource(ColorStripSource):
scale=None,
mirror=False,
custom_palette=None,
audio_reactive=False,
reactive_audio_source_id="",
reactive_mode="brightness",
reactive_intensity=None,
**_kwargs,
):
return cls(
@@ -612,6 +630,10 @@ class EffectColorStripSource(ColorStripSource):
scale=BindableFloat.from_raw(scale, default=1.0),
mirror=bool(mirror),
custom_palette=custom_palette if isinstance(custom_palette, list) else None,
audio_reactive=bool(audio_reactive),
reactive_audio_source_id=reactive_audio_source_id or "",
reactive_mode=reactive_mode or "brightness",
reactive_intensity=BindableFloat.from_raw(reactive_intensity, default=0.7),
)
def apply_update(self, **kwargs) -> None:
@@ -633,6 +655,16 @@ class EffectColorStripSource(ColorStripSource):
if "custom_palette" in kwargs:
cp = kwargs["custom_palette"]
self.custom_palette = cp if isinstance(cp, list) else None
if kwargs.get("audio_reactive") is not None:
self.audio_reactive = bool(kwargs["audio_reactive"])
if "reactive_audio_source_id" in kwargs:
self.reactive_audio_source_id = kwargs["reactive_audio_source_id"] or ""
if kwargs.get("reactive_mode") is not None:
self.reactive_mode = kwargs["reactive_mode"]
if kwargs.get("reactive_intensity") is not None:
self.reactive_intensity = self.reactive_intensity.apply_update(
kwargs["reactive_intensity"]
)
@dataclass
+13
View File
@@ -197,6 +197,19 @@ class Database:
self._conn.executemany(sql, params_list)
self._conn.commit()
def fetch_under_lock(self, sql: str, params: Tuple = ()) -> List[sqlite3.Row]:
"""Run a read-only query holding the lock only for this call (no commit).
Used by streaming exporters that must release the lock between batches
instead of holding it across the whole stream. Guards against a closed
connection so a post-close call surfaces a clear ``RuntimeError`` rather
than an ``AttributeError`` on ``None``.
"""
with self._lock:
if self._conn is None:
raise RuntimeError("database is closed")
return self._conn.execute(sql, params).fetchall()
@contextmanager
def transaction(self):
"""Context manager for multi-statement transactions.
@@ -81,12 +81,14 @@ class Device:
hue_username: str = "",
hue_client_key: str = "",
hue_entertainment_group_id: str = "",
hue_gradient_mode: bool = True,
# Yeelight fields
yeelight_min_interval_ms: int = 500,
# WiZ fields
wiz_min_interval_ms: int = 50,
# LIFX fields
lifx_min_interval_ms: int = 50,
lifx_per_zone: bool = False,
# Govee fields
govee_min_interval_ms: int = 50,
# OPC fields
@@ -94,6 +96,7 @@ class Device:
# Nanoleaf fields
nanoleaf_token: str = "",
nanoleaf_min_interval_ms: int = 100,
nanoleaf_per_panel: bool = False,
# SPI Direct fields
spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B",
@@ -141,13 +144,16 @@ class Device:
self.hue_username = hue_username
self.hue_client_key = hue_client_key
self.hue_entertainment_group_id = hue_entertainment_group_id
self.hue_gradient_mode = hue_gradient_mode
self.yeelight_min_interval_ms = yeelight_min_interval_ms
self.wiz_min_interval_ms = wiz_min_interval_ms
self.lifx_min_interval_ms = lifx_min_interval_ms
self.lifx_per_zone = lifx_per_zone
self.govee_min_interval_ms = govee_min_interval_ms
self.opc_channel = opc_channel
self.nanoleaf_token = nanoleaf_token
self.nanoleaf_min_interval_ms = nanoleaf_min_interval_ms
self.nanoleaf_per_panel = nanoleaf_per_panel
self.spi_speed_hz = spi_speed_hz
self.spi_led_type = spi_led_type
self.chroma_device_type = chroma_device_type
@@ -239,6 +245,7 @@ class Device:
hue_username=self.hue_username,
hue_client_key=self.hue_client_key,
hue_entertainment_group_id=self.hue_entertainment_group_id,
hue_gradient_mode=self.hue_gradient_mode,
)
if dt == "yeelight":
return YeelightConfig(
@@ -254,6 +261,7 @@ class Device:
return LIFXConfig(
**base,
lifx_min_interval_ms=self.lifx_min_interval_ms,
lifx_per_zone=self.lifx_per_zone,
)
if dt == "govee":
return GoveeConfig(
@@ -270,6 +278,7 @@ class Device:
**base,
nanoleaf_token=self.nanoleaf_token,
nanoleaf_min_interval_ms=self.nanoleaf_min_interval_ms,
nanoleaf_per_panel=self.nanoleaf_per_panel,
)
if dt == "spi":
return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
@@ -349,12 +358,17 @@ class Device:
d["hue_client_key"] = _enc(self.hue_client_key)
if self.hue_entertainment_group_id:
d["hue_entertainment_group_id"] = self.hue_entertainment_group_id
# Gradient mode defaults ON — only persist the opt-out.
if not self.hue_gradient_mode:
d["hue_gradient_mode"] = False
if self.yeelight_min_interval_ms != 500:
d["yeelight_min_interval_ms"] = self.yeelight_min_interval_ms
if self.wiz_min_interval_ms != 50:
d["wiz_min_interval_ms"] = self.wiz_min_interval_ms
if self.lifx_min_interval_ms != 50:
d["lifx_min_interval_ms"] = self.lifx_min_interval_ms
if self.lifx_per_zone:
d["lifx_per_zone"] = True
if self.govee_min_interval_ms != 50:
d["govee_min_interval_ms"] = self.govee_min_interval_ms
if self.opc_channel:
@@ -364,6 +378,8 @@ class Device:
d["nanoleaf_token"] = _enc(self.nanoleaf_token)
if self.nanoleaf_min_interval_ms != 100:
d["nanoleaf_min_interval_ms"] = self.nanoleaf_min_interval_ms
if self.nanoleaf_per_panel:
d["nanoleaf_per_panel"] = True
if self.spi_speed_hz != 800000:
d["spi_speed_hz"] = self.spi_speed_hz
if self.spi_led_type != "WS2812B":
@@ -419,13 +435,16 @@ class Device:
hue_username=_dec(data.get("hue_username", "")),
hue_client_key=_dec(data.get("hue_client_key", "")),
hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""),
hue_gradient_mode=bool(data.get("hue_gradient_mode", True)),
yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500),
wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50),
lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50),
lifx_per_zone=bool(data.get("lifx_per_zone", False)),
govee_min_interval_ms=data.get("govee_min_interval_ms", 50),
opc_channel=data.get("opc_channel", 0),
nanoleaf_token=_dec(data.get("nanoleaf_token", "")),
nanoleaf_min_interval_ms=data.get("nanoleaf_min_interval_ms", 100),
nanoleaf_per_panel=bool(data.get("nanoleaf_per_panel", False)),
spi_speed_hz=data.get("spi_speed_hz", 800000),
spi_led_type=data.get("spi_led_type", "WS2812B"),
chroma_device_type=data.get("chroma_device_type", "chromalink"),
@@ -473,13 +492,16 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
"hue_username",
"hue_client_key",
"hue_entertainment_group_id",
"hue_gradient_mode",
"yeelight_min_interval_ms",
"wiz_min_interval_ms",
"lifx_min_interval_ms",
"lifx_per_zone",
"govee_min_interval_ms",
"opc_channel",
"nanoleaf_token",
"nanoleaf_min_interval_ms",
"nanoleaf_per_panel",
"spi_speed_hz",
"spi_led_type",
"chroma_device_type",
@@ -580,13 +602,16 @@ class DeviceStore(BaseSqliteStore[Device]):
hue_username: str = "",
hue_client_key: str = "",
hue_entertainment_group_id: str = "",
hue_gradient_mode: bool = True,
yeelight_min_interval_ms: int = 500,
wiz_min_interval_ms: int = 50,
lifx_min_interval_ms: int = 50,
lifx_per_zone: bool = False,
govee_min_interval_ms: int = 50,
opc_channel: int = 0,
nanoleaf_token: str = "",
nanoleaf_min_interval_ms: int = 100,
nanoleaf_per_panel: bool = False,
spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B",
chroma_device_type: str = "chromalink",
@@ -630,13 +655,16 @@ class DeviceStore(BaseSqliteStore[Device]):
hue_username=hue_username,
hue_client_key=hue_client_key,
hue_entertainment_group_id=hue_entertainment_group_id,
hue_gradient_mode=hue_gradient_mode,
yeelight_min_interval_ms=yeelight_min_interval_ms,
wiz_min_interval_ms=wiz_min_interval_ms,
lifx_min_interval_ms=lifx_min_interval_ms,
lifx_per_zone=lifx_per_zone,
govee_min_interval_ms=govee_min_interval_ms,
opc_channel=opc_channel,
nanoleaf_token=nanoleaf_token,
nanoleaf_min_interval_ms=nanoleaf_min_interval_ms,
nanoleaf_per_panel=nanoleaf_per_panel,
spi_speed_hz=spi_speed_hz,
spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type,
+18 -4
View File
@@ -10,7 +10,9 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, List
from ledgrab.utils import secret_box
from ledgrab.utils import get_logger, secret_box
logger = get_logger(__name__)
# Keys inside ``adapter_config`` that hold secrets and must be encrypted at
# rest (and decrypted on load). Mirrors the secret_box pattern used by
@@ -37,8 +39,14 @@ def _decrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
Migration-safe: ``secret_box.decrypt`` returns the input unchanged when it
is not an encryption envelope, so legacy plaintext rows still load. On a
corrupt/undecryptable envelope, fall back to dropping the value rather than
crashing the whole store load.
corrupt/undecryptable envelope (e.g. ``data/.secret_key`` was lost/rotated,
or the DB was restored to a machine without the key), PRESERVE the original
encrypted envelope rather than discarding it. Discarding it would let a
later write-through save overwrite the recoverable ciphertext with an empty
string, permanently destroying the secret (silent data loss). Keeping the
envelope means the token is unreadable now but recoverable once the correct
key is restored. A still-encrypted value never matches a real token, so the
adapter simply rejects requests until the secret is recovered/re-entered.
"""
result = dict(adapter_config)
for key in _SECRET_CONFIG_KEYS:
@@ -48,7 +56,13 @@ def _decrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
try:
result[key] = secret_box.decrypt(value)
except Exception:
result[key] = ""
logger.warning(
"Could not decrypt %r for a game integration (secret key "
"missing/rotated?); preserving the encrypted value. Restore "
"data/.secret_key or re-enter the secret to recover.",
key,
)
# Leave result[key] = value (the intact envelope) untouched.
# else: legacy plaintext — leave as-is (migration-safe read path).
return result
+32 -7
View File
@@ -14,7 +14,9 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Dict, List
from ledgrab.utils import secret_box
from ledgrab.utils import get_logger, secret_box
logger = get_logger(__name__)
def _parse_common(data: dict) -> dict:
@@ -58,19 +60,36 @@ class HTTPEndpoint:
icon_color: str = ""
def __post_init__(self) -> None:
# Invariant: ``self.auth_token`` is always plaintext at runtime.
# If a caller constructed this from a raw dict that still holds the
# encrypted envelope, decrypt now so ``build_request_headers``
# Invariant: ``self.auth_token`` is plaintext at runtime when the key is
# available. If a caller constructed this from a raw dict that still
# holds the encrypted envelope, decrypt now so ``build_request_headers``
# doesn't accidentally send ``Authorization: Bearer <envelope>``.
# On decrypt failure (secret key missing/rotated) PRESERVE the envelope
# rather than blanking it — blanking would let a later write-through
# save overwrite the recoverable ciphertext with "" (silent data loss).
# A still-encrypted token is treated as absent at request-build time.
if self.auth_token and secret_box.is_encrypted(self.auth_token):
try:
self.auth_token = secret_box.decrypt(self.auth_token)
except Exception:
self.auth_token = ""
logger.warning(
"Could not decrypt HTTP-endpoint auth_token (secret key "
"missing/rotated?); preserving the encrypted value. Restore "
"data/.secret_key or re-enter the token to recover."
)
@property
def plaintext_token(self) -> str:
return secret_box.decrypt(self.auth_token) if self.auth_token else ""
if not self.auth_token:
return ""
# An undecryptable envelope (key lost) must not be sent or surfaced as
# a token — treat it as absent.
if secret_box.is_encrypted(self.auth_token):
try:
return secret_box.decrypt(self.auth_token)
except Exception:
return ""
return self.auth_token
def build_request_headers(self) -> Dict[str, str]:
"""Compose the headers actually sent on a fetch.
@@ -82,7 +101,13 @@ class HTTPEndpoint:
"""
result: Dict[str, str] = dict(self.headers)
already_has_auth = any(k.lower() == "authorization" for k in result)
if self.auth_token and not already_has_auth:
# Never send a still-encrypted envelope as a bearer token (happens only
# when the secret key is missing and decryption failed in __post_init__).
if (
self.auth_token
and not already_has_auth
and not secret_box.is_encrypted(self.auth_token)
):
result["Authorization"] = f"Bearer {self.auth_token}"
return result
@@ -48,6 +48,10 @@ class MQTTSource:
password: str = ""
client_id: str = "ledgrab"
base_topic: str = "ledgrab"
# Home Assistant MQTT auto-discovery: publish homeassistant/.../config so
# MQTT-only HA installs get LedGrab entities automatically.
publish_ha_discovery: bool = False
discovery_prefix: str = "homeassistant"
description: str | None = None
tags: List[str] = field(default_factory=list)
icon: str = ""
@@ -66,6 +70,8 @@ class MQTTSource:
"password": stored_password,
"client_id": self.client_id,
"base_topic": self.base_topic,
"publish_ha_discovery": self.publish_ha_discovery,
"discovery_prefix": self.discovery_prefix,
"description": self.description,
"tags": self.tags,
"created_at": self.created_at.isoformat(),
@@ -93,6 +99,8 @@ class MQTTSource:
password=password,
client_id=data.get("client_id", "ledgrab"),
base_topic=data.get("base_topic", "ledgrab"),
publish_ha_discovery=bool(data.get("publish_ha_discovery", False)),
discovery_prefix=data.get("discovery_prefix", "homeassistant") or "homeassistant",
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
)
@@ -70,6 +70,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password: str = "",
client_id: str = "ledgrab",
base_topic: str = "ledgrab",
publish_ha_discovery: bool = False,
discovery_prefix: str = "homeassistant",
description: str | None = None,
tags: List[str] | None = None,
icon: str | None = None,
@@ -94,6 +96,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password=password,
client_id=client_id,
base_topic=base_topic,
publish_ha_discovery=publish_ha_discovery,
discovery_prefix=discovery_prefix or "homeassistant",
description=description,
tags=tags or [],
icon=icon or "",
@@ -115,6 +119,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password: str | None = None,
client_id: str | None = None,
base_topic: str | None = None,
publish_ha_discovery: bool | None = None,
discovery_prefix: str | None = None,
description: str | None = None,
tags: List[str] | None = None,
icon: str | None = None,
@@ -136,6 +142,14 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password=password if password is not None else existing.password,
client_id=client_id if client_id is not None else existing.client_id,
base_topic=base_topic if base_topic is not None else existing.base_topic,
publish_ha_discovery=(
publish_ha_discovery
if publish_ha_discovery is not None
else existing.publish_ha_discovery
),
discovery_prefix=(
discovery_prefix if discovery_prefix is not None else existing.discovery_prefix
),
description=description if description is not None else existing.description,
tags=tags if tags is not None else existing.tags,
icon=icon if icon is not None else existing.icon,
@@ -265,6 +265,17 @@
<small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small>
<input type="number" id="device-lifx-min-interval" min="0" max="10000" step="10" value="50">
</div>
<div class="form-group" id="device-lifx-per-zone-group" style="display: none;">
<div class="label-row">
<label for="device-lifx-per-zone" data-i18n="device.lifx_per_zone">Per-zone streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.lifx_per_zone.hint">Address individual zones (Z/Beam multizone) or pixels (Tile/Canvas matrix) instead of one averaged colour. Auto-detected on connect; older bulbs fall back to single colour.</small>
<label class="settings-toggle">
<input type="checkbox" id="device-lifx-per-zone">
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- Govee fields -->
<div class="form-group" id="device-govee-min-interval-group" style="display: none;">
<div class="label-row">
@@ -283,6 +294,17 @@
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_min_interval.hint">Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.</small>
<input type="number" id="device-nanoleaf-min-interval" min="0" max="10000" step="10" value="100">
</div>
<div class="form-group" id="device-nanoleaf-per-panel-group" style="display: none;">
<div class="label-row">
<label for="device-nanoleaf-per-panel" data-i18n="device.nanoleaf_per_panel">Per-panel streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_per_panel.hint">Stream each panel individually via extControl UDP instead of one averaged colour. Requires a recent controller firmware.</small>
<label class="settings-toggle">
<input type="checkbox" id="device-nanoleaf-per-panel">
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- ESP-NOW fields -->
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
<div class="label-row">
@@ -325,6 +347,17 @@
<small class="input-hint" style="display:none" data-i18n="device.hue.group_id.hint">Entertainment configuration ID from your Hue bridge</small>
<input type="text" id="device-hue-group-id" placeholder="Entertainment group ID">
</div>
<div class="form-group" id="device-hue-gradient-mode-group" style="display: none;">
<div class="label-row">
<label for="device-hue-gradient-mode" data-i18n="device.hue_gradient_mode">Map across segments:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.hue_gradient_mode.hint">Spread the strip across a gradient lightstrip's segments (channels) instead of one averaged colour per light. Auto-detected on connect; plain bulbs are unaffected.</small>
<label class="settings-toggle">
<input type="checkbox" id="device-hue-gradient-mode" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- BLE LED Controller fields -->
<div class="form-group" id="device-ble-family-group" style="display: none;">
<div class="label-row">
@@ -132,6 +132,48 @@
</div>
</section>
<section class="ds-section" data-ds-key="action" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="automations.section.action">Webhook</span>
<span class="ds-section-index" aria-hidden="true">05</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="automation-action-webhook-url" data-i18n="automations.action.webhook_url">Webhook URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.action.webhook_url.hint">Optional. POST to a Discord / IFTTT / Zapier / Node-RED URL when this automation fires. LAN addresses are allowed; loopback and cloud-metadata are blocked. Leave empty for no webhook.</small>
<input type="text" id="automation-action-webhook-url" placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="form-group" id="automation-action-fields" style="display:none">
<div class="label-row">
<label for="automation-action-method" data-i18n="automations.action.method">Method:</label>
</div>
<select id="automation-action-method">
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="GET">GET</option>
</select>
<div class="label-row" style="margin-top:0.5rem">
<label for="automation-action-fire-on" data-i18n="automations.action.fire_on">Fire on:</label>
</div>
<select id="automation-action-fire-on">
<option value="activate" data-i18n="automations.action.fire_on.activate">When activated</option>
<option value="deactivate" data-i18n="automations.action.fire_on.deactivate">When deactivated</option>
<option value="both" data-i18n="automations.action.fire_on.both">Both</option>
</select>
<div class="label-row" style="margin-top:0.5rem">
<label for="automation-action-body" data-i18n="automations.action.body_template">Body template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.action.body_template.hint">JSON body for POST/PUT. Tokens: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.</small>
<textarea id="automation-action-body" rows="3" placeholder='{"content": "LedGrab: {{automation_name}} {{event}}"}'></textarea>
</div>
</div>
</section>
<div id="automation-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
@@ -194,6 +194,22 @@
<input type="number" id="cal-roi-height" min="1" max="100" value="100">
</div>
</div>
<div class="calibration-linear-row">
<label class="settings-toggle">
<input type="checkbox" id="cal-linear-blend">
<span class="settings-toggle-slider"></span>
</label>
<label for="cal-linear-blend" data-i18n="calibration.linear_blend">Linear-light blending</label>
<small class="input-hint" data-i18n="calibration.linear_blend.hint">Average border pixels in linear light for perceptually correct, brighter colour mixing.</small>
</div>
<div class="calibration-linear-row">
<label class="settings-toggle">
<input type="checkbox" id="cal-dither">
<span class="settings-toggle-slider"></span>
</label>
<label for="cal-dither" data-i18n="calibration.dither">Dithering</label>
<small class="input-hint" data-i18n="calibration.dither.hint">Spatio-temporal dithering reduces visible banding on smooth gradients.</small>
</div>
</div>
</div>
</section>
@@ -114,10 +114,10 @@
<div id="css-editor-gradient-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-gradient-preset" data-i18n="color_strip.gradient.select">Gradient:</label>
<label for="css-editor-gradient-preset" data-i18n="color_strip.gradient.select">Palette:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.select.hint">Select a gradient from the library. Create and edit gradients in the Gradients tab.</small>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.select.hint">Select a palette from the library. Create and edit palettes in the Palettes tab.</small>
<select id="css-editor-gradient-preset">
</select>
</div>
@@ -214,6 +214,37 @@
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- Audio reactivity -->
<div class="form-group">
<div class="label-row">
<label for="css-editor-effect-audio-reactive" data-i18n="color_strip.effect.reactive">Audio reactive:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.reactive.hint">Modulate this effect's brightness and/or saturation with live audio loudness.</small>
<label class="settings-toggle">
<input type="checkbox" id="css-editor-effect-audio-reactive" onchange="onEffectReactiveToggle()">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div id="css-editor-effect-reactive-group" style="display:none">
<div class="form-group">
<label for="css-editor-effect-reactive-source" data-i18n="color_strip.effect.reactive.source">Audio source:</label>
<select id="css-editor-effect-reactive-source"></select>
</div>
<div class="form-group">
<label for="css-editor-effect-reactive-mode" data-i18n="color_strip.effect.reactive.mode">Modulate:</label>
<select id="css-editor-effect-reactive-mode">
<option value="brightness" data-i18n="color_strip.effect.reactive.mode.brightness">Brightness</option>
<option value="saturation" data-i18n="color_strip.effect.reactive.mode.saturation">Saturation</option>
<option value="both" data-i18n="color_strip.effect.reactive.mode.both">Both</option>
</select>
</div>
<div class="form-group">
<label for="css-editor-effect-reactive-intensity" data-i18n="color_strip.effect.reactive.intensity">Strength:</label>
<input type="range" id="css-editor-effect-reactive-intensity" min="0" max="1" step="0.05" value="0.7">
</div>
</div>
</div>
<!-- Composite-specific fields -->
@@ -294,6 +294,17 @@
<small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small>
<input type="number" id="settings-lifx-min-interval" min="0" max="10000" step="10" value="50">
</div>
<div class="form-group" id="settings-lifx-per-zone-group" style="display: none;">
<div class="label-row">
<label for="settings-lifx-per-zone" data-i18n="device.lifx_per_zone">Per-zone streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.lifx_per_zone.hint">Address individual zones (Z/Beam multizone) or pixels (Tile/Canvas matrix) instead of one averaged colour. Auto-detected on connect; older bulbs fall back to single colour.</small>
<label class="settings-toggle">
<input type="checkbox" id="settings-lifx-per-zone">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group" id="settings-govee-min-interval-group" style="display: none;">
<div class="label-row">
@@ -312,6 +323,17 @@
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_min_interval.hint">Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.</small>
<input type="number" id="settings-nanoleaf-min-interval" min="0" max="10000" step="10" value="100">
</div>
<div class="form-group" id="settings-nanoleaf-per-panel-group" style="display: none;">
<div class="label-row">
<label for="settings-nanoleaf-per-panel" data-i18n="device.nanoleaf_per_panel">Per-panel streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_per_panel.hint">Stream each panel individually via extControl UDP instead of one averaged colour. Requires a recent controller firmware.</small>
<label class="settings-toggle">
<input type="checkbox" id="settings-nanoleaf-per-panel">
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- Read-only pairing indicator. We never show the
token value itself (it's encrypted at rest and
@@ -2,7 +2,7 @@
<div id="gradient-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gradient-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="gradient-editor-title" data-i18n="gradient.add">Add Gradient</h2>
<h2 id="gradient-editor-title" data-i18n="gradient.add">Add Palette</h2>
<button class="modal-close-btn" onclick="closeGradientEditor()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
@@ -22,7 +22,7 @@
<label for="gradient-editor-name" data-i18n="gradient.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="gradient.name.hint">A descriptive name for this gradient.</small>
<small class="input-hint" style="display:none" data-i18n="gradient.name.hint">A descriptive name for this palette.</small>
<input type="text" id="gradient-editor-name" required>
<div id="gradient-editor-tags-container"></div>
</div>
@@ -32,7 +32,7 @@
<label for="gradient-editor-description" data-i18n="gradient.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="gradient.description.hint">Optional description for this gradient.</small>
<small class="input-hint" style="display:none" data-i18n="gradient.description.hint">Optional description for this palette.</small>
<input type="text" id="gradient-editor-description" maxlength="500">
</div>
</div>
@@ -42,7 +42,7 @@
<section class="ds-section" data-ds-key="gradient" data-ch="magenta">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.gradient">Gradient</span>
<span class="ds-section-title" data-i18n="settings.section.gradient">Palette</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
@@ -58,6 +58,25 @@
</div>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.gradient.harmony">Color harmony:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.harmony.hint">Pick a base color, then generate a harmonious set of stops from classic color-theory relationships.</small>
<div class="gradient-harmony-row">
<input type="color" id="ge-gradient-harmony-base" value="#3366ff" data-i18n-title="color_strip.gradient.harmony.base" title="Base color">
<div id="ge-gradient-harmony-types" class="gradient-harmony-types">
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="complementary" data-i18n="color_strip.gradient.harmony.complementary">Complementary</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="analogous" data-i18n="color_strip.gradient.harmony.analogous">Analogous</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="triadic" data-i18n="color_strip.gradient.harmony.triadic">Triadic</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="split_complementary" data-i18n="color_strip.gradient.harmony.split_complementary">Split</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="tetradic" data-i18n="color_strip.gradient.harmony.tetradic">Tetradic</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="monochromatic" data-i18n="color_strip.gradient.harmony.monochromatic">Mono</button>
</div>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.gradient.stops">Color Stops:</label>
@@ -107,6 +107,24 @@
<small class="input-hint" style="display:none" data-i18n="mqtt_source.base_topic.hint">Prefix for status and state topics, e.g. ledgrab/status</small>
<input type="text" id="mqtt-source-base-topic" value="ledgrab" placeholder="ledgrab">
</div>
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-ha-discovery" data-i18n="mqtt_source.ha_discovery">Home Assistant discovery:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.ha_discovery.hint">Publish homeassistant/.../config topics so MQTT-only Home Assistant installs get LedGrab automation + connectivity entities automatically.</small>
<label class="settings-toggle">
<input type="checkbox" id="mqtt-source-ha-discovery">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group" id="mqtt-source-discovery-prefix-group" style="display:none">
<div class="label-row">
<label for="mqtt-source-discovery-prefix" data-i18n="mqtt_source.discovery_prefix">Discovery prefix:</label>
</div>
<input type="text" id="mqtt-source-discovery-prefix" value="homeassistant" placeholder="homeassistant">
</div>
</div>
</section>
@@ -570,7 +570,7 @@
<label for="al-settings-max-days" data-i18n="settings.activity_log.max_days.label">Max age (days)</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.activity_log.max_days.hint">Entries older than this many days are pruned automatically. Set to 0 to disable age-based pruning (keep forever, up to the entry limit).</small>
<small id="al-settings-max-days-hint" class="input-hint" style="display:none" data-i18n="settings.activity_log.max_days.hint">Entries older than this many days are pruned automatically. Set to 0 to disable age-based pruning (keep forever, up to the entry limit).</small>
<input type="number" id="al-settings-max-days" min="0" max="3650" value="365" aria-describedby="al-settings-max-days-hint">
</div>
<div class="form-group">
@@ -578,7 +578,7 @@
<label for="al-settings-max-entries" data-i18n="settings.activity_log.max_entries.label">Max entries</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.activity_log.max_entries.hint">Maximum number of entries to keep. When this limit is reached, the oldest entries are pruned. Set to 0 for no limit.</small>
<small id="al-settings-max-entries-hint" class="input-hint" style="display:none" data-i18n="settings.activity_log.max_entries.hint">Maximum number of entries to keep. When this limit is reached, the oldest entries are pruned. Set to 0 for no limit.</small>
<input type="number" id="al-settings-max-entries" min="0" max="10000000" value="100000" aria-describedby="al-settings-max-entries-hint">
</div>
</div>
@@ -3,8 +3,10 @@
Identity (signal) — name + tags
Routing (cyan) — device + color strip source
Output (amber) — brightness + FPS + advanced (collapsible)
Power (coral) — ABL current budget + per-LED draw
The `<details>` advanced collapse is preserved inside the Output
section. All inner element IDs preserved. -->
section; the power/ABL controls live in their own Power section.
All inner element IDs preserved. -->
<div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title">
<div class="modal-content">
<div class="modal-header">
@@ -139,27 +141,37 @@
<small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
</div>
<div class="form-group" id="target-editor-power-limit-group">
<div class="label-row">
<label for="target-editor-max-milliamps" data-i18n="targets.power_limit">Max current (ABL):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.power_limit.hint">Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.</small>
<div class="label-row">
<input type="number" id="target-editor-max-milliamps" min="0" max="200000" step="100" value="0">
<span data-i18n="targets.power_limit.ma_suffix">mA (0 = unlimited)</span>
</div>
<div class="label-row">
<label for="target-editor-ma-per-led" data-i18n="targets.power_limit.per_led">mA per LED (full white):</label>
<input type="number" id="target-editor-ma-per-led" min="1" max="200" step="1" value="55">
</div>
</div>
</div>
</details>
</div>
</section>
<!-- ── 04 · POWER ──────────────────────────────────── -->
<section class="ds-section" data-ds-key="power" data-ch="coral">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.power">Power</span>
<span class="ds-section-index" aria-hidden="true">04</span>
</div>
<div class="ds-section-body">
<div class="form-group" id="target-editor-power-limit-group">
<div class="label-row">
<label for="target-editor-max-milliamps" data-i18n="targets.power_limit">Max current (ABL):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.power_limit.hint">Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.</small>
<div class="label-row">
<input type="number" id="target-editor-max-milliamps" min="0" max="200000" step="100" value="0">
<span data-i18n="targets.power_limit.ma_suffix">mA (0 = unlimited)</span>
</div>
<div class="label-row">
<label for="target-editor-ma-per-led" data-i18n="targets.power_limit.per_led">mA per LED (full white):</label>
<input type="number" id="target-editor-ma-per-led" min="1" max="200" step="1" value="55">
</div>
</div>
</div>
</section>
<div id="target-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
+39
View File
@@ -0,0 +1,39 @@
"""Spatio-temporal ordered dithering for the final 8-bit quantization.
A smooth gradient quantized to 8 bits per channel bands: adjacent LEDs that
should differ by less than one code round to the same value. Dithering adds a
sub-code threshold that varies per-LED (space) and per-frame (time) before the
floor, so the eye time-averages the strip to a higher effective bit depth
instead of seeing hard steps.
The threshold uses an additive-recurrence (R2 low-discrepancy) sequence: the
per-LED and per-frame phases are irrational multipliers, so the values are
equidistributed in [0, 1) which means ``floor(value + threshold)`` averages
back to ``value`` over time (E[floor(v + U)] = v for the fractional part).
"""
from __future__ import annotations
import numpy as np
# Irrational phase increments (plastic-number / R2 constants) — equidistributed.
_G_LED = 0.7548776662466927
_G_FRAME = 0.5698402909980532
def ordered_dither_quantize(
values: np.ndarray, frame_index: int, led_offset: int = 0
) -> np.ndarray:
"""Quantize float sRGB values in [0, 255] to uint8 with spatio-temporal dither.
``values`` is an ``(N, 3)`` float array. The same per-LED threshold is
applied to all three channels (so hue is preserved); it shifts each frame
via ``frame_index``. ``led_offset`` keeps the spatial phase continuous when
a strip is mapped one edge/segment at a time.
"""
n = values.shape[0]
idx = np.arange(led_offset, led_offset + n, dtype=np.float64)
thr = np.mod(idx * _G_LED + frame_index * _G_FRAME, 1.0).astype(np.float32)[:, None]
out = np.floor(values + thr)
np.clip(out, 0, 255, out=out)
return out.astype(np.uint8)
+48
View File
@@ -0,0 +1,48 @@
"""sRGB ↔ linear-light conversion helpers (LUT-accelerated).
Averaging/blending colours in gamma-encoded sRGB space is perceptually wrong:
the mean of two sRGB values is darker and less saturated than the physically
correct mean of their light intensities. These helpers let the per-LED
reduction blend in linear light and convert back to sRGB for the wire.
Decode is a 256-entry lookup (exact sRGB EOTF); encode is the analytic inverse
applied to the small per-LED result array, so the hot path stays cheap.
"""
from __future__ import annotations
import numpy as np
def _build_srgb_to_linear_lut() -> np.ndarray:
s = np.arange(256, dtype=np.float64) / 255.0
linear = np.where(s <= 0.04045, s / 12.92, ((s + 0.055) / 1.055) ** 2.4)
return linear.astype(np.float32)
# uint8 sRGB index → linear [0, 1] (float32)
SRGB_TO_LINEAR_LUT = _build_srgb_to_linear_lut()
def srgb_to_linear(arr_uint8: np.ndarray) -> np.ndarray:
"""Map a uint8 sRGB array to float32 linear-light in [0, 1] via LUT.
Output shares the input shape; the conversion is per-channel.
"""
return SRGB_TO_LINEAR_LUT[arr_uint8]
def linear_to_srgb_float(linear: np.ndarray) -> np.ndarray:
"""Map float32 linear-light [0, 1] to sRGB float in [0, 255] (no rounding).
Split out from :func:`linear_to_srgb_uint8` so a caller that wants to dither
the final quantization can get the un-rounded sRGB value.
"""
x = np.clip(linear, 0.0, 1.0)
srgb = np.where(x <= 0.0031308, x * 12.92, 1.055 * np.power(x, 1.0 / 2.4) - 0.055)
return (srgb * 255.0).astype(np.float32)
def linear_to_srgb_uint8(linear: np.ndarray) -> np.ndarray:
"""Map float32 linear-light [0, 1] back to uint8 sRGB (inverse EOTF)."""
return np.clip(linear_to_srgb_float(linear) + 0.5, 0, 255).astype(np.uint8)
+99
View File
@@ -0,0 +1,99 @@
"""Solar position helpers — sunrise/sunset computation and timezone offsets.
Pure math with no project imports, so any layer (processing streams, the
automation engine, ) can use it without creating an import cycle. The two
public functions were originally private helpers in
``core/processing/daylight_stream.py``; they now live here so the automation
engine can compute sunrise/sunset windows without importing a
processing/stream module.
``compute_solar_times`` deliberately CLAMPS sunrise into ``[0.5, 11.5]`` and
sunset into ``[12.5, 23.5]``. That keeps the daylight LUT renderable and, for
the solar automation trigger, guarantees ``sunrise < sunset`` always holds
which sidesteps the polar-day/polar-night degeneracy (where the raw equations
collapse sunrise == sunset) at the cost of approximating the window in polar
regions. The approximation is identical to what the daylight-cycle feature
already shows, so behaviour stays consistent across the app.
"""
import datetime
import math
try:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
except ImportError: # pragma: no cover — pre-3.9 fallback, not expected in target envs
ZoneInfo = None # type: ignore[assignment]
class ZoneInfoNotFoundError(Exception): # type: ignore[no-redef]
pass
def compute_solar_times(
latitude: float,
longitude: float,
day_of_year: int,
utc_offset_hours: float = 0.0,
) -> tuple[float, float]:
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time.
Uses simplified NOAA solar equations:
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
- hour angle: cos(ha) = -tan(lat) * tan(decl)
- solar noon (UTC): 12 - longitude/15
- wall-clock sunrise/sunset: solar_noon_utc + utc_offset ha/15
Polar day and polar night are clamped to visible ranges, and the result
is further clamped so sunrise lands in ``[0.5, 11.5]`` and sunset in
``[12.5, 23.5]`` see the module docstring for why.
"""
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
# ZoneInfo() also raises ValueError (path-traversal / null-byte names) and
# OSError (over-long names) for malformed input, not just
# ZoneInfoNotFoundError. Catch all three so one bad SolarRule.timezone
# can't crash the whole automation evaluation tick.
except (ZoneInfoNotFoundError, ValueError, OSError):
pass
local_offset = when.astimezone().utcoffset()
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
@@ -0,0 +1,102 @@
"""Tests for the manual-trigger automation route (POST /automations/{id}/trigger).
The AutomationEngine is replaced with a lightweight fake so the route layer is
tested without driving the real evaluation loop or scene application.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from ledgrab.api import dependencies as deps
from ledgrab.api.routes.automations import router
from ledgrab.storage.automation import ManualTriggerRule
from ledgrab.storage.automation_store import AutomationStore
class FakeEngine:
"""Stand-in exposing only what the trigger route calls."""
def __init__(self, result=("triggered", [])):
self.result = result
self.calls = []
async def fire_manual_trigger(self, automation):
self.calls.append(automation.id)
return self.result
@pytest.fixture
def _route_db(tmp_path):
from ledgrab.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def automation_store(_route_db) -> AutomationStore:
store = AutomationStore(_route_db)
store.create_automation(
name="Manual one",
enabled=True,
rule_logic="or",
rules=[ManualTriggerRule()],
scene_preset_id=None,
)
return store
@pytest.fixture
def fake_engine():
return FakeEngine()
@pytest.fixture
def client(automation_store, fake_engine):
app = FastAPI()
app.include_router(router)
from ledgrab.api.auth import verify_api_key
app.dependency_overrides[verify_api_key] = lambda: "test-user"
app.dependency_overrides[deps.get_automation_store] = lambda: automation_store
app.dependency_overrides[deps.get_automation_engine] = lambda: fake_engine
# Routes may fire entity events through the processor manager; give it a stub.
deps._deps["processor_manager"] = None
return TestClient(app, raise_server_exceptions=False)
def _first_id(store: AutomationStore) -> str:
return store.get_all_automations()[0].id
class TestTriggerRoute:
def test_trigger_returns_status(self, client, automation_store, fake_engine):
aid = _first_id(automation_store)
resp = client.post(f"/api/v1/automations/{aid}/trigger")
assert resp.status_code == 200
assert resp.json() == {"status": "triggered", "errors": []}
assert fake_engine.calls == [aid]
def test_trigger_skipped(self, client, automation_store, fake_engine):
fake_engine.result = ("skipped", [])
aid = _first_id(automation_store)
resp = client.post(f"/api/v1/automations/{aid}/trigger")
assert resp.status_code == 200
assert resp.json()["status"] == "skipped"
def test_trigger_partial_errors(self, client, automation_store, fake_engine):
fake_engine.result = ("partial", ["dev1: timeout"])
aid = _first_id(automation_store)
resp = client.post(f"/api/v1/automations/{aid}/trigger")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "partial"
assert body["errors"] == ["dev1: timeout"]
def test_trigger_unknown_id_404(self, client, fake_engine):
resp = client.post("/api/v1/automations/auto_ghost/trigger")
assert resp.status_code == 404
assert fake_engine.calls == []
@@ -161,10 +161,42 @@ class TestCreateIntegration:
description="My game",
tags=["fps"],
)
assert data["adapter_config"] == {"auth_token": "secret123"}
# The auth_token is a live shared secret and must NEVER be echoed back
# over the API — it is masked to "" in every response.
assert data["adapter_config"] == {"auth_token": ""}
assert data["description"] == "My game"
assert data["tags"] == ["fps"]
def test_update_with_blank_token_preserves_secret(self, client, game_store):
"""The API masks secrets, so the edit form re-submits a blank token for
an unchanged secret. The update must PRESERVE the stored secret rather
than overwrite it with the blank (otherwise a no-op edit wipes the key).
"""
created = _create_integration(client, adapter_config={"auth_token": "secret123"})
gi_id = created["id"]
resp = client.put(
f"/api/v1/game-integrations/{gi_id}",
json={"name": "Renamed", "adapter_config": {"auth_token": ""}},
)
assert resp.status_code == 200, resp.text
# The stored (decrypted) secret is unchanged despite the blank submit.
cfg = game_store.get_integration(gi_id)
assert cfg.adapter_config.get("auth_token") == "secret123"
def test_update_with_new_token_replaces_secret(self, client, game_store):
"""A non-empty token in the update is a deliberate change and is kept."""
created = _create_integration(client, adapter_config={"auth_token": "secret123"})
gi_id = created["id"]
resp = client.put(
f"/api/v1/game-integrations/{gi_id}",
json={"adapter_config": {"auth_token": "rotated456"}},
)
assert resp.status_code == 200, resp.text
assert game_store.get_integration(gi_id).adapter_config.get("auth_token") == "rotated456"
def test_create_duplicate_name(self, client):
_create_integration(client, name="Unique")
resp = client.post(
@@ -132,8 +132,18 @@ def test_prune_by_max_entries(tmp_db):
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
for _ in range(10):
repo.record(_make_entry())
# Give each entry a distinct, increasing timestamp and capture insertion
# order so we can assert *which* five survive — not just the count. The
# engine settings→prune path must keep the NEWEST five (keeping the wrong
# half of an audit log would otherwise pass a count-only assertion).
from ledgrab.storage.activity_log import ActivityLogFilters
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
ids = []
for i in range(10):
e = _make_entry(ts=base + timedelta(hours=i))
ids.append(e.id)
repo.record(e)
assert repo.count() == 10
@@ -141,6 +151,10 @@ def test_prune_by_max_entries(tmp_db):
engine._prune()
assert repo.count() == 5
remaining = {r.id for r in repo.query(ActivityLogFilters(), limit=20)}
# Newest five (highest seq / latest ts) survive; oldest five are pruned.
assert all(sid in remaining for sid in ids[5:])
assert all(sid not in remaining for sid in ids[:5])
def test_prune_disabled_is_noop(tmp_db):
@@ -467,13 +467,18 @@ def test_activity_logged_event_payload_shape():
def test_entry_id_format():
"""Entry IDs must be 'al_' followed by 8 hex characters."""
"""Entry IDs must be 'al_' followed by the full 32-hex uuid4.
Widened from 8 hex (32 bits) to the full 128-bit uuid4 so a collision on the
UNIQUE id column which the best-effort recorder would silently drop is
astronomically unlikely even against the full retention window.
"""
recorder, persisted, _ = _make_recorder()
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
entry_id = persisted[0].id
assert entry_id.startswith("al_"), f"id does not start with 'al_': {entry_id!r}"
suffix = entry_id[3:]
assert len(suffix) == 8, f"id suffix length is {len(suffix)}, expected 8: {entry_id!r}"
assert len(suffix) == 32, f"id suffix length is {len(suffix)}, expected 32: {entry_id!r}"
assert all(c in "0123456789abcdef" for c in suffix), f"id suffix is not hex: {suffix!r}"

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