From 6745e25b20f455226d1abde1127158ce1e73f1e9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 22 Jun 2026 23:21:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20roadmap=20batch=20(2026-06-19)=20?= =?UTF-8?q?=E2=80=94=20solar/linear-light/dither/nanoleaf=20+=20integratio?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/release.yml | 52 +++ CLAUDE.md | 35 +- .../java/com/ledgrab/android/ApiKeyManager.kt | 75 ++-- .../android/LedGrabNotificationListener.kt | 32 +- contexts/ci-cd.md | 8 +- server/src/ledgrab/api/auth.py | 116 ++++-- server/src/ledgrab/api/dependencies.py | 64 ++-- server/src/ledgrab/api/routes/activity_log.py | 84 +++-- server/src/ledgrab/api/routes/automations.py | 48 +++ server/src/ledgrab/api/routes/devices.py | 3 + .../ledgrab/api/routes/game_integration.py | 144 ++++---- server/src/ledgrab/api/schemas/automations.py | 36 +- .../api/schemas/color_strip_sources.py | 12 + server/src/ledgrab/api/schemas/devices.py | 17 + .../src/ledgrab/core/activity_log/context.py | 29 +- .../src/ledgrab/core/activity_log/recorder.py | 10 +- .../src/ledgrab/core/activity_log/sanitize.py | 14 +- .../core/automations/automation_engine.py | 299 +++++++++++++--- .../src/ledgrab/core/capture/calibration.py | 44 ++- .../core/capture/calibration_session.py | 16 +- .../core/capture/edge_interpolation.py | 33 +- .../ledgrab/core/devices/adalight_client.py | 15 + .../src/ledgrab/core/devices/device_config.py | 2 + .../ledgrab/core/devices/nanoleaf_client.py | 158 ++++++++- .../ledgrab/core/devices/nanoleaf_provider.py | 1 + .../game_integration/adapters/cs2_adapter.py | 6 +- .../adapters/dota2_adapter.py | 6 +- .../adapters/generic_webhook_adapter.py | 7 +- .../core/game_integration/lol_poll_manager.py | 90 +++++ .../core/game_integration/mapping_adapter.py | 8 +- .../core/game_integration/runtime_state.py | 110 ++++++ .../core/processing/audio_energy_tap.py | 115 ++++++ .../processing/color_strip_stream_manager.py | 7 + .../core/processing/daylight_stream.py | 76 +--- .../ledgrab/core/processing/device_health.py | 62 ++-- .../ledgrab/core/processing/effect_stream.py | 68 ++++ .../ledgrab/core/scenes/playlist_engine.py | 35 +- server/src/ledgrab/main.py | 17 + server/src/ledgrab/static/css/automations.css | 31 ++ server/src/ledgrab/static/css/modal.css | 41 +++ server/src/ledgrab/static/js/app.ts | 6 +- server/src/ledgrab/static/js/core/i18n.ts | 17 +- .../static/js/features/activity-log.ts | 81 ++++- .../static/js/features/auto-calibration.ts | 30 +- .../ledgrab/static/js/features/automations.ts | 140 +++++++- .../ledgrab/static/js/features/calibration.ts | 6 + .../js/features/color-strips/gradient.ts | 2 + .../static/js/features/color-strips/index.ts | 75 ++++ .../static/js/features/css-gradient-editor.ts | 106 ++++++ .../ledgrab/static/js/features/dashboard.ts | 16 +- .../static/js/features/device-discovery.ts | 5 + .../src/ledgrab/static/js/features/devices.ts | 5 + .../src/ledgrab/static/js/features/streams.ts | 2 +- .../src/ledgrab/static/js/types/automation.ts | 16 +- server/src/ledgrab/static/js/types/device.ts | 1 + server/src/ledgrab/static/locales/en.json | 94 +++-- server/src/ledgrab/static/locales/ru.json | 67 +++- server/src/ledgrab/static/locales/zh.json | 64 +++- .../storage/activity_log_repository.py | 88 +++-- server/src/ledgrab/storage/automation.py | 93 +++++ .../src/ledgrab/storage/color_strip_source.py | 32 ++ server/src/ledgrab/storage/database.py | 13 + server/src/ledgrab/storage/device_store.py | 9 + .../src/ledgrab/storage/game_integration.py | 22 +- server/src/ledgrab/storage/http_endpoint.py | 39 +- .../ledgrab/templates/modals/add-device.html | 11 + .../ledgrab/templates/modals/calibration.html | 16 + .../ledgrab/templates/modals/css-editor.html | 35 +- .../templates/modals/device-settings.html | 11 + .../templates/modals/gradient-editor.html | 27 +- .../ledgrab/templates/modals/settings.html | 4 +- .../templates/modals/target-editor.html | 46 ++- server/src/ledgrab/utils/dither.py | 39 ++ server/src/ledgrab/utils/linear_light.py | 48 +++ server/src/ledgrab/utils/solar.py | 95 +++++ .../api/routes/test_automations_routes.py | 102 ++++++ .../routes/test_game_integration_routes.py | 34 +- .../tests/core/test_activity_log_retention.py | 18 +- .../test_activity_recorder_adversarial.py | 9 +- server/tests/core/test_automation_engine.py | 127 ++++++- .../core/test_automation_rule_handlers.py | 2 + server/tests/test_activity_instrumentation.py | 9 +- ...st_activity_instrumentation_adversarial.py | 27 +- server/tests/test_adalight_client.py | 40 +++ server/tests/test_dither.py | 113 ++++++ server/tests/test_linear_light.py | 92 +++++ server/tests/test_lol_poll_manager.py | 178 ++++++++++ server/tests/test_nanoleaf_extcontrol.py | 89 +++++ server/tests/test_reactive_palette.py | 181 ++++++++++ server/tests/test_review_fixes.py | 334 ++++++++++++++++++ server/tests/test_solar_rule.py | 188 ++++++++++ 91 files changed, 4390 insertions(+), 540 deletions(-) create mode 100644 server/src/ledgrab/core/game_integration/lol_poll_manager.py create mode 100644 server/src/ledgrab/core/game_integration/runtime_state.py create mode 100644 server/src/ledgrab/core/processing/audio_energy_tap.py create mode 100644 server/src/ledgrab/utils/dither.py create mode 100644 server/src/ledgrab/utils/linear_light.py create mode 100644 server/src/ledgrab/utils/solar.py create mode 100644 server/tests/api/routes/test_automations_routes.py create mode 100644 server/tests/test_dither.py create mode 100644 server/tests/test_linear_light.py create mode 100644 server/tests/test_lol_poll_manager.py create mode 100644 server/tests/test_nanoleaf_extcontrol.py create mode 100644 server/tests/test_reactive_palette.py create mode 100644 server/tests/test_review_fixes.py create mode 100644 server/tests/test_solar_rule.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 28d295a..3766441 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index a6783bf..eb20a7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` 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 # Definition body (prefer over Read) +vex usages --strict # Reference sites (AST-precise on T1 langs) +vex callers # Call sites (function-scoped) +vex callees # Outgoing calls +vex paths --from --to # 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 # 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 diff --git a/android/app/src/main/java/com/ledgrab/android/ApiKeyManager.kt b/android/app/src/main/java/com/ledgrab/android/ApiKeyManager.kt index 7e4d4ac..905a22a 100644 --- a/android/app/src/main/java/com/ledgrab/android/ApiKeyManager.kt +++ b/android/app/src/main/java/com/ledgrab/android/ApiKeyManager.kt @@ -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 { 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) diff --git a/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt b/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt index 14f92bb..908cba4 100644 --- a/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt +++ b/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt @@ -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() diff --git a/contexts/ci-cd.md b/contexts/ci-cd.md index 464a7a1..1c258d8 100644 --- a/contexts/ci-cd.md +++ b/contexts/ci-cd.md @@ -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 diff --git a/server/src/ledgrab/api/auth.py b/server/src/ledgrab/api/auth.py index b76fd06..73be4ff 100644 --- a/server/src/ledgrab/api/auth.py +++ b/server/src/ledgrab/api/auth.py @@ -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 diff --git a/server/src/ledgrab/api/dependencies.py b/server/src/ledgrab/api/dependencies.py index 470b152..71d3255 100644 --- a/server/src/ledgrab/api/dependencies.py +++ b/server/src/ledgrab/api/dependencies.py @@ -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, diff --git a/server/src/ledgrab/api/routes/activity_log.py b/server/src/ledgrab/api/routes/activity_log.py index d3c9773..84e3bf4 100644 --- a/server/src/ledgrab/api/routes/activity_log.py +++ b/server/src/ledgrab/api/routes/activity_log.py @@ -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. diff --git a/server/src/ledgrab/api/routes/automations.py b/server/src/ledgrab/api/routes/automations.py index cade7df..4556877 100644 --- a/server/src/ledgrab/api/routes/automations.py +++ b/server/src/ledgrab/api/routes/automations.py @@ -15,6 +15,7 @@ from ledgrab.api.schemas.automations import ( AutomationCreate, AutomationListResponse, AutomationResponse, + AutomationTriggerResponse, AutomationUpdate, RuleSchema, ) @@ -24,8 +25,10 @@ from ledgrab.storage.automation import ( DisplayStateRule, HomeAssistantRule, HTTPPollRule, + ManualTriggerRule, MQTTRule, Rule, + SolarRule, StartupRule, SystemIdleRule, TimeOfDayRule, @@ -55,6 +58,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 +89,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 "", @@ -394,3 +412,33 @@ 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)) + + status, errors = await engine.fire_manual_trigger(automation) + return AutomationTriggerResponse(status=status, errors=errors) diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index e9d4d8a..910ece8 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -103,6 +103,7 @@ def _device_to_response(device) -> DeviceResponse: 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, @@ -288,6 +289,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", @@ -638,6 +640,7 @@ async def update_device( 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, diff --git a/server/src/ledgrab/api/routes/game_integration.py b/server/src/ledgrab/api/routes/game_integration.py index 40b06ea..5c97999 100644 --- a/server/src/ledgrab/api/routes/game_integration.py +++ b/server/src/ledgrab/api/routes/game_integration.py @@ -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") diff --git a/server/src/ledgrab/api/schemas/automations.py b/server/src/ledgrab/api/schemas/automations.py index 9f90ca5..c484ac4 100644 --- a/server/src/ledgrab/api/schemas/automations.py +++ b/server/src/ledgrab/api/schemas/automations.py @@ -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( @@ -179,3 +201,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." + ) diff --git a/server/src/ledgrab/api/schemas/color_strip_sources.py b/server/src/ledgrab/api/schemas/color_strip_sources.py index 78df0a8..6654f84 100644 --- a/server/src/ledgrab/api/schemas/color_strip_sources.py +++ b/server/src/ledgrab/api/schemas/color_strip_sources.py @@ -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,10 @@ 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: str | None = Field(None, description="brightness | saturation | both") + reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)") class CompositeCSSCreate(_CSSCreateBase): @@ -532,6 +540,10 @@ 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: str | None = Field(None, description="brightness | saturation | both") + reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)") class CompositeCSSUpdate(_CSSUpdateBase): diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index d74e0ea..d84d75c 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -106,6 +106,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" @@ -212,6 +215,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 +362,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): @@ -446,6 +460,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") diff --git a/server/src/ledgrab/core/activity_log/context.py b/server/src/ledgrab/core/activity_log/context.py index 657cc85..a5cef92 100644 --- a/server/src/ledgrab/core/activity_log/context.py +++ b/server/src/ledgrab/core/activity_log/context.py @@ -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 diff --git a/server/src/ledgrab/core/activity_log/recorder.py b/server/src/ledgrab/core/activity_log/recorder.py index 2fc2533..80f15e5 100644 --- a/server/src/ledgrab/core/activity_log/recorder.py +++ b/server/src/ledgrab/core/activity_log/recorder.py @@ -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: diff --git a/server/src/ledgrab/core/activity_log/sanitize.py b/server/src/ledgrab/core/activity_log/sanitize.py index e454a17..88c3c66 100644 --- a/server/src/ledgrab/core/activity_log/sanitize.py +++ b/server/src/ledgrab/core/activity_log/sanitize.py @@ -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 diff --git a/server/src/ledgrab/core/automations/automation_engine.py b/server/src/ledgrab/core/automations/automation_engine.py index d4189b5..b454490 100644 --- a/server/src/ledgrab/core/automations/automation_engine.py +++ b/server/src/ledgrab/core/automations/automation_engine.py @@ -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,9 @@ 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) return if not self._scene_preset_store or not self._target_store or not self._device_store: @@ -726,27 +856,86 @@ class AutomationEngine: else: logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)") - # Audit record — best-effort. - try: - from ledgrab.core.activity_log.recorder import get_module_recorder - from ledgrab.core.activity_log.sanitize import sanitize_display - from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity + # Audit record — best-effort (shared helper, also used by no-scene path). + self._audit_activation(automation) - rec = get_module_recorder() - if rec is not None: - _safe_name = sanitize_display(automation.name) if automation.name else None - rec.record( - category=ActivityCategory.CAPTURE, - action="automation.activated", - severity=ActivitySeverity.INFO, - actor="system", - entity_type="automation", - entity_id=automation.id, - entity_name=_safe_name, - message=f"Automation '{_safe_name or automation.id}' activated", - ) - except Exception: - pass + async def _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) @@ -781,11 +970,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, @@ -904,10 +1091,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 +1114,12 @@ def _assert_rule_handler_coverage() -> None: StartupRule, ApplicationRule, TimeOfDayRule, + SolarRule, SystemIdleRule, DisplayStateRule, MQTTRule, WebhookRule, + ManualTriggerRule, HomeAssistantRule, HTTPPollRule, } diff --git a/server/src/ledgrab/core/capture/calibration.py b/server/src/ledgrab/core/capture/calibration.py index b29c112..95391ed 100644 --- a/server/src/ledgrab/core/capture/calibration.py +++ b/server/src/ledgrab/core/capture/calibration.py @@ -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 diff --git a/server/src/ledgrab/core/capture/calibration_session.py b/server/src/ledgrab/core/capture/calibration_session.py index f80e0f5..e785a00 100644 --- a/server/src/ledgrab/core/capture/calibration_session.py +++ b/server/src/ledgrab/core/capture/calibration_session.py @@ -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()) diff --git a/server/src/ledgrab/core/capture/edge_interpolation.py b/server/src/ledgrab/core/capture/edge_interpolation.py index 746d3f1..de5563d 100644 --- a/server/src/ledgrab/core/capture/edge_interpolation.py +++ b/server/src/ledgrab/core/capture/edge_interpolation.py @@ -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 diff --git a/server/src/ledgrab/core/devices/adalight_client.py b/server/src/ledgrab/core/devices/adalight_client.py index 21245d3..fcc8678 100644 --- a/server/src/ledgrab/core/devices/adalight_client.py +++ b/server/src/ledgrab/core/devices/adalight_client.py @@ -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}") diff --git a/server/src/ledgrab/core/devices/device_config.py b/server/src/ledgrab/core/devices/device_config.py index bf6d109..b9bc49f 100644 --- a/server/src/ledgrab/core/devices/device_config.py +++ b/server/src/ledgrab/core/devices/device_config.py @@ -153,6 +153,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) diff --git a/server/src/ledgrab/core/devices/nanoleaf_client.py b/server/src/ledgrab/core/devices/nanoleaf_client.py index 233315d..6742670 100644 --- a/server/src/ledgrab/core/devices/nanoleaf_client.py +++ b/server/src/ledgrab/core/devices/nanoleaf_client.py @@ -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://``. 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() 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 diff --git a/server/src/ledgrab/core/devices/nanoleaf_provider.py b/server/src/ledgrab/core/devices/nanoleaf_provider.py index a42546d..97ce72a 100644 --- a/server/src/ledgrab/core/devices/nanoleaf_provider.py +++ b/server/src/ledgrab/core/devices/nanoleaf_provider.py @@ -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: diff --git a/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py b/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py index a63f54e..3da0089 100644 --- a/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py +++ b/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py @@ -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]: diff --git a/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py b/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py index 35a170e..a184ea8 100644 --- a/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py +++ b/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py @@ -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]: diff --git a/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py b/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py index f875773..b7ff54d 100644 --- a/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py +++ b/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py @@ -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]: diff --git a/server/src/ledgrab/core/game_integration/lol_poll_manager.py b/server/src/ledgrab/core/game_integration/lol_poll_manager.py new file mode 100644 index 0000000..96ad842 --- /dev/null +++ b/server/src/ledgrab/core/game_integration/lol_poll_manager.py @@ -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 diff --git a/server/src/ledgrab/core/game_integration/mapping_adapter.py b/server/src/ledgrab/core/game_integration/mapping_adapter.py index 808daaa..7ab43fa 100644 --- a/server/src/ledgrab/core/game_integration/mapping_adapter.py +++ b/server/src/ledgrab/core/game_integration/mapping_adapter.py @@ -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 diff --git a/server/src/ledgrab/core/game_integration/runtime_state.py b/server/src/ledgrab/core/game_integration/runtime_state.py new file mode 100644 index 0000000..0761776 --- /dev/null +++ b/server/src/ledgrab/core/game_integration/runtime_state.py @@ -0,0 +1,110 @@ +"""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 runtime stats for an integration.""" + with _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 _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 diff --git a/server/src/ledgrab/core/processing/audio_energy_tap.py b/server/src/ledgrab/core/processing/audio_energy_tap.py new file mode 100644 index 0000000..a938ce2 --- /dev/null +++ b/server/src/ledgrab/core/processing/audio_energy_tap.py @@ -0,0 +1,115 @@ +"""AudioEnergyTap — a lightweight read-only tap on a shared audio capture. + +Lets a non-audio stream (e.g. a procedural effect) react to live loudness +without owning audio capture. It resolves an ``audio_source_id`` to capture +params exactly like ``AudioColorStripStream`` does, acquires the SHARED stream +via the ``AudioCaptureManager`` (ref-counted, so it piggybacks on any audio +visualiser already capturing the same device), and exposes a smoothed 0–1 +energy scalar. +""" + +from __future__ import annotations + +from typing import Any + +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + +# RMS of typical program audio sits around 0.05–0.3; scale so a moderately +# loud signal reaches ~1.0 before the per-effect intensity slider is applied. +_RMS_GAIN = 4.0 + + +class AudioEnergyTap: + """Resolve → acquire → read smoothed loudness from a shared audio capture.""" + + def __init__( + self, + audio_capture_manager: Any, + audio_source_store: Any = None, + audio_template_store: Any = None, + ) -> None: + self._mgr = audio_capture_manager + self._audio_source_store = audio_source_store + self._audio_template_store = audio_template_store + self._stream = None + self._source_id = "" + self._device_index = -1 + self._loopback = True + self._engine_type = None + self._engine_config = None + self._smoothed = 0.0 + + @property + def available(self) -> bool: + return self._mgr is not None + + @property + def active(self) -> bool: + return self._stream is not None + + def configure(self, audio_source_id: str) -> None: + """Resolve capture params for ``audio_source_id`` (no acquire yet).""" + self._source_id = audio_source_id or "" + self._device_index = -1 + self._loopback = True + self._engine_type = None + self._engine_config = None + if not self._source_id or not self._audio_source_store: + return + try: + resolved = self._audio_source_store.resolve_audio_source(self._source_id) + self._device_index = resolved.device_index + self._loopback = resolved.is_loopback + if resolved.audio_template_id and self._audio_template_store: + try: + tpl = self._audio_template_store.get_template(resolved.audio_template_id) + self._engine_type = tpl.engine_type + self._engine_config = tpl.engine_config + except ValueError as e: + logger.warning( + "AudioEnergyTap: template %s missing: %s", resolved.audio_template_id, e + ) + except ValueError as e: + logger.warning( + "AudioEnergyTap: failed to resolve audio source %s: %s", self._source_id, e + ) + + def start(self) -> None: + if self._mgr is None or self._stream is not None: + return + try: + self._stream = self._mgr.acquire( + self._device_index, + self._loopback, + engine_type=self._engine_type, + engine_config=self._engine_config, + ) + except Exception as e: # acquisition is best-effort — never break the effect + logger.warning("AudioEnergyTap: failed to acquire audio capture: %s", e) + self._stream = None + + def stop(self) -> None: + if self._mgr is not None and self._stream is not None: + try: + self._mgr.release(self._device_index, self._loopback, engine_type=self._engine_type) + except Exception: # release is best-effort + pass + self._stream = None + self._smoothed = 0.0 + + def energy(self, smoothing: float = 0.4) -> float: + """Return smoothed loudness in [0, 1] (0 when no capture/analysis yet). + + ``smoothing`` is the inertia of the EMA: 0 = instantaneous, →1 = sluggish. + """ + if self._stream is None: + return 0.0 + analysis = self._stream.get_latest_analysis() + raw = 0.0 + if analysis is not None: + raw = min(1.0, max(0.0, float(getattr(analysis, "rms", 0.0)) * _RMS_GAIN)) + a = max(0.0, min(0.99, smoothing)) + self._smoothed = self._smoothed * a + raw * (1.0 - a) + return self._smoothed diff --git a/server/src/ledgrab/core/processing/color_strip_stream_manager.py b/server/src/ledgrab/core/processing/color_strip_stream_manager.py index 7670bb7..fa8213c 100644 --- a/server/src/ledgrab/core/processing/color_strip_stream_manager.py +++ b/server/src/ledgrab/core/processing/color_strip_stream_manager.py @@ -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) diff --git a/server/src/ledgrab/core/processing/daylight_stream.py b/server/src/ledgrab/core/processing/daylight_stream.py index 5de51f1..a51ffbb 100644 --- a/server/src/ledgrab/core/processing/daylight_stream.py +++ b/server/src/ledgrab/core/processing/daylight_stream.py @@ -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: diff --git a/server/src/ledgrab/core/processing/device_health.py b/server/src/ledgrab/core/processing/device_health.py index 2eaef07..670b57c 100644 --- a/server/src/ledgrab/core/processing/device_health.py +++ b/server/src/ledgrab/core/processing/device_health.py @@ -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 diff --git a/server/src/ledgrab/core/processing/effect_stream.py b/server/src/ledgrab/core/processing/effect_stream.py index e63b61c..feb1dd6 100644 --- a/server/src/ledgrab/core/processing/effect_stream.py +++ b/server/src/ledgrab/core/processing/effect_stream.py @@ -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``). + """ + e = self._audio_tap.energy() + k = max(0.0, min(1.0, self.resolve("reactive_intensity", self._reactive_intensity))) + if k <= 0.0: + return + 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: diff --git a/server/src/ledgrab/core/scenes/playlist_engine.py b/server/src/ledgrab/core/scenes/playlist_engine.py index 1a401ef..905455c 100644 --- a/server/src/ledgrab/core/scenes/playlist_engine.py +++ b/server/src/ledgrab/core/scenes/playlist_engine.py @@ -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.""" diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index 42d540d..9696550 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -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 @@ -185,6 +186,8 @@ 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 +372,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, @@ -429,6 +433,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 +513,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. diff --git a/server/src/ledgrab/static/css/automations.css b/server/src/ledgrab/static/css/automations.css index 08a9478..f1b1e4d 100644 --- a/server/src/ledgrab/static/css/automations.css +++ b/server/src/ledgrab/static/css/automations.css @@ -196,6 +196,37 @@ width: 100%; } +/* ── Solar (sunrise/sunset) rule ───────────────────────────── */ +.solar-event-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.solar-event-row select { + flex: 1 1 8rem; + min-width: 8rem; +} +.solar-event-row input[type="number"] { + width: 5rem; + text-align: right; + font-variant-numeric: tabular-nums; +} +.solar-offset-unit { + font-size: 0.8rem; + color: var(--text-muted); +} +.solar-coord-row { + display: flex; + gap: 10px; +} +.solar-coord-row .rule-field { + flex: 1; +} +.solar-coord-row input[type="number"] { + width: 100%; +} + .time-range-label { font-size: 0.65rem; font-weight: 700; diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 718b500..47c510c 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -3261,6 +3261,46 @@ 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; +} +.gradient-harmony-types { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.gradient-harmony-btn { + padding: 4px 10px; + 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; +} +.calibration-linear-row .input-hint { + flex-basis: 100%; + margin: 0; +} + #gradient-canvas, #ge-gradient-canvas { width: 100%; @@ -4755,6 +4795,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; } diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 735deb0..98d5d21 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -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, diff --git a/server/src/ledgrab/static/js/core/i18n.ts b/server/src/ledgrab/static/js/core/i18n.ts index 4e2a449..2dd48d3 100644 --- a/server/src/ledgrab/static/js/core/i18n.ts +++ b/server/src/ledgrab/static/js/core/i18n.ts @@ -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 = {}) { 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); diff --git a/server/src/ledgrab/static/js/features/activity-log.ts b/server/src/ledgrab/static/js/features/activity-log.ts index 7f0165a..10194a0 100644 --- a/server/src/ledgrab/static/js/features/activity-log.ts +++ b/server/src/ledgrab/static/js/features/activity-log.ts @@ -81,6 +81,11 @@ let _liveEventListener: ((e: Event) => void) | null = null; let _loadingDelayTimer: ReturnType | 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 { ${escapeHtml(t('activity_log.live'))} -
+
${rows}
${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 . + 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( + `[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 { _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 { diff --git a/server/src/ledgrab/static/js/features/auto-calibration.ts b/server/src/ledgrab/static/js/features/auto-calibration.ts index d64a908..03e166d 100644 --- a/server/src/ledgrab/static/js/features/auto-calibration.ts +++ b/server/src/ledgrab/static/js/features/auto-calibration.ts @@ -490,13 +490,17 @@ export async function autoCalSweepForward(): Promise { _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 { _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 { _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 { @@ -564,11 +571,14 @@ export async function autoCalSolve(): Promise { 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 { 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'); diff --git a/server/src/ledgrab/static/js/features/automations.ts b/server/src/ledgrab/static/js/features/automations.ts index 157eb05..6b6b13b 100644 --- a/server/src/ledgrab/static/js/features/automations.ts +++ b/server/src/ledgrab/static/js/features/automations.ts @@ -344,6 +344,7 @@ type RuleChipBuilder = (c: any) => ModChipOpts; the scene activation. */ const RULE_CHIP_RENDERERS: Record = { 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 +359,22 @@ const RULE_CHIP_RENDERERS: Record = { 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') }; @@ -544,6 +561,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 +583,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' } @@ -784,10 +812,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 +913,10 @@ function _renderStartupFields(container: HTMLElement, _data: any): void { container.innerHTML = `${t('automations.rule.startup.hint')}`; } +function _renderManualTriggerFields(container: HTMLElement, _data: any): void { + container.innerHTML = `${t('automations.rule.manual_trigger.hint')}`; +} + function _renderTimeOfDayFields(container: HTMLElement, data: any): void { const startTime = data.start_time || '00:00'; const endTime = data.end_time || '23:59'; @@ -936,6 +968,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) => ``) + .join(''); + const eventOpts = (sel: string) => ` + + `; + container.innerHTML = ` +
+ ${t('automations.rule.solar.hint')} +
+ +
+ + + ${t('automations.rule.solar.offset.unit')} +
+
+
+ +
+ + + ${t('automations.rule.solar.offset.unit')} +
+
+
+
+ + +
+
+ + +
+
+ ${t('automations.rule.solar.location_hint')} +
+ ${t('automations.rule.time_of_day.days')} +
${dayChips}
+ ${t('automations.rule.time_of_day.days_hint')} +
+
+ + +
+
`; + 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 +1348,10 @@ function _renderApplicationFields(container: HTMLElement, data: any): void { const RULE_FIELD_RENDERERS: Record = { startup: _renderStartupFields, + manual_trigger: _renderManualTriggerFields, application: _renderApplicationFields, time_of_day: _renderTimeOfDayFields, + solar: _renderSolarFields, system_idle: _renderSystemIdleFields, display_state: _renderDisplayStateFields, mqtt: _renderMqttFields, @@ -1340,6 +1436,7 @@ type RuleCollector = (row: Element) => Record; const RULE_COLLECTORS: Record = { 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 +1445,22 @@ const RULE_COLLECTORS: Record = { .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, @@ -1494,6 +1607,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; diff --git a/server/src/ledgrab/static/js/features/calibration.ts b/server/src/ledgrab/static/js/features/calibration.ts index d9d578c..3a01c26 100644 --- a/server/src/ledgrab/static/js/features/calibration.ts +++ b/server/src/ledgrab/static/js/features/calibration.ts @@ -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 { diff --git a/server/src/ledgrab/static/js/features/color-strips/gradient.ts b/server/src/ledgrab/static/js/features/color-strips/gradient.ts index d7921fa..1f9115b 100644 --- a/server/src/ledgrab/static/js/features/color-strips/gradient.ts +++ b/server/src/ledgrab/static/js/features/color-strips/gradient.ts @@ -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(); }); } diff --git a/server/src/ledgrab/static/js/features/color-strips/index.ts b/server/src/ledgrab/static/js/features/color-strips/index.ts index e85df4b..2b5e8a0 100644 --- a/server/src/ledgrab/static/js/features/color-strips/index.ts +++ b/server/src/ledgrab/static/js/features/color-strips/index.ts @@ -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 => ``).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 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 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 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(); diff --git a/server/src/ledgrab/static/js/features/css-gradient-editor.ts b/server/src/ledgrab/static/js/features/css-gradient-editor.ts index 6d415c5..6a46e85 100644 --- a/server/src/ledgrab/static/js/features/css-gradient-editor.ts +++ b/server/src/ledgrab/static/js/features/css-gradient-editor.ts @@ -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 (0–255) → 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 (0–255). */ +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> = { + 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 { diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index d21bc21..5dd0c01 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -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('#dashboard-recent-activity-list.dal-list'); + if (liveRa) { + const scratchRa = scratch.querySelector('#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; } diff --git a/server/src/ledgrab/static/js/features/device-discovery.ts b/server/src/ledgrab/static/js/features/device-discovery.ts index a47d690..053e590 100644 --- a/server/src/ledgrab/static/js/features/device-discovery.ts +++ b/server/src/ledgrab/static/js/features/device-discovery.ts @@ -1019,6 +1019,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)) { @@ -1254,6 +1256,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'; @@ -1654,6 +1657,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) { diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index 8f19fe5..1c6f301 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -746,6 +746,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'; } @@ -933,6 +937,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 diff --git a/server/src/ledgrab/static/js/features/streams.ts b/server/src/ledgrab/static/js/features/streams.ts index f6ddf30..deaac49 100644 --- a/server/src/ledgrab/static/js/features/streams.ts +++ b/server/src/ledgrab/static/js/features/streams.ts @@ -146,7 +146,7 @@ registerIconEntityType('gradient', makeSimpleIconAdapter({ endpointPrefix: '/gradients', reload: _reloadStreams, typeLabelKey: 'device.icon.entity.gradient', - typeLabelFallback: 'Gradient', + typeLabelFallback: 'Palette', cardSelectors: (id) => [`[data-card-section="gradients"] [data-id="${CSS.escape(id)}"]`], })); diff --git a/server/src/ledgrab/static/js/types/automation.ts b/server/src/ledgrab/static/js/types/automation.ts index 41f1ac8..b793efd 100644 --- a/server/src/ledgrab/static/js/types/automation.ts +++ b/server/src/ledgrab/static/js/types/automation.ts @@ -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; diff --git a/server/src/ledgrab/static/js/types/device.ts b/server/src/ledgrab/static/js/types/device.ts index 2fac06d..02dae37 100644 --- a/server/src/ledgrab/static/js/types/device.ts +++ b/server/src/ledgrab/static/js/types/device.ts @@ -44,6 +44,7 @@ export interface Device { 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; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 0b48d2a..0f1a169 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -75,6 +75,7 @@ "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.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 +97,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 +337,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 +362,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 +430,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 +453,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.", @@ -485,6 +503,10 @@ "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 +546,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 +790,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 +846,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.0–1.0)", "color_strip.gradient.preset": "Preset:", "color_strip.gradient.preset.apply": "Apply", @@ -837,8 +871,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", @@ -1371,7 +1414,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", @@ -1498,6 +1541,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 +1851,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", @@ -2323,7 +2368,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 +2626,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 +2642,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 +2720,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 +3016,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.", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 6b8223e..5734709 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -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,7 @@ "activity_log.msg.audit_log.disabled": "Запись активности отключена", "activity_log.msg.automation.activated": "Автоматизация '{name}' активирована", "activity_log.msg.automation.deactivated": "Автоматизация '{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 +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": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.", @@ -475,6 +496,10 @@ "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 +541,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 +757,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 +797,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": "Применить", @@ -784,6 +821,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": "остановок", @@ -1290,7 +1336,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-эндпоинт", @@ -1417,6 +1463,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 +1754,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 +2451,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 +2467,7 @@ "settings.section.notif_permission": "Разрешение ОС", "settings.section.offsets": "Смещения", "settings.section.output": "Вывод", + "settings.section.power": "Питание", "settings.section.preview": "Превью", "settings.section.protocol": "Протокол", "settings.section.provider": "Провайдер", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 82110d2..fa1baf3 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -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,7 @@ "activity_log.msg.audit_log.disabled": "活动记录已禁用", "activity_log.msg.automation.activated": "自动化 '{name}' 已激活", "activity_log.msg.automation.deactivated": "自动化 '{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 +99,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 +287,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 +339,7 @@ "automation.disabled": "自动化已禁用", "automation.enabled": "自动化已启用", "automations.action.disable": "禁用", + "automations.action.trigger": "触发", "automations.add": "添加自动化", "automations.created": "自动化已创建", "automations.deactivation_mode": "停用方式:", @@ -360,6 +364,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 +422,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 +445,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": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。", @@ -475,6 +495,10 @@ "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 +538,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 +754,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 +794,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 +818,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": "个色标", @@ -1288,7 +1333,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 端点", @@ -1415,6 +1460,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 +1751,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 +2444,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 +2460,7 @@ "settings.section.notif_permission": "系统权限", "settings.section.offsets": "偏移", "settings.section.output": "输出", + "settings.section.power": "电源", "settings.section.preview": "预览", "settings.section.protocol": "协议", "settings.section.provider": "提供商", diff --git a/server/src/ledgrab/storage/activity_log_repository.py b/server/src/ledgrab/storage/activity_log_repository.py index b1df695..a0e3bf3 100644 --- a/server/src/ledgrab/storage/activity_log_repository.py +++ b/server/src/ledgrab/storage/activity_log_repository.py @@ -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 diff --git a/server/src/ledgrab/storage/automation.py b/server/src/ledgrab/storage/automation.py index d47693c..5ee141b 100644 --- a/server/src/ledgrab/storage/automation.py +++ b/server/src/ledgrab/storage/automation.py @@ -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 diff --git a/server/src/ledgrab/storage/color_strip_source.py b/server/src/ledgrab/storage/color_strip_source.py index b9cf14b..1080f33 100644 --- a/server/src/ledgrab/storage/color_strip_source.py +++ b/server/src/ledgrab/storage/color_strip_source.py @@ -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 diff --git a/server/src/ledgrab/storage/database.py b/server/src/ledgrab/storage/database.py index ad9517f..b152685 100644 --- a/server/src/ledgrab/storage/database.py +++ b/server/src/ledgrab/storage/database.py @@ -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. diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py index 0482391..5c826c1 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -94,6 +94,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", @@ -148,6 +149,7 @@ class Device: 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 @@ -270,6 +272,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) @@ -364,6 +367,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": @@ -426,6 +431,7 @@ class Device: 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"), @@ -480,6 +486,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset( "opc_channel", "nanoleaf_token", "nanoleaf_min_interval_ms", + "nanoleaf_per_panel", "spi_speed_hz", "spi_led_type", "chroma_device_type", @@ -587,6 +594,7 @@ class DeviceStore(BaseSqliteStore[Device]): 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", @@ -637,6 +645,7 @@ class DeviceStore(BaseSqliteStore[Device]): 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, diff --git a/server/src/ledgrab/storage/game_integration.py b/server/src/ledgrab/storage/game_integration.py index 3669e2b..2d89bae 100644 --- a/server/src/ledgrab/storage/game_integration.py +++ b/server/src/ledgrab/storage/game_integration.py @@ -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 diff --git a/server/src/ledgrab/storage/http_endpoint.py b/server/src/ledgrab/storage/http_endpoint.py index c407510..a8ee9ed 100644 --- a/server/src/ledgrab/storage/http_endpoint.py +++ b/server/src/ledgrab/storage/http_endpoint.py @@ -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 ``. + # 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 diff --git a/server/src/ledgrab/templates/modals/add-device.html b/server/src/ledgrab/templates/modals/add-device.html index 5e0a40e..71b5878 100644 --- a/server/src/ledgrab/templates/modals/add-device.html +++ b/server/src/ledgrab/templates/modals/add-device.html @@ -283,6 +283,17 @@
+ +
+ + Linear-light blending + Average border pixels in linear light for perceptually correct, brighter colour mixing. +
+
+ + Dithering + Spatio-temporal dithering reduces visible banding on smooth gradients. +
diff --git a/server/src/ledgrab/templates/modals/css-editor.html b/server/src/ledgrab/templates/modals/css-editor.html index 0eae785..64a4b54 100644 --- a/server/src/ledgrab/templates/modals/css-editor.html +++ b/server/src/ledgrab/templates/modals/css-editor.html @@ -114,10 +114,10 @@ + + +
+
+ + +
+ + +
+ diff --git a/server/src/ledgrab/templates/modals/device-settings.html b/server/src/ledgrab/templates/modals/device-settings.html index 64382ec..ad07e2f 100644 --- a/server/src/ledgrab/templates/modals/device-settings.html +++ b/server/src/ledgrab/templates/modals/device-settings.html @@ -312,6 +312,17 @@ + + section; the power/ABL controls live in their own Power section. + All inner element IDs preserved. --> + +
+
+ + Power + +
+
+
+
+ + +
+ +
+ + mA (0 = unlimited) +
+
+ + +
+
+
+
+ diff --git a/server/src/ledgrab/utils/dither.py b/server/src/ledgrab/utils/dither.py new file mode 100644 index 0000000..af9a272 --- /dev/null +++ b/server/src/ledgrab/utils/dither.py @@ -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) diff --git a/server/src/ledgrab/utils/linear_light.py b/server/src/ledgrab/utils/linear_light.py new file mode 100644 index 0000000..d875d81 --- /dev/null +++ b/server/src/ledgrab/utils/linear_light.py @@ -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) diff --git a/server/src/ledgrab/utils/solar.py b/server/src/ledgrab/utils/solar.py new file mode 100644 index 0000000..f57b46b --- /dev/null +++ b/server/src/ledgrab/utils/solar.py @@ -0,0 +1,95 @@ +"""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 + except ZoneInfoNotFoundError: + pass + local_offset = when.astimezone().utcoffset() + return local_offset.total_seconds() / 3600.0 if local_offset else 0.0 diff --git a/server/tests/api/routes/test_automations_routes.py b/server/tests/api/routes/test_automations_routes.py new file mode 100644 index 0000000..4c1b969 --- /dev/null +++ b/server/tests/api/routes/test_automations_routes.py @@ -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 == [] diff --git a/server/tests/api/routes/test_game_integration_routes.py b/server/tests/api/routes/test_game_integration_routes.py index 7b293ad..192cd05 100644 --- a/server/tests/api/routes/test_game_integration_routes.py +++ b/server/tests/api/routes/test_game_integration_routes.py @@ -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( diff --git a/server/tests/core/test_activity_log_retention.py b/server/tests/core/test_activity_log_retention.py index 3feab00..147ae9c 100644 --- a/server/tests/core/test_activity_log_retention.py +++ b/server/tests/core/test_activity_log_retention.py @@ -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): diff --git a/server/tests/core/test_activity_recorder_adversarial.py b/server/tests/core/test_activity_recorder_adversarial.py index df7af5f..dfd6b5c 100644 --- a/server/tests/core/test_activity_recorder_adversarial.py +++ b/server/tests/core/test_activity_recorder_adversarial.py @@ -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}" diff --git a/server/tests/core/test_automation_engine.py b/server/tests/core/test_automation_engine.py index 609e690..2a3bf17 100644 --- a/server/tests/core/test_automation_engine.py +++ b/server/tests/core/test_automation_engine.py @@ -1,7 +1,7 @@ """Tests for AutomationEngine — rule evaluation in isolation.""" from datetime import datetime, timezone -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -10,6 +10,7 @@ from ledgrab.storage.automation import ( ApplicationRule, Automation, DisplayStateRule, + ManualTriggerRule, StartupRule, SystemIdleRule, TimeOfDayRule, @@ -552,6 +553,130 @@ class TestHTTPValueStreamExtraction: assert _extract_simple_path(body, "MediaContainer.size", "") == 2 assert _extract_simple_path(body, "MediaContainer.Metadata[0].title", "") == "Show" + +# --------------------------------------------------------------------------- +# Manual trigger — one-shot apply gated by the automation's rules +# --------------------------------------------------------------------------- + + +class TestManualTrigger: + """fire_manual_trigger: evaluate rules with the manual term True, then + apply the scene once (without entering the sticky active state).""" + + def _make(self, rules, *, enabled=True, logic="or", scene=None, aid="auto_manual"): + now = datetime.now(timezone.utc) + return Automation( + id=aid, + name="Manual", + enabled=enabled, + rule_logic=logic, + rules=rules, + scene_preset_id=scene, + deactivation_mode="none", + deactivation_scene_preset_id=None, + created_at=now, + updated_at=now, + ) + + def test_handle_manual_inert_by_default(self, engine): + # Outside a manual fire the rule reads False, so a manual-only + # automation never activates from the background tick. + assert engine._handle_manual(ManualTriggerRule(), None) is False + + @pytest.mark.asyncio + async def test_fires_no_scene_one_shot(self, engine): + auto = self._make([ManualTriggerRule()]) + status, errors = await engine.fire_manual_trigger(auto) + assert status == "triggered" + assert errors == [] + # One-shot: records last_activated but does NOT enter the sticky state + # (so the background tick has nothing to reconcile away → no bounce). + assert auto.id not in engine._active_automations + assert auto.id in engine._last_activated + # The transient flag is always cleared after the evaluation. + assert engine._manual_fire_active is False + + @pytest.mark.asyncio + async def test_skipped_when_and_companion_false(self, engine): + # manual AND an unset webhook → AND fails → nothing applied. + auto = self._make([ManualTriggerRule(), WebhookRule(token="nope")], logic="and") + status, errors = await engine.fire_manual_trigger(auto) + assert status == "skipped" + assert errors == [] + assert auto.id not in engine._last_activated + + @pytest.mark.asyncio + async def test_fires_or_when_companion_false(self, engine): + # manual OR an unset webhook → manual alone satisfies "or". + auto = self._make([ManualTriggerRule(), WebhookRule(token="nope")], logic="or") + status, _errors = await engine.fire_manual_trigger(auto) + assert status == "triggered" + + @pytest.mark.asyncio + async def test_works_when_disabled(self, engine): + # enabled gates only the background loop; a manual trigger ignores it. + auto = self._make([ManualTriggerRule()], enabled=False) + status, _errors = await engine.fire_manual_trigger(auto) + assert status == "triggered" + + @pytest.mark.asyncio + async def test_manual_only_automation_inert_in_background_tick(self, engine, mock_store): + created = mock_store.create_automation( + name="manual-bg", + enabled=True, + rule_logic="or", + rules=[ManualTriggerRule()], + scene_preset_id=None, + ) + await engine.trigger_evaluate() + assert created.id not in engine._active_automations + + @pytest.mark.asyncio + async def test_audits_with_user_actor(self, engine): + rec = MagicMock() + with patch("ledgrab.core.activity_log.recorder.get_module_recorder", return_value=rec): + await engine.fire_manual_trigger(self._make([ManualTriggerRule()])) + rec.record.assert_called_once() + kwargs = rec.record.call_args.kwargs + assert kwargs["action"] == "automation.triggered" + # No explicit actor → recorder resolves the current user (not "system"). + assert "actor" not in kwargs + + @pytest.mark.asyncio + async def test_applies_scene_activated_maps_to_triggered(self, engine): + engine._scene_preset_store = MagicMock() + preset = MagicMock() + preset.name = "Scene" + engine._scene_preset_store.get_preset.return_value = preset + engine._target_store = MagicMock() + engine._device_store = MagicMock() + auto = self._make([ManualTriggerRule()], scene="scene_x") + with patch( + "ledgrab.core.scenes.scene_activator.apply_scene_state", + new=AsyncMock(return_value=("activated", [])), + ) as apply_mock: + status, errors = await engine.fire_manual_trigger(auto) + apply_mock.assert_awaited_once() + assert status == "triggered" + assert errors == [] + + @pytest.mark.asyncio + async def test_applies_scene_partial_passthrough(self, engine): + engine._scene_preset_store = MagicMock() + preset = MagicMock() + preset.name = "Scene" + engine._scene_preset_store.get_preset.return_value = preset + engine._target_store = MagicMock() + engine._device_store = MagicMock() + auto = self._make([ManualTriggerRule()], scene="scene_x") + with patch( + "ledgrab.core.scenes.scene_activator.apply_scene_state", + new=AsyncMock(return_value=("partial", ["dev1: timeout"])), + ): + status, errors = await engine.fire_manual_trigger(auto) + assert status == "partial" + assert errors == ["dev1: timeout"] + def test_chained_indices(self): from ledgrab.core.processing.value_stream import _extract_simple_path diff --git a/server/tests/core/test_automation_rule_handlers.py b/server/tests/core/test_automation_rule_handlers.py index 04a3a50..3657200 100644 --- a/server/tests/core/test_automation_rule_handlers.py +++ b/server/tests/core/test_automation_rule_handlers.py @@ -21,6 +21,7 @@ from ledgrab.storage.automation import ( HTTPPollRule, MQTTRule, Rule, + SolarRule, StartupRule, SystemIdleRule, TimeOfDayRule, @@ -31,6 +32,7 @@ EXPECTED_RULE_TYPES = { StartupRule, ApplicationRule, TimeOfDayRule, + SolarRule, SystemIdleRule, DisplayStateRule, MQTTRule, diff --git a/server/tests/test_activity_instrumentation.py b/server/tests/test_activity_instrumentation.py index cd90c90..6bb7040 100644 --- a/server/tests/test_activity_instrumentation.py +++ b/server/tests/test_activity_instrumentation.py @@ -13,6 +13,7 @@ Coverage targets from __future__ import annotations +import asyncio from unittest.mock import MagicMock, patch import pytest @@ -233,7 +234,7 @@ class TestAuthInstrumentation: req = self._make_mock_request() with pytest.raises(Exception): # HTTPException 401 - verify_api_key(req, None) + asyncio.run(verify_api_key(req, None)) # At least one warning record about auth warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING] @@ -251,7 +252,7 @@ class TestAuthInstrumentation: req = self._make_mock_request(client_ip="127.0.0.1") with pytest.raises(Exception): # HTTPException 401 - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) # At least one warning-level auth record warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING] @@ -286,7 +287,7 @@ class TestAuthInstrumentation: req = self._make_mock_request(client_ip="192.168.1.100") with pytest.raises(Exception): # HTTPException 401 - verify_api_key(req, None) + asyncio.run(verify_api_key(req, None)) warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING] assert len(warnings) >= 1 @@ -302,7 +303,7 @@ class TestAuthInstrumentation: req = self._make_mock_request(client_ip="10.0.0.5") with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) auth_records = [e for e in persisted if e.category == ActivityCategory.AUTH] assert len(auth_records) >= 1 diff --git a/server/tests/test_activity_instrumentation_adversarial.py b/server/tests/test_activity_instrumentation_adversarial.py index f7a2087..c34c97f 100644 --- a/server/tests/test_activity_instrumentation_adversarial.py +++ b/server/tests/test_activity_instrumentation_adversarial.py @@ -7,6 +7,7 @@ shape, and self-referential exclusion. from __future__ import annotations +import asyncio import time from unittest.mock import AsyncMock, MagicMock, patch @@ -95,7 +96,7 @@ class TestNoSecretLeakage: mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) assert len(persisted) >= 1, "Expected at least one auth record" for entry in persisted: @@ -121,7 +122,7 @@ class TestNoSecretLeakage: mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, None) + asyncio.run(verify_api_key(req, None)) warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING] assert len(warnings) >= 1 @@ -152,7 +153,7 @@ class TestNoSecretLeakage: mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING] assert len(warnings) >= 1 @@ -284,7 +285,7 @@ class TestNoSecretLeakage: mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) for entry in persisted: for v in entry.metadata.values(): @@ -367,7 +368,7 @@ class TestBestEffortResilience: # Should raise the HTTP 401, not the recorder RuntimeError with pytest.raises(Exception) as exc_info: - verify_api_key(req, None) # missing creds + asyncio.run(verify_api_key(req, None)) # missing creds # Must be an HTTPException (401), NOT the RuntimeError from the recorder assert "RuntimeError" not in type(exc_info.value).__name__ @@ -734,7 +735,7 @@ class TestNoDuplicateRecords: mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) rejected = [e for e in persisted if e.action == "auth.rejected"] assert len(rejected) == 1, f"Expected exactly 1 auth.rejected record, got {len(rejected)}" @@ -768,7 +769,7 @@ class TestMetadataShape: mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) rejected = [e for e in persisted if e.action == "auth.rejected"] assert len(rejected) >= 1 @@ -800,7 +801,7 @@ class TestMetadataShape: mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) rejected = [e for e in persisted if e.action == "auth.rejected"] assert rejected[0].metadata["client"] == _EXPECTED_IP @@ -1041,7 +1042,7 @@ class TestCategorySeverityContract: cfg.auth.api_keys = {"dev": "good"} mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) self._check_all_entries(persisted) auth_records = [e for e in persisted if e.category == "auth"] @@ -1384,7 +1385,7 @@ class TestAuthFailureThrottle: cfg.auth.api_keys = {"dev": "correct-key"} mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) return persisted_ref @@ -1414,7 +1415,7 @@ class TestAuthFailureThrottle: cfg.auth.api_keys = {"dev": "real-key"} mock_cfg.return_value = cfg try: - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) except Exception: exceptions_raised += 1 @@ -1444,7 +1445,7 @@ class TestAuthFailureThrottle: cfg.auth.api_keys = {"dev": "right"} mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) rejected = [e for e in all_persisted if e.action == "auth.rejected"] assert len(rejected) == len( @@ -1477,7 +1478,7 @@ class TestAuthFailureThrottle: cfg.auth.api_keys = {"dev": "correct"} mock_cfg.return_value = cfg with pytest.raises(Exception): - verify_api_key(req, creds) + asyncio.run(verify_api_key(req, creds)) rejected = [e for e in all_persisted if e.action == "auth.rejected"] assert ( diff --git a/server/tests/test_adalight_client.py b/server/tests/test_adalight_client.py index 430a1d8..471c398 100644 --- a/server/tests/test_adalight_client.py +++ b/server/tests/test_adalight_client.py @@ -131,3 +131,43 @@ def test_build_frame_total_length(led_count): frame = client._build_frame(pixels, brightness=255) assert len(frame) == 6 + led_count * 3 + + +async def test_close_settles_before_port_close(monkeypatch): + """close() must let the board paint the black frame before resetting it. + + The black frame has to be written AND given settle time before + ``serial.close()`` toggles DTR (Arduino auto-reset). If the reset wins the + race the strip latches its last lit frame and "stays on". This guards the + ordering: write → flush → sleep(settle) → close. + """ + import concurrent.futures + + from unittest.mock import MagicMock + + from ledgrab.core.devices import adalight_client as mod + + client = _make_client(led_count=3) + events: list[str] = [] + + serial = MagicMock() + serial.is_open = True + serial.write.side_effect = lambda *_a, **_k: events.append("write") + serial.flush.side_effect = lambda *_a, **_k: events.append("flush") + serial.close.side_effect = lambda *_a, **_k: events.append("close") + + client._serial = serial + client._connected = True + client._tx_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + + async def fake_sleep(_seconds): + events.append(f"sleep:{_seconds}") + + monkeypatch.setattr(mod.asyncio, "sleep", fake_sleep) + + await client.close() + + # Black frame is written and flushed, the board is given settle time, and + # ONLY THEN is the port closed (which resets the board). + assert events == ["write", "flush", f"sleep:{mod.BLACK_FRAME_SETTLE_DELAY}", "close"] + assert mod.BLACK_FRAME_SETTLE_DELAY > 0 diff --git a/server/tests/test_dither.py b/server/tests/test_dither.py new file mode 100644 index 0000000..a8210bb --- /dev/null +++ b/server/tests/test_dither.py @@ -0,0 +1,113 @@ +"""Tests for spatio-temporal dithering of the final 8-bit quantization. + +The load-bearing property is *temporal convergence*: over many frames, the +dithered output time-averages back to the true sub-integer value (that's what +lets the eye see a higher effective bit depth instead of banding). +""" + +from __future__ import annotations + +import numpy as np + +from ledgrab.core.capture.calibration import calibration_from_dict, calibration_to_dict +from ledgrab.core.capture.edge_interpolation import average_edge_to_leds +from ledgrab.utils.dither import ordered_dither_quantize + + +class TestOrderedDither: + def test_single_frame_is_floor_or_ceil(self): + vals = np.array([[100.4, 50.0, 200.7]], dtype=np.float32) + out = ordered_dither_quantize(vals, frame_index=7) + assert out.dtype == np.uint8 + assert out[0, 0] in (100, 101) + assert out[0, 1] == 50 # exact integer never moves + assert out[0, 2] in (200, 201) + + def test_integers_are_stable_across_frames(self): + vals = np.array([[10.0, 20.0, 30.0]], dtype=np.float32) + for f in range(20): + out = ordered_dither_quantize(vals, frame_index=f) + assert list(out[0]) == [10, 20, 30] + + def test_temporal_average_converges_to_true_value(self): + vals = np.array([[100.4, 50.9, 200.25]], dtype=np.float32) + acc = np.zeros(3, dtype=np.float64) + n = 2000 + for f in range(n): + acc += ordered_dither_quantize(vals, frame_index=f)[0] + avg = acc / n + assert abs(avg[0] - 100.4) < 0.3 + assert abs(avg[1] - 50.9) < 0.3 + assert abs(avg[2] - 200.25) < 0.3 + + def test_same_threshold_across_channels_preserves_hue_steps(self): + # All three channels share the per-LED threshold, so an equal-channel + # grey never splits into a coloured pixel. + vals = np.array([[123.5, 123.5, 123.5]], dtype=np.float32) + for f in range(50): + out = ordered_dither_quantize(vals, frame_index=f) + assert out[0, 0] == out[0, 1] == out[0, 2] + + def test_clips_to_byte_range(self): + vals = np.array([[-5.0, 255.9, 300.0]], dtype=np.float32) + out = ordered_dither_quantize(vals, frame_index=3) + assert out[0, 0] == 0 + assert out[0, 1] == 255 + assert out[0, 2] == 255 + + +class TestEdgeReductionDither: + def _half_edge(self): + # 2 columns (black, white) → 1 LED whose mean is exactly 127.5, i.e. a + # value the plain uint8 path can only represent as 127. + e = np.zeros((2, 2, 3), dtype=np.uint8) + e[:, 1, :] = 255 + return e + + def test_dithered_output_varies_by_frame(self): + edge = self._half_edge() + seen = { + int(average_edge_to_leds(edge, "top", 1, {}, "k", dither=True, frame_index=f)[0, 0]) + for f in range(50) + } + # The 127.5 LED straddles two codes → dither flips it across frames. + assert seen == {127, 128} + + def test_temporal_average_recovers_the_half_step(self): + edge = self._half_edge() + # Plain quantization truncates 127.5 → 127; dither recovers ~127.5. + plain = int(average_edge_to_leds(edge, "top", 1, {}, "k")[0, 0]) + acc = np.zeros(3, dtype=np.float64) + n = 1500 + for f in range(n): + acc += average_edge_to_leds(edge, "top", 1, {}, "k", dither=True, frame_index=f)[0] + avg = acc[0] / n + assert plain == 127 + assert abs(avg - 127.5) < 0.2 + + +class TestCalibrationRoundTrip: + def test_dither_round_trips(self): + cfg = calibration_from_dict( + { + "mode": "simple", + "layout": "clockwise", + "start_position": "bottom_left", + "leds_top": 5, + "dither": True, + } + ) + assert cfg.dither is True + assert calibration_to_dict(cfg).get("dither") is True + + def test_dither_default_off_and_omitted(self): + cfg = calibration_from_dict( + { + "mode": "simple", + "layout": "clockwise", + "start_position": "bottom_left", + "leds_top": 5, + } + ) + assert cfg.dither is False + assert "dither" not in calibration_to_dict(cfg) diff --git a/server/tests/test_linear_light.py b/server/tests/test_linear_light.py new file mode 100644 index 0000000..8a937c8 --- /dev/null +++ b/server/tests/test_linear_light.py @@ -0,0 +1,92 @@ +"""Tests for linear-light blending in the per-LED reduction. + +Verifies the sRGB↔linear conversion correctness and that the edge-reduction +kernel, when ``linear=True``, blends in linear light (a mid-grey from black + +white is brighter than the gamma-space mean) — plus the CalibrationConfig +round-trip of the opt-in flag. +""" + +from __future__ import annotations + +import numpy as np + +from ledgrab.core.capture.calibration import calibration_from_dict, calibration_to_dict +from ledgrab.core.capture.edge_interpolation import average_edge_to_leds +from ledgrab.utils.linear_light import ( + SRGB_TO_LINEAR_LUT, + linear_to_srgb_uint8, + srgb_to_linear, +) + + +class TestConversions: + def test_lut_endpoints_and_monotonic(self): + assert SRGB_TO_LINEAR_LUT.shape == (256,) + assert SRGB_TO_LINEAR_LUT[0] == 0.0 + assert abs(SRGB_TO_LINEAR_LUT[255] - 1.0) < 1e-6 + assert np.all(np.diff(SRGB_TO_LINEAR_LUT) > 0) # strictly increasing + + def test_mid_grey_decodes_below_half(self): + # sRGB 0.5 (≈128) is ~0.214 in linear light, not 0.5. + assert 0.18 < float(SRGB_TO_LINEAR_LUT[128]) < 0.25 + + def test_round_trip_is_near_identity(self): + ramp = np.arange(256, dtype=np.uint8) + back = linear_to_srgb_uint8(srgb_to_linear(ramp)) + assert np.max(np.abs(back.astype(int) - ramp.astype(int))) <= 1 + + def test_linear_mean_of_black_and_white_is_brighter(self): + lin = (srgb_to_linear(np.array([0, 255], dtype=np.uint8))).mean() + encoded = int(linear_to_srgb_uint8(np.array([lin], dtype=np.float32))[0]) + assert encoded > 127 # brighter than the sRGB mean (127) + assert 180 < encoded < 195 # ~188 + + +class TestEdgeReductionLinear: + def _edge(self): + # top edge (axis=0): shape (rows=2, width=4, 3); two black + two white cols + e = np.zeros((2, 4, 3), dtype=np.uint8) + e[:, 2:, :] = 255 + return e + + def test_srgb_blend_is_plain_mean(self): + out = average_edge_to_leds(self._edge(), "top", 1, {}, "k", linear=False) + assert int(out[0, 0]) == 127 + + def test_linear_blend_is_brighter(self): + out = average_edge_to_leds(self._edge(), "top", 1, {}, "k", linear=True) + assert int(out[0, 0]) > 127 + assert 180 < int(out[0, 0]) < 195 + + def test_uniform_edge_unchanged_by_linear(self): + e = np.full((2, 4, 3), 200, dtype=np.uint8) + out = average_edge_to_leds(e, "top", 1, {}, "k", linear=True) + # A flat colour survives the decode→mean→encode round-trip (±1). + assert abs(int(out[0, 0]) - 200) <= 1 + + +class TestCalibrationRoundTrip: + def test_linear_blend_round_trips_simple(self): + cfg = calibration_from_dict( + { + "mode": "simple", + "layout": "clockwise", + "start_position": "bottom_left", + "leds_top": 5, + "linear_blend": True, + } + ) + assert cfg.linear_blend is True + assert calibration_to_dict(cfg).get("linear_blend") is True + + def test_default_is_off_and_omitted(self): + cfg = calibration_from_dict( + { + "mode": "simple", + "layout": "clockwise", + "start_position": "bottom_left", + "leds_top": 5, + } + ) + assert cfg.linear_blend is False + assert "linear_blend" not in calibration_to_dict(cfg) diff --git a/server/tests/test_lol_poll_manager.py b/server/tests/test_lol_poll_manager.py new file mode 100644 index 0000000..c44efae --- /dev/null +++ b/server/tests/test_lol_poll_manager.py @@ -0,0 +1,178 @@ +"""Tests for the LoL poll manager + shared game-integration payload processing. + +Covers the runtime wiring that was previously missing: the orphaned +``LoLPoller`` is now driven by ``LoLPollManager``, and polled payloads flow +through the same ``process_payload`` core the HTTP ingest route uses. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from ledgrab.core.game_integration import runtime_state +from ledgrab.core.game_integration.adapters.lol_adapter import LoLAdapter +from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager + + +# ── Fakes ─────────────────────────────────────────────────────────────── + + +class _FakeBus: + def __init__(self) -> None: + self.published: list[Any] = [] + + def publish(self, event: Any) -> None: + self.published.append(event) + + +@dataclass +class _FakeConfig: + id: str + enabled: bool + adapter_type: str + adapter_config: dict = field(default_factory=dict) + + +class _FakePoller: + """Stand-in for LoLPoller so the manager test never spawns real threads.""" + + instances: list["_FakePoller"] = [] + + def __init__(self, adapter_config: dict, callback: Any) -> None: + self.adapter_config = adapter_config + self.callback = callback + self.started = False + self.stopped = False + _FakePoller.instances.append(self) + + def start(self) -> None: + self.started = True + + def stop(self) -> None: + self.stopped = True + + +@pytest.fixture +def fake_poller(monkeypatch): + _FakePoller.instances = [] + monkeypatch.setattr("ledgrab.core.game_integration.lol_poll_manager.LoLPoller", _FakePoller) + return _FakePoller + + +def _lol(id: str = "lol1", enabled: bool = True, **cfg) -> _FakeConfig: + return _FakeConfig(id=id, enabled=enabled, adapter_type="lol", adapter_config=cfg) + + +# ── process_payload ───────────────────────────────────────────────────── + + +class TestProcessPayload: + def setup_method(self): + runtime_state.cleanup_state("pp_test") + + def test_publishes_parsed_events_and_records_stats(self): + bus = _FakeBus() + payload = { + "activePlayer": { + "championStats": {"currentHealth": 50, "maxHealth": 100}, + "summonerName": "Me", + "level": 9, + }, + "allPlayers": [], + } + events = runtime_state.process_payload("pp_test", LoLAdapter, {}, payload, bus) + + assert len(events) >= 1 + assert any(e.event_type == "health" and abs(e.value - 0.5) < 1e-6 for e in events) + assert len(bus.published) == len(events) + assert runtime_state.get_stats("pp_test")["event_count"] == len(events) + + def test_swallows_parse_errors(self): + bus = _FakeBus() + + class _Boom: + ADAPTER_TYPE = "boom" + + @classmethod + def parse_payload(cls, *a): + raise ValueError("bad frame") + + events = runtime_state.process_payload("pp_test", _Boom, {}, {}, bus) + assert events == [] + assert bus.published == [] + + +# ── LoLPollManager lifecycle ──────────────────────────────────────────── + + +class TestLoLPollManager: + def test_sync_starts_poller_for_enabled_lol(self, fake_poller): + mgr = LoLPollManager(_FakeBus()) + mgr.sync([_lol()]) + assert mgr.active_count == 1 + assert fake_poller.instances[-1].started is True + + def test_sync_ignores_non_lol_and_disabled(self, fake_poller): + mgr = LoLPollManager(_FakeBus()) + mgr.sync( + [ + _FakeConfig("cs", True, "cs2", {}), + _lol("off", enabled=False), + ] + ) + assert mgr.active_count == 0 + assert fake_poller.instances == [] + + def test_sync_is_idempotent_for_unchanged_config(self, fake_poller): + mgr = LoLPollManager(_FakeBus()) + mgr.sync([_lol(poll_interval_ms=500)]) + first = fake_poller.instances[-1] + mgr.sync([_lol(poll_interval_ms=500)]) + assert mgr.active_count == 1 + assert len(fake_poller.instances) == 1 # not restarted + assert first.stopped is False + + def test_sync_restarts_on_config_change(self, fake_poller): + mgr = LoLPollManager(_FakeBus()) + mgr.sync([_lol(poll_interval_ms=500)]) + first = fake_poller.instances[-1] + mgr.sync([_lol(poll_interval_ms=1000)]) + assert first.stopped is True + assert len(fake_poller.instances) == 2 + assert mgr.active_count == 1 + + def test_sync_stops_when_removed_or_disabled(self, fake_poller): + mgr = LoLPollManager(_FakeBus()) + mgr.sync([_lol()]) + poller = fake_poller.instances[-1] + mgr.sync([]) # integration gone + assert poller.stopped is True + assert mgr.active_count == 0 + + def test_stop_all(self, fake_poller): + mgr = LoLPollManager(_FakeBus()) + mgr.sync([_lol("a"), _lol("b")]) + assert mgr.active_count == 2 + mgr.stop_all() + assert mgr.active_count == 0 + assert all(p.stopped for p in fake_poller.instances) + + def test_callback_routes_polled_data_through_process_payload(self, fake_poller): + bus = _FakeBus() + mgr = LoLPollManager(bus) + mgr.sync([_lol("cbtest")]) + callback = fake_poller.instances[-1].callback + callback( + { + "activePlayer": { + "championStats": {"currentHealth": 80, "maxHealth": 100}, + "summonerName": "Me", + "level": 1, + }, + "allPlayers": [], + } + ) + assert any(e.event_type == "health" for e in bus.published) diff --git a/server/tests/test_nanoleaf_extcontrol.py b/server/tests/test_nanoleaf_extcontrol.py new file mode 100644 index 0000000..b3aece3 --- /dev/null +++ b/server/tests/test_nanoleaf_extcontrol.py @@ -0,0 +1,89 @@ +"""Tests for Nanoleaf extControl v2 per-panel streaming. + +The device-side (panelLayout fetch, extControl enable, UDP send) needs a real +controller to validate; here we lock down the parts that DON'T: panel +ordering, strip→panel resampling, the exact UDP packet framing, and the +``nanoleaf_per_panel`` config round-trip through the device store. +""" + +from __future__ import annotations + +import struct + +from ledgrab.core.devices.nanoleaf_client import ( + build_extcontrol_v2_packet, + map_pixels_to_panels, + order_panels, +) +from ledgrab.storage.device_store import Device + + +class TestOrderPanels: + def test_sorts_by_x_then_y_and_drops_controller(self): + position = [ + {"panelId": 5, "x": 100, "y": 0}, + {"panelId": 3, "x": 0, "y": 0}, + {"panelId": 0, "x": 50, "y": 50}, # controller / rhythm → dropped + {"panelId": 7, "x": 0, "y": 100}, + ] + assert order_panels(position) == [3, 7, 5] + + def test_ignores_non_integer_panel_ids(self): + assert order_panels([{"panelId": "x", "x": 0, "y": 0}, {"x": 1, "y": 1}]) == [] + + +class TestMapPixelsToPanels: + def test_nearest_neighbour_resample(self): + pixels = [[10, 10, 10], [20, 20, 20], [30, 30, 30], [40, 40, 40]] + out = map_pixels_to_panels(pixels, [1, 2]) + assert out == [(1, 10, 10, 10), (2, 30, 30, 30)] + + def test_empty_strip_is_black(self): + assert map_pixels_to_panels([], [9, 8]) == [(9, 0, 0, 0), (8, 0, 0, 0)] + + def test_more_panels_than_pixels_repeats(self): + out = map_pixels_to_panels([[255, 0, 0]], [1, 2, 3]) + assert out == [(1, 255, 0, 0), (2, 255, 0, 0), (3, 255, 0, 0)] + + +class TestPacket: + def test_framing_is_byte_exact(self): + panels = [(100, 10, 20, 30), (200, 40, 50, 60)] + pkt = build_extcontrol_v2_packet(panels) + assert len(pkt) == 2 + 2 * 8 # uint16 header + 8 bytes/panel + assert struct.unpack(">H", pkt[0:2])[0] == 2 + pid, r, g, b, w, trans = struct.unpack(">HBBBBH", pkt[2:10]) + assert (pid, r, g, b, w, trans) == (100, 10, 20, 30, 0, 1) + pid2, r2, g2, b2, w2, trans2 = struct.unpack(">HBBBBH", pkt[10:18]) + assert (pid2, r2, g2, b2, w2, trans2) == (200, 40, 50, 60, 0, 1) + + def test_values_are_masked_to_byte_range(self): + pkt = build_extcontrol_v2_packet([(70000, 300, -5, 256)]) + pid, r, g, b, w, trans = struct.unpack(">HBBBBH", pkt[2:10]) + assert pid == 70000 & 0xFFFF + assert r == 300 & 0xFF and b == 256 & 0xFF + + +class TestConfigRoundTrip: + def _device(self, per_panel: bool) -> Device: + return Device( + device_id="d1", + name="Shapes", + url="nanoleaf://1.2.3.4", + led_count=10, + device_type="nanoleaf", + nanoleaf_token="tok", + nanoleaf_per_panel=per_panel, + ) + + def test_per_panel_round_trips_through_store(self): + d = self._device(True) + assert d.to_dict().get("nanoleaf_per_panel") is True + back = Device.from_dict(d.to_dict()) + assert back.nanoleaf_per_panel is True + assert back.to_config().nanoleaf_per_panel is True + + def test_default_off_is_omitted(self): + d = self._device(False) + assert "nanoleaf_per_panel" not in d.to_dict() + assert d.to_config().nanoleaf_per_panel is False diff --git a/server/tests/test_reactive_palette.py b/server/tests/test_reactive_palette.py new file mode 100644 index 0000000..1dcc365 --- /dev/null +++ b/server/tests/test_reactive_palette.py @@ -0,0 +1,181 @@ +"""Tests for audio-reactive palette modulation on procedural effects. + +Covers the model round-trip, the AudioEnergyTap (resolve/acquire/energy via +fakes), and the per-frame brightness/saturation modulation applied to a +rendered effect frame. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import numpy as np + +from ledgrab.core.processing.audio_energy_tap import AudioEnergyTap +from ledgrab.core.processing.effect_stream import EffectColorStripStream +from ledgrab.storage.color_strip_source import EffectColorStripSource + + +def _make_source(**overrides) -> EffectColorStripSource: + base = dict( + id="fx1", + name="fx", + source_type="effect", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + effect_type="plasma", + ) + base.update(overrides) + return EffectColorStripSource.create_from_kwargs(**base) + + +# ── Model ─────────────────────────────────────────────────────────────── + + +class TestModel: + def test_defaults(self): + s = _make_source() + assert s.audio_reactive is False + assert s.reactive_mode == "brightness" + assert s.reactive_audio_source_id == "" + + def test_round_trip_preserves_reactive_fields(self): + s = _make_source( + audio_reactive=True, + reactive_audio_source_id="as_42", + reactive_mode="both", + reactive_intensity=0.5, + ) + back = EffectColorStripSource.from_dict(s.to_dict()) + assert back.audio_reactive is True + assert back.reactive_audio_source_id == "as_42" + assert back.reactive_mode == "both" + assert back.reactive_intensity.value == 0.5 + + def test_apply_update_changes_reactive_fields(self): + s = _make_source() + s.apply_update(audio_reactive=True, reactive_mode="saturation", reactive_intensity=0.9) + assert s.audio_reactive is True + assert s.reactive_mode == "saturation" + assert s.reactive_intensity.value == 0.9 + + +# ── AudioEnergyTap ────────────────────────────────────────────────────── + + +class _Analysis: + def __init__(self, rms): + self.rms = rms + + +class _CaptureStream: + def __init__(self, rms=0.25): + self._rms = rms + + def get_latest_analysis(self): + return _Analysis(self._rms) + + +class _Resolved: + device_index = 3 + is_loopback = True + audio_template_id = "" + + +class _SourceStore: + def resolve_audio_source(self, sid): + return _Resolved() + + +class _Manager: + def __init__(self): + self.acquired = [] + self.released = [] + self.stream = _CaptureStream() + + def acquire(self, device, loopback, engine_type=None, engine_config=None): + self.acquired.append((device, loopback)) + return self.stream + + def release(self, device, loopback, engine_type=None): + self.released.append((device, loopback)) + + +class TestAudioEnergyTap: + def test_unavailable_without_manager(self): + tap = AudioEnergyTap(None) + assert tap.available is False + tap.start() + assert tap.energy() == 0.0 + + def test_configure_resolves_capture_params(self): + mgr = _Manager() + tap = AudioEnergyTap(mgr, _SourceStore()) + tap.configure("as_1") + tap.start() + assert tap.active is True + assert mgr.acquired == [(3, True)] + + def test_energy_smooths_rms(self): + mgr = _Manager() + tap = AudioEnergyTap(mgr, _SourceStore()) + tap.configure("as_1") + tap.start() + # rms 0.25 * gain 4 = 1.0 (clamped); EMA rises toward 1.0 + e1 = tap.energy() + e2 = tap.energy() + assert 0.0 < e1 < e2 <= 1.0 + + def test_stop_releases_and_resets(self): + mgr = _Manager() + tap = AudioEnergyTap(mgr, _SourceStore()) + tap.configure("as_1") + tap.start() + tap.stop() + assert tap.active is False + assert mgr.released == [(3, True)] + assert tap.energy() == 0.0 + + +# ── Modulation ────────────────────────────────────────────────────────── + + +class _FixedTap: + def __init__(self, energy): + self._e = energy + + def energy(self, smoothing=0.4): + return self._e + + +class TestModulation: + def _stream(self, mode, intensity, energy): + src = _make_source(audio_reactive=True, reactive_mode=mode, reactive_intensity=intensity) + st = EffectColorStripStream(src) + st._audio_tap = _FixedTap(energy) + return st + + def test_brightness_silence_dims_to_zero(self): + st = self._stream("brightness", 1.0, 0.0) + buf = np.array([[200, 100, 50]], dtype=np.uint8) + st._apply_audio_modulation(buf) + assert np.array_equal(buf, np.array([[0, 0, 0]], dtype=np.uint8)) + + def test_brightness_full_energy_preserves(self): + st = self._stream("brightness", 1.0, 1.0) + buf = np.array([[200, 100, 50]], dtype=np.uint8) + st._apply_audio_modulation(buf) + assert np.array_equal(buf, np.array([[200, 100, 50]], dtype=np.uint8)) + + def test_saturation_silence_desaturates_to_luminance(self): + st = self._stream("saturation", 1.0, 0.0) + buf = np.array([[200, 100, 50]], dtype=np.uint8) + st._apply_audio_modulation(buf) + # All channels collapse to the luminance value (greyscale). + assert buf[0, 0] == buf[0, 1] == buf[0, 2] + + def test_zero_intensity_is_noop(self): + st = self._stream("both", 0.0, 0.0) + buf = np.array([[200, 100, 50]], dtype=np.uint8) + st._apply_audio_modulation(buf) + assert np.array_equal(buf, np.array([[200, 100, 50]], dtype=np.uint8)) diff --git a/server/tests/test_review_fixes.py b/server/tests/test_review_fixes.py new file mode 100644 index 0000000..bcabb80 --- /dev/null +++ b/server/tests/test_review_fixes.py @@ -0,0 +1,334 @@ +"""Regression tests for the 2026-06-18 production-readiness review fixes. + +Each test maps to a confirmed finding from the review and would fail against +the pre-fix code. Grouped by area; see the inline finding tags (#N). +""" + +from __future__ import annotations + +import threading +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from ledgrab.storage.activity_log import ( + ActivityCategory, + ActivityLogEntry, + ActivityLogFilters, + ActivitySeverity, +) +from ledgrab.storage.activity_log_repository import ActivityLogRepository +from ledgrab.storage.database import Database + + +def _entry(*, ts: datetime | None = None, message: str = "m") -> ActivityLogEntry: + return ActivityLogEntry( + id="al_" + uuid.uuid4().hex[:8], + ts=ts or datetime.now(timezone.utc), + category=ActivityCategory.SYSTEM, + action="test.action", + severity=ActivitySeverity.INFO, + actor="system", + message=message, + ) + + +@pytest.fixture +def repo(tmp_db: Database) -> ActivityLogRepository: + return ActivityLogRepository(tmp_db) + + +# --------------------------------------------------------------------------- +# #8 — activity-log entry id has full 128-bit entropy +# --------------------------------------------------------------------------- + + +def test_new_id_uses_full_uuid_hex(): + from ledgrab.core.activity_log.recorder import _new_id + + val = _new_id() + assert val.startswith("al_") + hex_part = val[3:] + assert len(hex_part) == 32 # full uuid4 hex (was 8 → collision-prone) + int(hex_part, 16) # parses as hex + + +# --------------------------------------------------------------------------- +# #26 — sanitize_display honours its bounded-length contract for tiny maxlen +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("maxlen", [0, 1, 2, 5]) +def test_sanitize_display_respects_bound(maxlen): + from ledgrab.core.activity_log.sanitize import sanitize_display + + result = sanitize_display("abcdef", maxlen=maxlen) + assert len(result) <= maxlen + + +def test_sanitize_display_degenerate_maxlen_returns_empty(): + from ledgrab.core.activity_log.sanitize import sanitize_display + + assert sanitize_display("abcdef", maxlen=0) == "" + assert sanitize_display("abcdef", maxlen=-3) == "" + + +# --------------------------------------------------------------------------- +# #10 — non-ASCII Bearer / WS token does not raise from compare_digest +# --------------------------------------------------------------------------- + + +def test_match_api_key_non_ascii_token_returns_none_not_raises(): + from ledgrab.api.auth import _match_api_key + + with patch("ledgrab.api.auth.get_config") as cfg: + c = MagicMock() + c.auth.api_keys = {"dev": "correct-key"} + cfg.return_value = c + # café contains a non-ASCII char; must cleanly fail to match, not raise. + assert _match_api_key("café") is None + assert _match_api_key("correct-key") == "dev" + + +# --------------------------------------------------------------------------- +# #15 — game adapters tolerate non-ASCII attacker-controlled tokens +# --------------------------------------------------------------------------- + + +def test_generic_webhook_adapter_non_ascii_header_returns_false(): + from ledgrab.core.game_integration.adapters.generic_webhook_adapter import ( + GenericWebhookAdapter, + ) + + ok = GenericWebhookAdapter.validate_auth( + {"Authorization": "Bearer café"}, {}, {"auth_token": "secret123"} + ) + assert ok is False # no TypeError + + +def test_cs2_adapter_non_ascii_payload_token_returns_false(): + from ledgrab.core.game_integration.adapters.cs2_adapter import CS2Adapter + + ok = CS2Adapter.validate_auth({}, {"auth": {"token": "café"}}, {"auth_token": "secret123"}) + assert ok is False # no TypeError + + +# --------------------------------------------------------------------------- +# #2 — async auth dependency sets the actor ContextVar visibly to the handler +# --------------------------------------------------------------------------- + + +def test_async_auth_dep_actor_visible_to_handler(): + import asyncio + + from ledgrab.api.auth import verify_api_key + + # Guard against a regression back to a sync dependency (which would run the + # contextvar mutation in a throwaway threadpool context the handler can't see). + assert asyncio.iscoroutinefunction(verify_api_key) + + from fastapi import Depends, FastAPI + from fastapi.testclient import TestClient + + from ledgrab.core.activity_log.context import current_actor + + app = FastAPI() + + # Use Depends() as a default value rather than the ``AuthRequired`` Annotated + # alias: this module has ``from __future__ import annotations`` (stringized + # annotations), and the route is defined in a local scope FastAPI can't + # resolve the alias from — the default-value form sidesteps that entirely. + @app.get("/whoami") + async def whoami(auth=Depends(verify_api_key)): + return {"label": auth, "actor": current_actor.get()} + + with patch("ledgrab.api.auth.get_config") as cfg: + c = MagicMock() + c.auth.api_keys = {"dev": "k"} + cfg.return_value = c + client = TestClient(app) + resp = client.get("/whoami", headers={"Authorization": "Bearer k"}) + + assert resp.status_code == 200 + body = resp.json() + assert body["label"] == "dev" + # Pre-fix (sync dep) this would be "system"; the async dep makes it visible. + assert body["actor"] == "dev" + + +# --------------------------------------------------------------------------- +# #9 / #12 — auth-failure throttle dict survives concurrent access +# --------------------------------------------------------------------------- + + +def test_should_record_auth_failure_concurrent_no_exception(): + from ledgrab.api import auth as auth_mod + + auth_mod._auth_record_last.clear() + # Pre-fill to one below the hard cap so the eviction branch is hot. + cap = auth_mod._AUTH_THROTTLE_HARD_CAP + for i in range(cap - 1): + auth_mod._auth_record_last[f"seed-{i}"] = 0.0 + + errors: list[BaseException] = [] + + def hammer(base: int): + try: + for j in range(500): + auth_mod._should_record_auth_failure(f"{base}.{j}") + except BaseException as exc: # noqa: BLE001 - capture any escape + errors.append(exc) + + threads = [threading.Thread(target=hammer, args=(t,)) for t in range(16)] + for th in threads: + th.start() + for th in threads: + th.join(timeout=30) + + assert not errors, f"throttle raised under concurrency: {errors[:3]}" + assert len(auth_mod._auth_record_last) <= cap + auth_mod._auth_record_last.clear() + + +# --------------------------------------------------------------------------- +# #1 / #4 — since/until filter normalises naive + non-UTC-offset datetimes +# --------------------------------------------------------------------------- + + +def test_until_boundary_includes_entry_at_exact_instant(repo: ActivityLogRepository): + # Entry stored at exactly 12:00 UTC. + instant = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc) + repo.record(_entry(ts=instant, message="boundary")) + + # A naive datetime-local value at the same wall-clock (no offset) — the + # realistic frontend path. Pre-fix this lexically excluded the row. + naive_until = datetime(2026, 6, 18, 12, 0, 0) # noqa: DTZ001 - intentional naive + page = repo.query(ActivityLogFilters(until=naive_until), limit=10) + assert len(page) == 1 + + +def test_since_with_non_utc_offset_includes_correct_instant(repo: ActivityLogRepository): + # Stored at 13:30 UTC. + repo.record(_entry(ts=datetime(2026, 6, 18, 13, 30, tzinfo=timezone.utc), message="x")) + + # since expressed in +02:00 == 13:00 UTC → row (13:30Z) must be included. + since_incl = datetime(2026, 6, 18, 15, 0, tzinfo=timezone(timedelta(hours=2))) + assert len(repo.query(ActivityLogFilters(since=since_incl), limit=10)) == 1 + + # since == 16:00+02:00 == 14:00 UTC → row (13:30Z) must be excluded. + since_excl = datetime(2026, 6, 18, 16, 0, tzinfo=timezone(timedelta(hours=2))) + assert len(repo.query(ActivityLogFilters(since=since_excl), limit=10)) == 0 + + +# --------------------------------------------------------------------------- +# #21 — iter_export advances the keyset cursor correctly across batches +# --------------------------------------------------------------------------- + + +def test_iter_export_multi_batch_no_gaps_or_dupes(repo: ActivityLogRepository): + n = 7 + for i in range(n): + repo.record(_entry(message=f"e{i}")) + + # batch_size=2 over 7 rows → 4 batches, exercising cursor advancement. + exported = list(repo.iter_export(batch_size=2)) + ids = [e.id for e in exported] + assert len(ids) == n + assert len(set(ids)) == n # no duplicates across batch boundaries + # Identical set to a single-batch run. + assert set(ids) == {e.id for e in repo.iter_export(batch_size=1000)} + + +# --------------------------------------------------------------------------- +# #13 — undecryptable secret envelope is preserved, not discarded +# --------------------------------------------------------------------------- + + +def test_decrypt_failure_preserves_envelope(monkeypatch): + from ledgrab.storage import game_integration as gi + + envelope = "ENC:v1:undecryptable-blob" + monkeypatch.setattr(gi.secret_box, "is_encrypted", lambda v: v == envelope) + + def _boom(_v): + raise ValueError("secret key missing") + + monkeypatch.setattr(gi.secret_box, "decrypt", _boom) + + result = gi._decrypt_adapter_config({"auth_token": envelope, "other": "x"}) + # Pre-fix: result["auth_token"] == "" (data loss on the next write-through). + assert result["auth_token"] == envelope + assert result["other"] == "x" + + +# --------------------------------------------------------------------------- +# #22 — real-thread / real-DB concurrency on the repository +# --------------------------------------------------------------------------- + + +def test_repo_concurrent_writes_are_consistent(repo: ActivityLogRepository): + threads_n, per_thread = 8, 100 + total = threads_n * per_thread + + def worker(): + for _ in range(per_thread): + repo.record(_entry()) + + threads = [threading.Thread(target=worker) for _ in range(threads_n)] + for th in threads: + th.start() + for th in threads: + th.join(timeout=60) + + assert repo.count() == total + exported = list(repo.iter_export(batch_size=50)) + assert len(exported) == total + assert len({e.id for e in exported}) == total # unique, no corruption + + +# --------------------------------------------------------------------------- +# #23 — CSV export strips control chars from string cells (defense-in-depth) +# --------------------------------------------------------------------------- + + +def test_csv_export_strips_control_chars(repo: ActivityLogRepository): + import csv + import io + + from ledgrab.api.routes.activity_log import _CSV_COLUMNS, _export_csv_generator + + evil = "evil\x00dev\x1b[31mred\x1b[0m\r\ninject" + repo.record(_entry(message=evil)) + + text = b"".join(_export_csv_generator(repo, ActivityLogFilters())).decode("utf-8") + rows = list(csv.reader(io.StringIO(text))) + assert len(rows) == 2 # header + 1 data row (newline did not split the field) + msg_cell = rows[1][_CSV_COLUMNS.index("message")] + for bad in ("\x00", "\x1b", "\r", "\n"): + assert bad not in msg_cell, f"control char {bad!r} survived into the CSV cell" + + +# --------------------------------------------------------------------------- +# #44 — non-serialisable metadata is dropped best-effort, never raises +# --------------------------------------------------------------------------- + + +def test_non_serializable_metadata_does_not_raise(): + from ledgrab.core.activity_log.recorder import ActivityRecorder + + persisted: list = [] + repo = MagicMock() + # Route through to_row() so the real json.dumps codec runs (and raises). + repo.record.side_effect = lambda entry: persisted.append(entry.to_row()) + recorder = ActivityRecorder(repo, MagicMock(), loop=None) + + # Must not raise into the caller despite the un-encodable values. + recorder.record( + category=ActivityCategory.SYSTEM, + action="bad.metadata", + message="m", + metadata={"when": datetime.now(timezone.utc), "tags": {1, 2, 3}}, + ) + assert persisted == [], "non-serialisable entry must not persist" diff --git a/server/tests/test_solar_rule.py b/server/tests/test_solar_rule.py new file mode 100644 index 0000000..40437d7 --- /dev/null +++ b/server/tests/test_solar_rule.py @@ -0,0 +1,188 @@ +"""Tests for SolarRule (sunrise/sunset automation trigger). + +Covers the data model (round-trip + input validation), the engine's +``_evaluate_solar`` window logic (sunrise/sunset patched to fixed hours so the +test is location/clock-independent), the shared solar math, and the API +schema ↔ Rule mapping. +""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import patch + +from ledgrab.api.routes.automations import _rule_from_schema, _rule_to_schema +from ledgrab.api.schemas.automations import RuleSchema +from ledgrab.core.automations import automation_engine +from ledgrab.core.automations.automation_engine import AutomationEngine +from ledgrab.storage.automation import Rule, SolarRule +from ledgrab.utils.solar import compute_solar_times + +# Fixed solar times the engine math is patched to: sunrise 06:00, sunset 19:00. +_SUNRISE_MIN = 6 * 60 # 360 +_SUNSET_MIN = 19 * 60 # 1140 + + +# ── Data model ────────────────────────────────────────────────────────── + + +class TestSolarRuleModel: + def test_defaults_are_night_window(self): + r = SolarRule() + assert r.rule_type == "solar" + assert r.start_event == "sunset" + assert r.end_event == "sunrise" + assert r.start_offset_minutes == 0 and r.end_offset_minutes == 0 + + def test_to_dict_from_dict_round_trip(self): + r = SolarRule( + start_event="sunrise", + start_offset_minutes=-30, + end_event="sunset", + end_offset_minutes=45, + latitude=51.5, + longitude=-0.12, + days_of_week=[5, 6], + timezone="Europe/London", + ) + back = SolarRule.from_dict(r.to_dict()) + assert back == r + + def test_from_dict_dispatches_via_base_rule(self): + d = {"rule_type": "solar", "start_event": "sunrise"} + r = Rule.from_dict(d) + assert isinstance(r, SolarRule) + assert r.start_event == "sunrise" + + def test_invalid_event_falls_back_to_default(self): + r = SolarRule.from_dict({"start_event": "noon", "end_event": ""}) + assert r.start_event == "sunset" + assert r.end_event == "sunrise" + + def test_offsets_are_clamped(self): + r = SolarRule.from_dict({"start_offset_minutes": 9000, "end_offset_minutes": -9000}) + assert r.start_offset_minutes == 1439 + assert r.end_offset_minutes == -1439 + + def test_offsets_handle_non_numeric(self): + r = SolarRule.from_dict({"start_offset_minutes": None, "end_offset_minutes": "x"}) + assert r.start_offset_minutes == 0 and r.end_offset_minutes == 0 + + def test_coords_are_clamped(self): + r = SolarRule.from_dict({"latitude": 200.0, "longitude": -400.0}) + assert r.latitude == 90.0 + assert r.longitude == -180.0 + + def test_days_of_week_filtered_and_sorted(self): + r = SolarRule.from_dict({"days_of_week": [6, 0, 9, -1, "x", 3, 3]}) + assert r.days_of_week == [0, 3, 6] + + +# ── Engine evaluation ─────────────────────────────────────────────────── + + +def _eval_at(dt: datetime, **rule_kwargs) -> bool: + rule = SolarRule.from_dict(rule_kwargs) + with ( + patch.object(automation_engine, "compute_solar_times", return_value=(6.0, 19.0)), + patch.object(automation_engine, "_now_in_tz", return_value=dt), + ): + return AutomationEngine._evaluate_solar(rule) + + +class TestSolarEvaluation: + def test_night_window_active_after_sunset(self): + # default sunset→sunrise; 22:00 is inside the evening portion + assert _eval_at(datetime(2026, 6, 15, 22, 0)) is True + + def test_night_window_active_before_sunrise(self): + # 03:00 is inside the after-midnight tail + assert _eval_at(datetime(2026, 6, 15, 3, 0)) is True + + def test_night_window_inactive_at_noon(self): + assert _eval_at(datetime(2026, 6, 15, 12, 0)) is False + + def test_day_window_active_at_noon(self): + assert ( + _eval_at(datetime(2026, 6, 15, 12, 0), start_event="sunrise", end_event="sunset") + is True + ) + + def test_day_window_inactive_at_night(self): + assert ( + _eval_at(datetime(2026, 6, 15, 23, 0), start_event="sunrise", end_event="sunset") + is False + ) + + def test_start_offset_shifts_boundary_earlier(self): + # sunset-30 = 18:30; the window opens earlier + assert _eval_at(datetime(2026, 6, 15, 18, 45), start_offset_minutes=-30) is True + assert _eval_at(datetime(2026, 6, 15, 18, 15), start_offset_minutes=-30) is False + + def test_weekday_restriction_evening(self): + dt = datetime(2026, 6, 15, 22, 0) # evening portion → today's weekday + assert _eval_at(dt, days_of_week=[dt.weekday()]) is True + assert _eval_at(dt, days_of_week=[(dt.weekday() + 1) % 7]) is False + + def test_weekday_restriction_overnight_tail_uses_previous_day(self): + dt = datetime(2026, 6, 15, 3, 0) # after-midnight tail → previous weekday + assert _eval_at(dt, days_of_week=[(dt.weekday() - 1) % 7]) is True + assert _eval_at(dt, days_of_week=[dt.weekday()]) is False + + +# ── Shared solar math ─────────────────────────────────────────────────── + + +class TestComputeSolarTimes: + def test_midlatitude_summer_has_sane_window(self): + sunrise, sunset = compute_solar_times(50.0, 0.0, 172, 0.0) # ~solstice + assert 0.5 <= sunrise <= 11.5 + assert 12.5 <= sunset <= 23.5 + assert sunrise < sunset + + def test_polar_winter_is_clamped_not_degenerate(self): + # High latitude, mid-winter: raw equations collapse, but the clamp + # keeps sunrise < sunset so the trigger never sees an empty window. + sunrise, sunset = compute_solar_times(80.0, 0.0, 355, 0.0) + assert sunrise <= 11.5 + assert sunset >= 12.5 + assert sunrise < sunset + + +# ── API schema mapping ────────────────────────────────────────────────── + + +class TestSolarSchemaMapping: + def test_schema_to_rule_and_back_preserves_fields(self): + schema = RuleSchema( + rule_type="solar", + start_event="sunrise", + start_offset_minutes=-15, + end_event="sunset", + end_offset_minutes=15, + latitude=51.5, + longitude=-0.1, + days_of_week=[5, 6], + timezone="Europe/London", + ) + rule = _rule_from_schema(schema) + assert isinstance(rule, SolarRule) + assert rule.start_event == "sunrise" + assert rule.end_offset_minutes == 15 + assert rule.latitude == 51.5 + + # Round-trip back to schema must NOT drop the solar fields. + out = _rule_to_schema(rule) + assert out.start_event == "sunrise" + assert out.end_event == "sunset" + assert out.start_offset_minutes == -15 + assert out.latitude == 51.5 + assert out.timezone == "Europe/London" + + def test_schema_to_rule_tolerates_missing_solar_fields(self): + # A bare solar schema (all optional fields None) must still build a + # valid default night-window rule. + rule = _rule_from_schema(RuleSchema(rule_type="solar")) + assert isinstance(rule, SolarRule) + assert rule.start_event == "sunset" + assert rule.end_event == "sunrise"